Repository navigation
1.9.79: the subscription sweep announces what it writes, for the last week - #302
Merged
Merged
Conversation
LTV webhooks were queued by every /ltv write and sent by nothing a
default install runs: 202-cronjobs/ltv_webhooks.php was the only
dispatcher, and every default scheduler (the installer's cron line,
install.sh, both Docker cron services, AutoCron) fetches index.php
alone. EveryCronJobIsScheduledByDefaultTest listed it as NOT_SCHEDULED
("opt-in, predates the measurement rewrite": history, not a reason). The
Landing Page Optimizer's pairing webhook waited with the rest.
The dispatch moves into Prosper202\Ltv\WebhookDispatcher, and two entry
points run it: the minutely tier of index.php (last, with a 15-second
budget) and ltv_webhooks.php on its own (--budget=N, default 50), which
the Coolify worker service now runs too. Both take one MySQL named
lock, as the attribution worker and the Android intake do, and each
delivery is still claimed by compare-and-set before it is sent, so runs
the lock does not see cannot send it twice either.
- The deadline is asked before each delivery, at least one a run, so a
run ends at most one delivery past it.
- A webhook whose delivery took 2 s or more to fail is not sent to again
in that run; its other deliveries stay due, untried, and a cursor on
(next_attempt_at, delivery_id) steps past them. A receiver that times
out costs one timeout a run instead of the budget every webhook on the
install shares. A failure that comes back at once is retried at its own
pace, as before.
- Retries and backoff are unchanged (recordAttempt(): 2^n minutes, failed
after 6, the webhook marked dead). A refusal, a failed lookup or a send
that throws fails its own delivery and the run goes on (#24).
- A delivery still unsent 7 days after it was queued is failed with
"expired: ..." as its last response, never sent, its webhook left
active: an upgraded install sends the last week of its backlog, oldest
first, rather than months of stale events. Expired 1,000 a statement;
a run whose budget ends first sends nothing, since what is left to
expire is the oldest due.
- The response body is stored as UTF-8 cut at a character boundary: a
body that is not UTF-8 was refused under a strict sql_mode, the outcome
went unrecorded and the delivery would have been sent again.
- post() builds every option through requestOptions(), the guard's set
winning over the POST's, and refuses to send when curl refuses one.
Tests: WebhookDispatchIntegrationTest (a real server, a php -S receiver,
two child processes for the overlapping runs, under the lock and
without it), WebhookDispatcherTest, LtvWebhooksAreSentEveryMinuteTest
(the minutely tier, last, with its budget; the Coolify worker), and the
scheduling, guard-parity and LTV server-failure tests updated. Each
removal planted was caught by a named test. tests/live/ltv-webhooks.sh
is the pass end to end against an https receiver.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
1.9.79 gained #262. The only conflict was 202-config/changelogs.json, where both sides added 1.9.79 lines: 1.9.79's lines are kept and this branch's follow them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in what 1.9.79 gained since this branch last merged it. The conflicts were only in the changelogs, where both sides added 1.9.79 lines: 1.9.79's lines are kept as it has them and this branch's follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…the rest of LTV maintenance daily 202-cronjobs/ltv_maintenance.php was the only thing that moved a subscription no renewal reached to past_due and then canceled, and no default deployment scheduled it (EveryCronJobIsScheduledByDefaultTest had it as "opt-in, predates the measurement rewrite"). So on every default install a subscription that stopped renewing stayed active for good: in MRR (GET /ltv/mrr, the customers' rollups, GET /ltv/summary) and never in churn. The jobs move into Prosper202\Ltv\LtvMaintenance, which both entry points run under one MySQL named lock (the dispatcher's pattern, #273): - index.php's hourly tier runs the sweep (20 s budget); the daily tier runs the reconcile, the next-offer transitions, company linking and the personalization purge (60 s). ltv_maintenance.php runs all of them (--full, --batch-size=N, --budget=N, default 300 s) and refuses an option it does not know. - The sweep reads 500 subscriptions at a time, oldest period end first, by the period_end_sweep index, and writes each batch and its customers' rollups in one transaction, asking each row again so a renewal that commits meanwhile wins. A deadline before each batch bounds every job, so an upgraded install's backlog drains over successive runs. - A canceled subscription is dated when the rule cancels it (period end + grace + 30 days), not when the run got to it: dated now, the first run on a seeded install put 29,404 long-lapsed subscriptions into the 90-day churn window (18.4% monthly churn; 5.1% dated by the rule). An active subscription past the cancel point goes straight to canceled. - The reconcile asked the ledger for a customer id range with no index on customer_id, reading all of it for every 500 ids: 59.6 s for the 3,349 customers active in 48 hours out of 100,000. It now reads each account's rows by (user_id, customer_id) and writes only rows that differ: 0.7 s. Both rollup statements tie the ledger and subscriptions to the customer's account (CLAUDE.md #27). Measured on 100,000 customers, 60,000 subscriptions (30,148 lapsed) and 550,717 revenue events: sweep 3.2 s for the backlog, 0.01 s after; the reconcile 0.7 s, 0.2 s after; transitions 3.9 s; company linking 6.9 s for 4,959 unattached customers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…e-scheduled The base now carries 1.9.79 through #282. Conflicts: changelogs.json and the upgrade guide's 1.9.79 list, the base's lines kept and this branch's added after them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…p-sends-subscription-changed This branch is stacked on #285: its subscription sweep (LtvMaintenance) is the code that now queues subscription.changed. No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
… week Every subscription status change made through the API queues a subscription.changed webhook event. The sweep that lapses and cancels subscriptions (LtvMaintenance, which #285 runs hourly on every install) queued none, so a CRM or the Landing Page Optimizer never heard that a subscription lapsed. Each status the sweep writes now queues the event the API's change of that status queues, through the same MysqlWebhookRepository::enqueue() and with the same body: past_due with action "upsert" (the API reaches past_due only through POST /ltv/subscriptions), canceled with action "cancel" (the cancel event). The batch's rows are read again under FOR UPDATE, so the rows it moves are the rows it announces, and the events are queued in the batch's transaction: a batch rolled back queues nothing, and a renewal that commits while the sweep waits still wins, with nothing announced for it by the sweep. Only a change that took effect within EVENT_WINDOW_SECONDS (the dispatcher's 7-day MAX_AGE_SECONDS) is announced. Measured on 30,000 subscriptions lapsed over two years: the first sweep moves all of them in 2.3 s (1.7 s before) and announces 561; announcing every one queued 30,000 deliveries per webhook in 11.4 s, which the dispatcher, sending oldest first for 15 s a minute, would have sent ahead of every live event for hours. The run's line says how many it did not announce. Tests: SweepAnnouncesSubscriptionChangesIntegrationTest compares the events queued for twin subscriptions, one moved by the sweep and one by the real LtvController, holds a rolled-back batch to queuing nothing, and the window to its edge; LtvMaintenanceIntegrationTest's renewal race now also holds that nothing is announced; SubscriptionWritesAreAnnouncedTest lists every statement that writes 202_subscriptions with who announces it. tests/live/ltv-sweep-subscription-changed.sh is the live pass, and ltv-subscription-lapse.sh reads the run's new line. CLAUDE.md #25 says what a second writer owes. 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, tests/live/README.md, 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-webhooks-scheduled (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
…changed Brings in ltv-maintenance-scheduled (this branch's stacked base), and through it 1.9.79's new commits. 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.
Stacked on #285 (9BA0,
1.9.79-ltv-maintenance-scheduled, at e2d4680), which is stacked on #273. Review and merge this after #285. Until #285 merges, this diff includes #273's and #285's commits. This PR's own change is commit 9eeae73; 217450c is the merge of #285's branch (no conflicts).Backlog: 2RBE
What was wrong
Every subscription status change made through the API queues a
subscription.changedwebhook event:POST /ltv/subscriptionsqueues it withaction: upsert;actionthe event type.The sweep that lapses and cancels subscriptions queued none. That is
LtvMaintenance, which #285 runs hourly on every install. So a CRM, or the Landing Page Optimizer (whose pairing webhook takes every event), never heard that a subscription lapsed.What changed
Each status the sweep writes queues the event the API's change of that status queues. It goes through the same
MysqlWebhookRepository::enqueue(), with the same envelope (event,occurred_at,data) and the same body (subscription_id,customer_id,action, as integers and a string):past_duegetsaction: "upsert". The API reachespast_dueonly throughPOST /ltv/subscriptionswithstatus: past_due, which queues exactly that.canceledgetsaction: "cancel", as the cancel event (POST /ltv/subscriptions/{ref}/events) queues it.In the batch's own transaction. Each batch now reads its rows again under
FOR UPDATE, still askingstatus = ?and the lapse condition. The rows it moves are the rows that read returned, and only those are announced.Only a change that took effect in the last 7 days is announced (
LtvMaintenance::EVENT_WINDOW_SECONDS=WebhookDispatcher::MAX_AGE_SECONDS). "Took effect" is the moment the rule set the status:past_due, period end + grace;canceled, period end + grace + 30 days, thecanceled_at1.9.79: the cron job sweeps lapsed LTV subscriptions hourly and runs the rest of LTV maintenance daily #285 writes.The status is written whatever its age.
The run's line says what it announced and what it did not:
subscriptions 1 to past_due, 2 to canceled, 3 customer rollups refreshed, 1 announced as subscription.changed (2 that took effect more than 7 days ago not announced). The report gainsannouncedandunannounced.Docs:
documentation/api/20-ltv.md: whatsubscription.changedcarries, and the sweep's events under "When a subscription stops renewing";docs/openapi.yaml: the two subscription writes;docs/cli-agent.md;Decision: the backlog (measured)
An install whose sweep never ran before 1.9.79 has every subscription that stopped renewing still
active. I seeded 30,000 subscriptions, one per customer, with period ends spread evenly over 4–700 days ago (#285's shape), and one webhook taking every event. MariaDB 10.11, PHP 8.3.6.The next sweep took 0.01 s and announced nothing.
Why 7 days, and not every change:
Tests
tests/Ltv/SweepAnnouncesSubscriptionChangesIntegrationTest.php(@group integration, scratch database, strictsql_mode, 3 tests):LtvController:upsertSubscription()withstatus: past_due, andsubscriptionEvent()withcancel.datakeys and value types, the action and the ids.cancel. A webhook subscribed only torevenue.recordedgets nothing. A second sweep announces nothing.LtvMaintenanceIntegrationTest(1.9.79: the cron job sweeps lapsed LTV subscriptions hourly and runs the rest of LTV maintenance daily #285's): its renewal race now registers a webhook and holds that nothing is announced for the renewed row. All 10 pass.tests/Ltv/SubscriptionWritesAreAnnouncedTest.php(structural). Every statement in the served tree that writes202_subscriptionsis listed with who announces it:LtvControllerannounces after;UserDataPurge's, with nobody left to tell.The listed announcers must still queue the event. A new writer fails it until it says how its change is announced.
Updated:
LtvMaintenanceSummaryTest(the line), andLtvFieldErrorsTest, which lists the sweep's new server failure.Plants. 11, all caught by a named test. For each, the file was backed up to
mktemp, compared withcmpand restored:testEachStatusTheSweepWritesIsQueuedAsTheApisChangeOfItIsSubscriptionWritesAreAnnouncedTest::testTheListedWritersQueueTheEventtestABatchRolledBackQueuesNothingAndItsRetryQueuesOncetestAChangeThatTookEffectBeforeTheWindowIsWrittenAndNotAnnouncedtestEachStatusTheSweepWrites…LtvMaintenanceIntegrationTest::testARenewalBetweenTheSweepsReadAndItsWriteWinscustomer_idas a string)testEachStatusTheSweepWrites…202_subscriptionsSubscriptionWritesAreAnnouncedTest::testEveryWriteOfASubscriptionRowIsListedLtvMaintenanceSummaryTest::testTheLineNamesOnlyTheJobsThatRanLive pass
tests/live/ltv-sweep-subscription-changed.sh(new) ran on an instance from this branch: PHP 8.3.6php -Sthrough the eval router, MariaDB 10.11, port 8321. The cron ran as the installer's line runs it:curl …/202-cronjobs/index.php, with this hour's and minute's records cleared first, as #285's pass does. 13 passed, 0 failed. The same script against #285's sweep gave 8 passed and 5 failed: no events, and the old line.subscription.changed, and five subscriptions recorded throughPOST /ltv/subscriptions:POST /ltv/subscriptionswithstatus: past_due, and the cancel event (changed: true).…, 3 customer rollups refreshed, 2 announced as subscription.changed (1 that took effect more than 7 days ago not announced);past_due,canceled,canceled;subscription.changed upsert subscription_id:int,customer_id:int,action:string event,occurred_at,data, and the same withcancel;The bodies were read from
202_ltv_webhook_deliveries, becauseGET /ltv/webhooks/{id}/deliveriesleaves the payload out. The webhook's address answers nothing, so the minutely sender only recorded attempts.#285's
tests/live/ltv-subscription-lapse.sh, with its expected line updated, also ran on this branch: 28 passed, 0 failed. Its Go CLI checks were skipped, as in #285's own run: the licence, below. Its PHP CLI check (bin/p202 ltv:mrr) ran.Licence (the stub is down)
install.php's POST checks the key with my.tracking202.com, so I did not run it. A scratch script ran the installer's own steps without the licence form:INSTALL::install_databases(),InstallAccount::create(), the202_api_keysrow andinstall_database_partitions(). AutoCron and the daily email registration, which call outside services, were left out.HTTPS_PROXY,https_proxy,HTTP_PROXY,http_proxy) pointed athttp://127.0.0.1:9, a closed port, and withNO_PROXY=localhost,127.0.0.1. Every licence check in its log isFailed to connect to 127.0.0.1 port 9, so nothing reached the licence service. No signed-in page is part of this pass: it uses the API and the cron line only.Verification
.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed, on 9eeae73, PHP 8.3.6. The schema tier ran on a scratch MariaDB 10.11 database. The unit tier ran 6,122 tests with 5 skipped: the 4 documented "User class requires DB singleton" skips, andCronEntryPointsFailLoudlyTest's executed case, which skips while the worktree holds the live instance's202-config.php. The range includes #273's and #285's changes.The first run of the ladder had
unit FAIL:LtvFieldErrorsTestcaught the sweep's newRuntimeException("The sweep locked …"), which was not listed. It is listed now, with why it is the server's.Beyond the script, each suite against a scratch MariaDB 10.11 database:
SweepAnnouncesSubscriptionChangesIntegrationTestLtvMaintenanceIntegrationTestWebhookDispatchIntegrationTestLtvDatabaseIntegrationTestshellcheck --severity=warningon both live scripts: clean.Not run:
DROP TRIGGER IF EXISTSandCREATE TRIGGER … FOR EACH ROW IF … END IF, whichTrackerLinkTransactionIntegrationTestalready runs in CI, and noRENAME TABLE. The sweep'sSELECT … FOR UPDATEby primary key is plain MySQL;tests/run-integration-suites.sh;For the reviewer to check
past_due→action: "upsert". This mirrors the only API path to that status, as the item asks ("the same event, with the same payload shape, that the API's change of that status would"). A new action such aslapsewould read more naturally, but it would be a value no subscriber has seen from the API. The body carries no status; a subscriber reads the subscription, as for everysubscription.changed.FOR UPDATEon the batch's rows. 1.9.79: the cron job sweeps lapsed LTV subscriptions hourly and runs the rest of LTV maintenance daily #285's writes asked the row again in theUPDATE. They now ask it in a locking read by primary key, and theUPDATEnames exactly the rows that read returned, so the events match the writes. Measured cost: +0.6 s on 30,000 rows, including the 561 enqueues.occurred_atenvelope is the time the event was queued, as for every API event (enqueue()sets it), not the time the change took effect.Found outside this item (not fixed here)
subscription.changed's body carries nostatus, from the API or the sweep. Every subscriber has to read the subscription back to know what changed. (Its shape was documented nowhere; this PR adds it todocumentation/api/20-ltv.md.)MysqlWebhookRepository::enqueue()reads the account's webhooks throughprepareRead(), the replica where one is configured, even when called inside a transaction on the primary.🤖 Generated with Claude Code
https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr