Repository navigation
1.9.79: company linking walks past names it cannot link - #304
Merged
Merged
Conversation
MysqlCompanyRepository::linkUnlinkedCustomers() took up to $limit of an account's customers with a company string and no company_id, in no order, and skipped the ones whose string canonicalName() reduces to '' (a tab, a no-break or ideographic space). A skip wrote nothing, so the next run took the same rows first: the cron asks for 2,000 per account, and an account with more than 2,000 such customers never had anyone else linked (each run reported 0 attached). - The sweep walks the account's customers in customer_id order with a cursor, until $limit are linked or none are left, so a return below $limit means the account is done (the contract the cron's loop in #285 reads). - A string that names nothing is stored as '' in one guarded UPDATE per batch (still unlinked, still holding the string read). '' is what clearing an unlinked string writes, and keeps the one reader that tells NULL from '' (domain auto-attach) answering as it did for the blank string. Doing it in SQL instead would need a second reading of canonicalName() that MariaDB, MySQL and PHP disagree on. - The select names its index: left alone, MariaDB 10.11 served the cursor from merged_into (every account's customers in id order) and read 102,000 rows to return 2,000; FORCE INDEX (user_company) reads 2,000. - A company id of 0 from findByName() or resolveOrCreate() was skipped the same way in the sweep (taken again every run) and in attachCustomer() (a CRM save answered done with the company unattached). Both now throw a QueryException naming the company and account. The cron's per-account probe asks the select's predicate without the cursor; once the blank strings are stored as '' it stops finding them, so the two agree and the cron no longer calls the sweep for nothing. Tests: CompanyLinkingIntegrationTest seeds 2,100 unlinkable names ahead of a linkable one and runs the cron's call (2,000); it fails on the old code (0 linked). CompanyTest covers the cleared statement, the cursor binds and the refusals. CLAUDE.md #37 records the shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Contributor
Contributor
This was referenced Oct 28, 2026
Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, changelogs.txt, where both sides added lines: the incoming lines are kept as they are and this branch's follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backlog: 4J3V
The defect
MysqlCompanyRepository::linkUnlinkedCustomers()took up to$limitof an account's customers that have a company string and nocompany_id. The select had noORDER BYand no cursor. It skipped, in PHP, every row whose stringcanonicalName()reduces to''(a tab, a newline, a no-break space, an ideographic space, NUL). A skip wrote nothing, so the next run's select took the same rows first. The cron asks for 2,000 per account, so an account with more than 2,000 such rows never had anyone else linked, and every run reportedcompany linking: 0 customers attached.Reproduced both ways:
202-cronjobs/ltv_maintenance.phpprinted0 customers attachedon every run against the old code.What changed
customer_id > ? ORDER BY customer_id. A call walks batches of$limituntil$limitcustomers are linked or none are left, so a return below$limitmeans the account has nothing left to link. That is the contract 1.9.79: the cron job sweeps lapsed LTV subscriptions hourly and runs the rest of LTV maintenance daily #285'sdo { … } while ($linked === COMPANY_BATCH)loop reads; the old code broke it whenever a batch held a blank.'', not skipped in SQL. Each batch does this in one guarded UPDATE (company_id IS NULL AND customer_id IN (…) AND company IN (<the strings as read>),updated_atbumped), so they leave the selection for good. Why''rather than NULL or an SQL filter:canonicalName(). PHP's/\s+/u(with UCP) plustrim()(which also strips NUL), MariaDB's PCRE2 and MySQL 8's ICUREGEXPeach disagree somewhere (NEL, NUL, U+200B). If SQL read a row as linkable where PHP reads blank, we are back to the bug; in the other direction, a real name would never link ([Update] Handle missing case parameter #29: one stored value, one parser).''? Taking [Setup] Remove undefined variable from template calls #25's readers of the column one by one:company IS NULL(domain auto-attach eligibility, and the CRM detach'sIF) is the only reader that tells NULL from''. It already read a blank string as "not NULL", so not eligible.''keeps its answer; NULL would make these customers newly eligible for domain auto-attach.company IS NOT NULL AND company <> ''(the sweep, the cron probe, the ABM report,deleteRefusal()) reads both as none.''is also exactly what clearing an unlinked string on the customer page writes.utf8mb4_general_ciis PAD SPACE, so' ' <> ''is false (measured).GET /ltv/abm. Measured live: four such groups (tab, newline, NBSP, ideographic space) before the sweep, none after.GET /ltv/customers/{id}reads their company as"".POST /ltv/customerswith"company": " "stores NULL. These are legacy rows.merged_into:merged_into_customer_id IS NULLholds for nearly every customer of every account, and that index also yields id order. With 100,000 other-account customers below this account's ids, it read 102,000 rows to return 2,000.FORCE INDEX (user_company)uses a range onuser_id+company_id+customer_id(InnoDB's implied primary key suffix) and reads 2,000. No new index or upgrade step is needed.user_companycomes from the 1.9.70 rung and the table definition, added together withcompany_idin one ALTER.findByName()can return a row with company id 0, andresolveOrCreate()can return no insert id. The sweep skipped either case the same way, so the customer would be taken again on every run (Fix newline at end of index #11).attachCustomer()returned silently on it, so a CRM save answered as done with the company unattached (Replace all <? as <?php. #1, Upgrade to PHP 8 #5 sibling). Both now throw aQueryExceptionnaming the company and the account:stampCompanyOnCreate()already catches it and logs it.resolveOrCreate()itself is unchanged: the Companies page's "add" ignores the id, and its INSERT did land ([Core] Update PHP version check for PHP 8 #13).The cron's probe
The probe in
ltv_maintenance.php, and the one in #285'sLtvMaintenance::companies(), asks the select's predicate without the cursor. Before this change it found blank strings forever, so the cron called the sweep for them on every run. After the first walk stores them as'', the probe stops finding them, so the probe and the select now ask the same question.ltv_maintenance.phpis not changed, and this PR is not stacked on #285.Interaction with #285 to check: its deadline is asked between calls. A single call links at most 2,000 but walks past any number of blank rows (one bulk UPDATE per 2,000). That is a one-time cleanup per row, but the first call on an account with a very large blank backlog runs past the deadline until it is through them.
Measurements (MariaDB 10.11, local)
merged_into)FORCE INDEX (user_company), first batchindex_merge, unordered)index_merge)The probe's steady-state cost (reading every company-less customer of the account) is the same before and after this change.
Tests and plants
tests/Ltv/CompanyLinkingIntegrationTest.php(@group integration, picked up bytests/integration-suites.php):' Acme Corp ', run with the cron's 2,000.tests/Ltv/CompanyTest.php: the cleared statement and its binds, the cursor binds, and the three refusals.testCrmUpsertResolvesCompanyEntityForCompanyNamenow expects the refusal, because the shared fake cannot report an insert id. The success path is covered by the integration suites.mktempbackup checked withcmp, and restored the same way:testMoreThanABatchOfUnlinkableNamesAhead…(0 linked, expected 1),testACallLinksUpToItsLimit…testLinkSweepRefusesAnInsertThatAnswersNoCompanyId,…AnEntityRowWithNoCompanyIdtestLinkSweepStampsTheEntityAndStoresBlankNamesAsNone+ 2 integration testsattachCustomer()returns on an id of 0testCrmUpsertResolves…,testAttachCustomerRefuses…testACallLinksUpToItsLimitInIdOrderAndTheNextGoesOnORDER BYtestLinkSweepStampsTheEntityAndStoresBlankNamesAsNonecustomer_id > ? * 0)The missed plant is expected. With every taken row leaving the selection, the cursor is not needed for correctness: it keeps one call from re-reading rows it has walked, and it bounds the loop if a guarded write ever misses. I did not add an engine-counter assertion (
Handler_read_next), because I cannot run it on CI's MySQL 8.0 here.Verification
.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed(PHP 8.3.6, full vendor/):(unit: 6107 tests, 0 failures, 4 skipped: the documented "User class requires DB singleton" skips)
Integration, each against a database created for the run (MariaDB 10.11):
CompanyLinkingIntegrationTest: OK (3 tests).LtvDatabaseIntegrationTest: OK (43 tests).Live, end to end (without the licence):
install.phpitself checks the key ("We couldn't confirm that license key"). The instance was installed by a scratch script that runsinstall.php's own steps after that check:INSTALL::install_databases(),InstallAccount::create(), and a202_api_keysrow. It was served withphp -Sthrough the CI router.POST /ltv/companiescreated Acme Corp (acme.com).POST /ltv/customerswith an ideographic-space company stored NULL.'Legacy Co 'and'acme corp'.GET /ltv/abmlisted four blank-named companies.php 202-cronjobs/ltv_maintenance.phpprintedcompany linking: 0 customers attachedon two runs.2 customers attached, then0on the next run.''; Legacy Co created and linked; acme corp linked to Acme Corp; the API's NULL customer still NULL.GET /ltv/abmlists only Acme Corp and Legacy Co.GET /ltv/companiesshows one contact each.PATCH /ltv/customers/{id}withpat@acme.comand no company answeredcompany: ""and did not attach, as before for the blank string.Not run:
FORCE INDEXplan and PAD SPACE behaviour there are read from the docs, not measured.abmBreakdown()was read throughGET /ltv/abm.tests/live/upgrade-equals-install.sh: there is no schema change.For a reviewer to check
''vs NULL for the stored-as-none rows (above).updated_at. The link UPDATE beside it does not, and never did. I left that as it was.QueryExceptioninattachCustomer()changes a CRM save with an unresolvable company id from a silent 200 to a 500. That needs a driver answering no insert id, or a202_companiesrow with id 0.Generated by Claude Code