Skip to content

1.9.79: the subscription sweep announces what it writes, for the last week - #302

Merged
tracking202 merged 10 commits into
1.9.79from
1.9.79-sweep-sends-subscription-changed
Oct 28, 2026
Merged

tracking202 merged 10 commits into
1.9.79from
1.9.79-sweep-sends-subscription-changed

Conversation

@tracking202

@tracking202 tracking202 commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

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.changed webhook event:

  • POST /ltv/subscriptions queues it with action: upsert;
  • the renewal, cancel and refund events queue it with action the 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_due gets action: "upsert". The API reaches past_due only through POST /ltv/subscriptions with status: past_due, which queues exactly that.
    • canceled gets action: "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 asking status = ? 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:

    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 gains announced and unannounced.

  • Docs:

    • documentation/api/20-ltv.md: what subscription.changed carries, and the sweep's events under "When a subscription stops renewing";
    • docs/openapi.yaml: the two subscription writes;
    • docs/cli-agent.md;
    • the upgrading guide;
    • both changelogs;
    • CLAUDE.md [Setup] Remove undefined variable from template calls #25, on what a second writer owes.

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.

first sweep time events queued
#285 (no events) 1.70 s 0
this PR (last 7 days) 2.28 s 561 (1,250 → past_due, 28,750 → canceled; 29,439 older not announced)
announcing every change (measured by widening the window) 11.44 s 30,000 per subscribed webhook

The next sweep took 0.01 s and announced nothing.

Why 7 days, and not every change:

  • The dispatcher would put a burst ahead of everything. 1.9.79: the minutely cron sends LTV webhooks, within a budget #273's dispatcher sends oldest first, for 15 s a minute. 30,000 deliveries per webhook would hold every live event behind them for hours: at a few hundred milliseconds a send, ~75 a minute, that is about 6–7 hours. And a slow receiver gets one send per run.
  • A year-old lapse is noise. "Subscription X changed" about something a year ago reads to a CRM as news, and can overwrite newer state there, which is 1.9.79: the minutely cron sends LTV webhooks, within a budget #273's reason for its own 7 days.
  • It matches what an always-running install would have sent. The dispatcher never sends a delivery more than 7 days after it was queued. A sweep that had run all along would have queued each event when the change happened, so only the last week's could still be sent today. A cron job stopped for 3 weeks behaves the same way: on its return, the last week's changes are announced and the rest are not, as the dispatcher's own expiry would have it.
  • It is bounded by a week's worth of lapses, not by the install's history. It is all within the sweep's 20 s hourly budget, and the burst drains in minutes.

Tests

  • tests/Ltv/SweepAnnouncesSubscriptionChangesIntegrationTest.php (@group integration, scratch database, strict sql_mode, 3 tests):

    • Twins.
      • For each status, one subscription is moved by the sweep and its twin through the real LtvController: upsertSubscription() with status: past_due, and subscriptionEvent() with cancel.
      • The delivery bodies queued for a webhook taking every event are compared: envelope keys, data keys and value types, the action and the ids.
      • A past_due subscription past its cancel point is announced as cancel. A webhook subscribed only to revenue.recorded gets nothing. A second sweep announces nothing.
    • The window. A lapse an hour inside 7 days is announced. One an hour outside, and a cancel a year ago, are written and not announced, and the line says 2 were not.
    • A rolled-back batch. A trigger refuses the rollup refresh, the batch's last write, after its event is queued. The status and the event both roll back. Without the trigger, the retry moves it and queues once.
  • 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 writes 202_subscriptions is listed with who announces it:

    • the repository's three, which LtvController announces after;
    • the sweep's, announced in its transaction;
    • 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), and LtvFieldErrorsTest, 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 with cmp and restored:

plant caught by
the sweep queues nothing testEachStatusTheSweepWritesIsQueuedAsTheApisChangeOfItIs
the event named otherwise in the sweep SubscriptionWritesAreAnnouncedTest::testTheListedWritersQueueTheEvent
the events committed apart from the batch testABatchRolledBackQueuesNothingAndItsRetryQueuesOnce
no window testAChangeThatTookEffectBeforeTheWindowIsWrittenAndNotAnnounced
the window asked of the sweep's time, not the change's same
past_due announced as another action testEachStatusTheSweepWrites…
canceled announced as another action same
the locked read does not ask the row again LtvMaintenanceIntegrationTest::testARenewalBetweenTheSweepsReadAndItsWriteWins
another body shape (customer_id as a string) testEachStatusTheSweepWrites…
a new writer of 202_subscriptions SubscriptionWritesAreAnnouncedTest::testEveryWriteOfASubscriptionRowIsListed
the line hides what was not announced LtvMaintenanceSummaryTest::testTheLineNamesOnlyTheJobsThatRan

