Repository navigation
1.9.79: company linking stamps the customer's updated_at - #311
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
MysqlCompanyRepository::linkUnlinkedCustomers() set company_id and company on a customer whose company was stored before the company existed and left updated_at as it was. Every other write of a customer's company sets it (attachCustomer(), a rename, a merge, the CRM detach, #304's clear of a blank name, reconcileAttachedStrings()), so a customer the LTV maintenance job attached answered the time of its last save as its updated_at on GET /ltv/customers/{id}, and read as unchanged to anything asking what changed since. Measured on a live instance: before, the linked customer kept updated_at 1700000000 after the cron attached it; after, it is the time of the run. - The link sets updated_at = time(), the clock the rest of the sweep uses (clearBlankCompanies() and reconcileAttachedStrings() bind time(); every other writer binds a time() its caller took; nothing writes the column with UNIX_TIMESTAMP()). - CustomerWritesStampUpdatedAtTest walks every served SQL string and holds each UPDATE of 202_customers to assigning updated_at for each alias of the table it writes, and each INSERT or REPLACE to naming it (and assigning it in an ON DUPLICATE KEY UPDATE). It finds 21 writes; the one SET built at run time (applyCrmFields()) and the UPDATEs whose table is built at run time are listed with what decides them, and the CRUD writers' tables are read by reflection. It fails on the link as it was, and on the cron's rollup reconcile and the customer INSERT with the column removed. - CompanyLinkingIntegrationTest runs the sweep on customers last written at 1 and asks which have updated_at at or after a time taken before it: the linked customer and the cleared blank name, through the predicate a list's updated_since applies and through the repository call that serves GET /ltv/customers/{id}; the untouched ones keep 1. It fails with the old link, and with #304's clear missing its stamp. - CLAUDE.md #5 records that a row's change stamp is a sweep over its writers, and that only 202_customers is held to it. Neither reader the item named exists: GET /ltv/customers takes no updated_since (it answers every customer, measured), and no sync carries LTV customers. Both are noted in the PR as findings. 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
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
Brings in ltv-company-linking-cursor (this branch's stacked base), and through it 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, 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: S6C6
Stacked on #304 (
1.9.79-ltv-company-linking-cursor, backlog 4J3V). The base is1.9.79, so until #304 merges this diff also shows #304's commit (27e092a); the change here is the one commit on top of it. Merge after #304.What changed and why
MysqlCompanyRepository::linkUnlinkedCustomers()(the LTV maintenance job's company-linking sweep) setcompany_idandcompanyon a customer and leftupdated_atas it was. Every other write of a customer's company sets it. So a customer the job attached kept theupdated_atof its last save onGET /ltv/customers/{id}(p202 ltv customers <id>), and read as unchanged to anything asking what changed since.updated_at = time(). That is the clock the rest of the company sweep uses:clearBlankCompanies()(1.9.79: company linking walks past names it cannot link #304) andreconcileAttachedStrings()bindtime(). Every other writer binds atime()its caller took, the cron's{$now}included. Nothing writes the column withUNIX_TIMESTAMP()or an injected clock.reconcileAttachedStrings()already setupdated_atwithtime(), so it is unchanged.tests/Ltv/CustomerWritesStampUpdatedAtTestwalks every served SQL string as PHP assembles it (AssembledStrings):UPDATEof202_customersmust assignupdated_atfor each alias of the table it writes (self-joins, derived tables and joined-only use are read).INSERT/REPLACEmust name it, and assign it in anyON DUPLICATE KEY UPDATE.applyCrmFields(), which appendsupdated_at = ?to every SET); it is listed with that reason. Statements whose table is built at run time are listed per file with what names their tables; the CRUD writers' tables are read by reflection (RuntimeWriteTables).%s) is listed, never read as another table. An unqualified assignment in a multi-table UPDATE is refused.CompanyLinkingIntegrationTest::testWhatTheSweepWritesReadsAsChangedSinceBeforeItruns the sweep on customers last written at 1, then asks which haveupdated_at >= <time before the sweep>:updated_sinceapplies (Controller::list()), and throughMysqlCustomerCrmRepository::get(), the call that servesGET /ltv/customers/{id}.''row and the other account's row keep 1.changelogs.txtand202-config/changelogs.json. One sentence indocumentation/api/20-ltv.md(1.9.79: company linking walks past names it cannot link #304's paragraph). A short paragraph in CLAUDE.md Upgrade to PHP 8 #5 saying the floor covers202_customersonly.Plants: each was put in, the named test failed, and the file was restored and compared with
cmpupdated_at)CustomerWritesStampUpdatedAtTest(MysqlCompanyRepository.php:738 without updated_at),CompanyTest::testLinkSweepStampsTheEntityAndStoresBlankNamesAsNoneupdated_atCustomerWritesStampUpdatedAtTest(:773)c.updated_at = {$now}CustomerWritesStampUpdatedAtTest(ltv_maintenance.php:174)insertCustomer()namingmodified_atforupdated_atCustomerWritesStampUpdatedAtTest(MysqlCustomerRepository.php:748)Every
UPDATE 202_customers(and its one INSERT)updated_atMysqlCompanyRepository::attachCustomer(), by name (:227)$now(time())attachCustomer(), by email domain (:255)$nowupdate()rename (:471)time()merge()(:542)time()linkUnlinkedCustomers()link (:738)time()clearBlankCompanies()(#304, :773)time()reconcileAttachedStrings()(:814)time()MysqlCustomerCrmRepository::merge()target (:339)merge()source (:365)merge()earlier merges re-pointed (:378)erase()(:416)reconcileCustomer()(:532)applyCrmFields()(:657, SET built at run time)syncCompanyLink()detach (:699)MysqlCustomerRepository::adjustRollups()(:462)MysqlCustomerRepository::insertCustomer()INSERT (:748)MysqlEngagementRepositoryengagement touch (:77)MysqlSubscriptionRepository::refreshCustomerSubscriptionRollups()(:514)static-endpoint-helpers.phpengagement touch (:328)ltv_maintenance.phpsubscription sweep refresh (:140)$nowltv_maintenance.phprollup reconcile (:174)$now, and on rows that did not change (finding below)The two readers the item named
GET /ltv/customers?updated_since=. This filter does not exist.LtvController::customers()readslimit,offset,sort,dir,segment,qand the LTV window, and ignores everything else. Measured live:?updated_since=<now>answered 200 with all 3 customers. Reported as a finding, not added here.grep -rn "ltv" 202-config/Sync go-cli/cmd/sync*finds nothing:202-config/Syncdoes not exist andgo-cli/cmd/sync.gonames no LTV entity.SyncEngine::ENTITY_ENDPOINTScarries only these: aff-networks, ppc-networks, ppc-accounts, campaigns, landing-pages, text-ads, rotators, trackers. No sync profile runs against LTV customers, so there is nothing to show picking one up.updated_atonGET /ltv/customers/{id}(p202 ltv customers <id>). The integration test and the live pass read that one, plus theupdated_at >= ?predicate a future filter would apply.Live end-to-end (port 8165, database
p202_s6c6_live)Without the licence. The licence stub is down and
install.phprefuses the install at its licence check ("We couldn't confirm that license key"). So I raninstall.php's own steps without that check, from a scratch script:INSTALL::install_databases(),InstallAccount::create(), the202_api_keysinsert andinstall_database_partitions().php -Sran with every proxy variable pointed at 127.0.0.1:9. The v3 API answered without the licence.Steps:
POST /api/v3/ltv/customers(company "Acme Corp").company_id = NULLwithcompany = 'Initech Inc', andcompany_id = NULLwithcompany = "\t". All three gotupdated_at = last_activity_time = 1700000000, outside the reconcile's 48h window.php 202-cronjobs/ltv_maintenance.php, then read each customer withGET /api/v3/ltv/customers/{id}.Results:
{"customer_id":1,"company":"Initech Inc","company_id":2,"updated_at":1700000000}. The customer was attached and the stamp did not move. The blank-name row did move (1.9.79: company linking walks past names it cannot link #304's clear).{"customer_id":1,"company":"Initech Inc","company_id":2,"updated_at":1793183727}, with the run starting at 1793183726. The blank-name row moved too, and customer 3 (untouched) stayed at 1700000000.Verification
.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed(after the commit; PHP 8.3.6, full vendor/; unit: 6147 tests, 0 failures, 4 skipped, the documented DB-singleton skips):Integration suites run by hand against a scratch database (
p202_s6c6_test, MariaDB 10.11):--group integration tests/Ltv/: OK (56 tests, 4707 assertions). This includesCompanyLinkingIntegrationTest(4 tests) andLtvDatabaseIntegrationTest, which calls the sweep andreconcileAttachedStrings().Could not run
p202 ltv customers <id>): not run. The shared brief says it gets 402 from an instance installed without the licence; I did not try it. The endpoint it calls was exercised directly.--since origin/1.9.79 --changeddid not select it.For a reviewer
time()at each UPDATE, asclearBlankCompanies()does. It does not use one time taken at the start of the call. Each statement commits on its own (the cron runs the sweep outside a transaction), so the time at the statement is the closest to when the change becomes visible.202_customersonly. Running the same reading over the 21 other tables that keepupdated_atfound writers that change an API-visible column without it (findings below). They are internal bookkeeping or another concern, so they are reported here, not widened into this PR.Findings outside this item
GET /ltv/customersignoresupdated_since: it answers 200 with every customer (measured). Reading the handler, it reads only its own parameters, so any other unknown parameter is ignored the same way.POST /ltv/customers/{id}/aliases,DELETE …/aliases/{aliasId}), and a save carrying onlyaliasesorcustom_fields, leave the customer'supdated_atas it was. Measured for an alias add: it answered 201, and the detail showed the new alias withupdated_atunchanged. The delete and thealiases/custom_fields-only save were read from the code, not measured.ltv_maintenance.phpstampsupdated_at = {$now}on every customer active in the last 48h whether or not a rollup changed. It reports each one as "corrected": measured "1 customer rows corrected" on two runs in a row for a customer with nothing to correct.customer.updatedwebhooks fire for API upserts, patches, merges and erasures. They do not fire for a company rename or merge that rewrites attached customers'company, or for the maintenance job's link or blank-name clear.ModelRepository::clearDefault()clearsis_defaulton other attribution models, andAppIcons::store()writes an app registration'sapp_icon. Neither sets those rows'updated_at.GET /ltv/customersandGET /ltv/customers/{id}answertotal_revenue,refunded_amountandmrras decimal strings ("0.00000"), where the LTV summary answers numbers since 17305ee.Generated by Claude Code