Skip to content

1.9.79: the cron job sweeps lapsed LTV subscriptions hourly and runs the rest of LTV maintenance daily - #285

Merged
tracking202 merged 7 commits into
1.9.79from
1.9.79-ltv-maintenance-scheduled
Oct 28, 2026
Merged

tracking202 merged 7 commits into
1.9.79from
1.9.79-ltv-maintenance-scheduled

Conversation

@tracking202

Copy link
Copy Markdown
Owner

Stacked on #273 (QSB2, 1.9.79-ltv-webhooks-scheduled): review and merge this after #273. Until #273 merges, this diff includes #273's commits; the change of its own is commit dfb9783, followed by a merge of #273's branch as it now stands (272f792, with 1.9.79 through #282 in it).

Backlog: 9BA0

What was wrong

202-cronjobs/ltv_maintenance.php was the only code that moved an LTV subscription no renewal reached to past_due, and then to canceled. No default deployment scheduled it. EveryCronJobIsScheduledByDefaultTest listed it as NOT_SCHEDULED ("opt-in, predates the measurement rewrite"). So on every default install a subscription that stopped renewing stayed active for good:

  • it stayed in MRR (GET /ltv/mrr, and the customer rollups that GET /ltv/summary sums);
  • it never counted in churn;
  • it never showed in the at_risk segment.

The same file also runs four other jobs that never ran either: the customer rollup reconcile, the next-offer transitions rebuild, company linking, and the personalization token purge.

What changed

  • Prosper202\Ltv\LtvMaintenance holds the five jobs. Both entry points run it under one MySQL named lock (p202_ltv_maintenance: + sha1 of the database), the same pattern as 1.9.79: the minutely cron sends LTV webhooks, within a budget #273's dispatcher.
    • index.php's hourly tier runs the sweep. It runs last in that tier, with a 20 s budget (HOURLY_SECONDS).
    • index.php's daily tier runs the other four. They have a 60 s budget (DAILY_SECONDS) and run before the day's run is recorded.
    • ltv_maintenance.php runs all five on its own. It takes --full, --batch-size=N (50–5000) and --budget=N (1–86400 s, default 300). It now refuses an option it does not know, where before it ran with defaults (--batch-size=abc was read as 50). It exits 1 when a job failed, and answers 500 over HTTP.
  • The sweep is bounded. It reads 500 subscriptions at a time, oldest period end first, through the period_end_sweep index (a sargable current_period_end < now comes first). It uses a cursor, so a run never reads a row twice.
    • Each batch's status writes and its customers' cached mrr / active_subscription_count go in one transaction. Before, the owners were refreshed after the sweep, and a crash in between left them stale.
    • Each write asks the row again (status = … and the lapse condition), so a renewal that commits while the sweep waits on the row wins. There is a test for this race.
    • The deadline is checked before each batch, and at least one batch runs. A backlog therefore drains over successive hourly runs.
    • An active subscription already past the cancel point goes straight to canceled, in the same batch.
  • The reconcile reads by index. Before, it asked 202_revenue_events for customer_id BETWEEN x AND y, and no index leads with customer_id. So it read the whole ledger for every 500 ids in the range spanned by the customers active in the last 48 hours. It now selects that window's customers 500 at a time by primary key and reads each account's ledger and subscriptions by (user_id, customer_id). It writes only rows that differ, so "N corrected" means drift was found, not that updated_at was touched.
  • Account ties (CLAUDE.md [Update] Fix CPC warnings and input length #27). Both rollup statements tie the ledger and the subscriptions to the customer's account (user_id = ? in each derived table). The AccountScopedJoinTest entries for the old file's three untied statements are gone.
  • The other jobs. Transitions, company linking and the purge call the existing repository methods. Company linking loops linkUnlinkedCustomers() while the budget lasts, instead of 2,000 per account per run. purgeExpired() takes a LIMIT. Each job's failure is reported and logged, and the jobs after it run, as the old file's isolated sections did.

Decision: the schedule (measured)

I measured on the live instance's database, seeded with 100,000 customers, 60,000 subscriptions (30,148 of them active but lapsed 4–700 days ago, the backlog an upgraded install has) and 550,717 revenue events (116,057 of them conversions). MariaDB 10.11, PHP 8.3.6.

job as written (old file) now, first run now, next run
sweep 2.9 s, three unbounded statements 3.2 s in 61 batches (1,177 → past_due, 28,971 → canceled, 30,148 rollups) 0.01 s
reconcile (48 h window, 3,340 customers) 59.6 s 0.7 s (26 corrected) 0.2 s (0 corrected)
transitions 4.1 s 3.9 s (380 pairs) —
company linking 10.2 s (2,000 of 4,959) 6.9 s (all 4,959) 0.3 s
purge 0 s 0 s 0 s
  • The sweep runs hourly. The status it sets is what MRR, churn and the at_risk segment read, so it should lag the clock by at most an hour. In steady state it is one empty index probe. A backlog clears at about 9,000 subscriptions a second here, so a 20 s run clears roughly 180,000, and a larger one drains over the next hours.
  • The rest runs daily. The reconcile's 48-hour window is a daily run's, with a day to spare. It repairs drift and does not keep anything fresh: every write already recomputes its customer's rollups. The transitions rebuild is one statement per account that no deadline can interrupt, and it grows with conversion volume. Company linking and the purge are housekeeping.

Decision: when a lapsed subscription was canceled

MysqlLtvRepository::mrr() counts churn as subscriptions with canceled_at in the last 90 days. The old sweep set canceled_at = now. On an upgraded install, its first run would therefore have dated every subscription that lapsed in the last two years as canceled this quarter.

Measured on the seed:

  • dated now: canceled_in_window 29,404, monthly churn 18.4%;
  • dated by the rule: 4,355 and 5.1%.

The sweep now writes canceled_at = current_period_end + (grace_days + 30) × 86400. That is the moment the rule cancels the subscription, and where an hourly sweep that had always run would have put it. It is also what the API's cancel event does: it dates by occurred_at, not by when the request arrived. An upgraded install therefore reads the same churn as one that ran the sweep all along.

Tests

  • tests/Ltv/LtvMaintenanceIntegrationTest.php (@group integration, scratch database, strict sql_mode), 10 tests:
    • the states, with each subscription's own grace period;
    • canceled_at dated by the rule;
    • churn through the real mrr();
    • two accounts, and another account's subscription naming this customer read as nothing;
    • batches of 2 with a deadline: one batch, then every job left for the next run;
    • the reconcile window, --full, another account's ledger row ignored, and a row already right not rewritten;
    • the lock;
    • a job that fails (its table renamed away) while the purge after it runs;
    • company linking and the purge;
    • an unknown job refused before anything runs;
    • a renewal that commits while the sweep waits on the row's lock. A child process (tests/Ltv/fixtures/maintenance-runner.php) runs the sweep, held at its write until it is seen in LOCK WAIT. The renewal wins.
  • tests/Cron/LtvMaintenanceRunsHourlyAndDailyTest.php:
    • the hourly tier calls RunLtvMaintenance([SWEEP], HOURLY_SECONDS) after its other jobs and before recording the hour;
    • the daily tier calls RunLtvMaintenance(DAILY_JOBS, DAILY_SECONDS) before recording the day;
    • the minutely tier calls neither;
    • the helper passes the cron's connection, time() + $seconds and the jobs to runExclusive(), inside a try that logs;
    • executed: between the two tiers every job runs, and the sweep runs only hourly;
    • the call reader refuses names that only end in RunLtvMaintenance(.
  • tests/Ltv/LtvMaintenanceSummaryTest.php: the printed line, and when index.php prints one.
  • Updated tests:
    • EveryCronJobIsScheduledByDefaultTest: ltv_maintenance.php moves to SCHEDULED (LtvMaintenance::runExclusive), and NOT_SCHEDULED shrinks by one;
    • AccountScopedJoinTest: the three stale entries are gone;
    • LtvFieldErrorsTest: the two lock failures are listed;
    • phpstan-baseline.neon: the old file's 10 direct bind_param/execute entries are gone.
  • Plants: 22, each against its named tests, with the file backed up, compared with cmp and restored. Every one was caught by a named test failing. They ran before the base merge and the PSR-12 line wrapping, neither of which touched the planted code.
    • Two first runs missed, and the tests were fixed:
      • "the reconcile rewrites rows already right": both runs fell in one second, so updated_at did not change. The test now backdates updated_at between the runs.
      • "the lock test": it was flaky under one plant, because a closed connection's lock is not released synchronously. The test now calls RELEASE_LOCK before close().
The 22 plants
plant caught by
cancel dated now testALapsedSubscriptionIsPastDueThenCanceledDatedByTheRule, testChurnCountsALapseByWhenItLapsedNotWhenTheSweepRan
rollup refresh untied from the account testEveryAccountIsSweptAndARollupCountsOnlyItsAccountsSubscriptions
no rollup refresh in the sweep testALapsed…, testEveryAccount…, testABacklogIsSweptInBatches…
no deadline testABacklogIsSweptInBatchesAndADeadlineLeavesTheRestForTheNextRun
newest period end first testABacklogIsSweptInBatches…
active straight to canceled skipped testALapsed…
past_due write does not ask the row again testARenewalBetweenTheSweepsReadAndItsWriteWins
reconcile ignores its window testTheReconcileCorrectsTheCustomersActiveInItsWindow
reconcile counts another account's ledger testTheReconcile…
reconcile rewrites rows already right testTheReconcile…
no lock testTheLockKeepsASecondRunOut
a job's failure ends the run testAJobThatFailsIsReportedAndTheJobsAfterItRun
companies job skips linking testCompaniesAreLinkedAndExpiredTokensPurged
purge purges nothing testAJobThatFails…, testCompanies…
unknown job accepted testAJobNotOnTheListIsRefusedBeforeAnythingRuns
hourly tier does not sweep LtvMaintenanceRunsHourlyAndDailyTest::testTheHourlyTierSweepsAndTheDailyTierRunsTheRest
daily tier does not run the rest same
sweep moved to the daily tier same
helper given no deadline testTheHelperRunsTheLockedMaintenanceWithTheTiersDeadline
helper never calls runExclusive testTheHelper…, EveryCronJobIsScheduledByDefaultTest::testIndexPhpRunsTheEntryPointOfEveryScheduledJob
a job left out of both tiers testBetweenTheTiersEveryJobRunsAndTheSweepOnlyHourly
standalone job no longer runs the maintenance EveryCronJobIsScheduledByDefaultTest::testIndexPhpRunsTheEntryPointOfEveryScheduledJob

Live pass

tests/live/ltv-subscription-lapse.sh (new) ran on an instance installed from this branch: PHP 8.3.6 php -S, MariaDB 10.11, port 8240. The cron runs as the installer's cron line runs it, curl …/202-cronjobs/index.php. Before each run the script deletes this hour's and this minute's 202_cronjobs records, which is what the clock passing does.

  1. Four subscriptions were recorded through POST /ltv/subscriptions. MRR went up by 100, and active subscriptions by 4.
  2. Three period ends were moved into the past in the database: 5, 60 and 400 days ago.
  3. One run printed LTV maintenance: subscriptions 1 to past_due, 2 to canceled, 3 customer rollups refreshed. After it:
    • GET /ltv/subscriptions reads past_due for the 5-day one and canceled for the other two, with canceled_at = period end + 33 days;
    • GET /ltv/mrr: MRR is down to the one current subscription, past_due +1, canceled +2, and canceled_in_window +1 (the one canceled 27 days ago, not the one canceled 367 days ago);
    • GET /ltv/summary's MRR, from the customer rollups, equals GET /ltv/mrr's;
    • GET /ltv/customers/{id}: each lapsed customer has 0 MRR and 0 active subscriptions, and the current one keeps 30/1;
    • the CLIs read the same: p202 ltv mrr and p202 ltv subscriptions --status past_due (Go), and bin/p202 ltv:mrr --json (PHP).
  4. The next run moved nothing.
  5. A renewal (POST /ltv/subscriptions/{ref}/events) made the past_due one active again, a run left it so, and it was back in MRR.
  6. php 202-cronjobs/ltv_maintenance.php --budget=abc exits 1, naming the argument. --budget=30 exits 0 and prints its line.

Results:

  • 30 passed, 0 failed on 10/23, before the base merge, with the Go CLI.
  • 28 passed, 0 failed on 10/28, after the merge (e2d4680). The two Go CLI checks were skipped: since the 10/28 restart the licence stub is down, and the Go CLI gets a 402 licence refusal from this instance (p202 ltv mrr exits 6). The PHP CLI has no licence gate and ran.

The measurements above were taken against the same instance's database, through ltv_maintenance.php and the class.

Verification

.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79-ltv-webhooks-scheduled --changed, on the merged branch (e2d4680), PHP 8.3.6. The unit tier ran 6,120 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.

Scope report
------------
  patterns   PASS
  phpcs      PASS
  phpstan    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".

Beyond the script:

  • LtvMaintenanceIntegrationTest against a fresh scratch MariaDB 10.11 database (p202_9ba0_it), after the merge: OK (10 tests, 500 assertions).
  • shellcheck --severity=warning on the new live script: clean.

Not run:

  • the schema tier (the ladder did not select it: no table definition or v3 SQL changed). The new SQL ran against a real server in the integration test and the live pass;
  • CI's MySQL 8.0 (only MariaDB 10.11 here);
  • the Go tier (no Go file changed);
  • the browser passes (no page changed);
  • an agent-eval case (no agent-facing command changed);
  • the full tests/run-integration-suites.sh (only the new suite ran);
  • after the merge, the Go CLI checks of the live pass (licence stub, above);
  • CI, until it runs on this PR.

For the reviewer to check

  • The tier split. I chose hourly for the sweep and daily for the rest, from the table above. Putting the reconcile hourly too would cost about 0.2–0.7 s an hour at this size.
  • canceled_at dated by the rule. This is a deliberate change to what the sweep writes; the reasons are above.
  • The reconcile starts from the first customer each run. If an account's 48-hour-active set took longer than the daily 60 s budget (about 280,000 customers at the measured rate), its later customers would never be reconciled by the daily tier. The summary says out of time, next run: reconcile, and ltv_maintenance.php --budget=N does the whole set. I did not persist a cursor for that.
  • The strict options. ltv_maintenance.php refuses --batch-size below 50 or above 5000. Before, it clamped to 50.
  • The webhook dispatcher's lock and this one are separate. Both run inside index.php's file lock, one tier after the other.

Found outside this item (not fixed here)

  • The sweep queues no subscription.changed webhook event, while every API status change does (upsert, renewal, cancel). A CRM or the LPO never hears that a subscription lapsed. Emitting it would also send an upgraded install's whole backlog as events.
  • A trialing subscription whose trial ends with no renewal is never swept: no rule moves it, and it stays trialing forever. paused likewise.
  • GET /ltv/customers/{id} answers money and counts as mysqli strings ("mrr": "0.00000", "active_subscription_count": "0"). Other routes answer numbers (CLAUDE.md [Update] Prevent warnings in subid deletion #28).
  • MysqlCompanyRepository::linkUnlinkedCustomers() reads 2,000 pending rows with no order and without skipping whitespace-only company strings. More than 2,000 such rows in one account hold back linking for every other row in it (pre-existing).

Generated by Claude Code

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
Copilot AI balanced review requested due to automatic review settings October 28, 2026 05:16
@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-28T05:16:08.181919Z
We couldn’t complete this review. Comment @codex review to try again.
e2d4680 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.

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.

@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.

@github-actions

Copy link
Copy Markdown
Contributor

Code review: CLEAN

For e2d4680: 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 d0e1de0: 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
@tracking202
tracking202 merged commit 20b7ddb 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