Live pass

tests/live/ltv-sweep-subscription-changed.sh (new) ran on an instance from this branch: PHP 8.3.6 php -S through 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.

  1. A webhook subscribed to subscription.changed, and five subscriptions recorded through POST /ltv/subscriptions:
    • twins lapsed 2 days ago;
    • twins past their cancel point 2 days ago;
    • one that lapsed 400 days ago.
  2. One of each twin changed through the API: POST /ltv/subscriptions with status: past_due, and the cancel event (changed: true).
  3. One cron run:
    • its line reads …, 3 customer rollups refreshed, 2 announced as subscription.changed (1 that took effect more than 7 days ago not announced);
    • the three are past_due, canceled, canceled;
    • each sweep-moved twin has one delivery, equal to its API twin's: subscription.changed upsert subscription_id:int,customer_id:int,action:string event,occurred_at,data, and the same with cancel;
    • the 400-day one has none.
  4. The next run queues nothing.

The bodies were read from 202_ltv_webhook_deliveries, because GET /ltv/webhooks/{id}/deliveries leaves 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. 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(), the 202_api_keys row and install_database_partitions(). AutoCron and the daily email registration, which call outside services, were left out.
  • The web server ran with every proxy variable (HTTPS_PROXY, https_proxy, HTTP_PROXY, http_proxy) pointed at http://127.0.0.1:9, a closed port, and with NO_PROXY=localhost,127.0.0.1. Every licence check in its log is Failed 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.
  • The Go CLI was not used.

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, and CronEntryPointsFailLoudlyTest's executed case, which skips while the worktree holds the live instance's 202-config.php. The range includes #273's and #285's changes.

Scope report
------------
  openapi    PASS
  patterns   PASS
  phpcs      PASS
  phpstan    PASS
  schema     PASS
  syntax     PASS
  unit       PASS

Not covered by this script:
  live      stand up an instance, drive the path a user would take
  agent     add and run a case under tests/fixtures/agent-eval/cases/
  ci        a workflow still in_progress has not passed; read the run

Report the lines above verbatim. Do not write "tests pass".

The first run of the ladder had unit FAIL: LtvFieldErrorsTest caught the sweep's new RuntimeException ("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:

suite result
SweepAnnouncesSubscriptionChangesIntegrationTest 3 tests OK
LtvMaintenanceIntegrationTest 10 tests OK
WebhookDispatchIntegrationTest 15 tests OK
LtvDatabaseIntegrationTest 43 tests OK

shellcheck --severity=warning on both live scripts: clean.

Not run:

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 as lapse would 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 every subscription.changed.
  • The window is on when the change took effect, not on when it is queued. The dispatcher's 7 days apply to the queueing, so the two together send nothing about a change more than about two weeks old.
  • FOR UPDATE on 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 the UPDATE. They now ask it in a locking read by primary key, and the UPDATE names 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.
  • The occurred_at envelope 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 no status, 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 to documentation/api/20-ltv.md.)
  • MysqlWebhookRepository::enqueue() reads the account's webhooks through prepareRead(), 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

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
Copilot AI balanced review requested due to automatic review settings October 28, 2026 09:15
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-28T09:15:48.036356Z
We couldn’t complete this review. Comment @codex review to try again.
9eeae73 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@github-actions

Copy link
Copy Markdown
Contributor

Code review: CLEAN

For 9eeae73: 0 issue(s). The review is encrypted for the maintainers in the review-findings artifact of this run.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Adversarial review: NON-BLOCKING

For 704d2eb: 4 finding(s). The report is encrypted for the maintainers in the review-findings artifact of this run.

Rewritten on every push; the check fails on BLOCKING.

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
@tracking202
tracking202 merged commit 2aa2429 into 1.9.79 Oct 28, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants