Repository navigation
1.9.79: one analytics delivery at a time, sync-messaging.php's and the cron job's - #316
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
Stacks the cron lock on #285 (and #273), which change 202-cronjobs/index.php. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
202-cronjobs/index.php kept two runs apart by a file's presence: file_exists(), then touch(), so two runs starting together could both find it absent; once the file was ten minutes old the next run deleted it as stale and ran beside the one still going (measured: a run held in its first tier and a second started with the file backdated eleven minutes both ran the minute and hourly tiers); and a run that died left the file, so every run for the next ten minutes, page pings included, did nothing. 1.9.79 adds work to the tiers (LTV webhooks, LTV maintenance, the GeoIP refresh), which makes a run past ten minutes likelier. The lock is now Prosper202\Cron\CronLock: LOCK_EX|LOCK_NB on 202-cronjobs/cron-run.lock, taken right after the bootstrap (so a database that needs an upgrade still stops the run before anything), held by a variable for the life of the run and released when the object goes or the process dies. A run that finds it held logs why and exits 0 without recording a run. A file that cannot be made, opened or locked throws CronLockFailed, which index.php's catch turns into exit 1 / 500 and cronjob.log, never "busy"; a stream without lock support is refused before flock(), whose would-block flag reads a stale errno there. The file is made 0644 whatever the umask and opened read-only when this user may not write it, so a crontab's php line as root and the web server's pings share it; 'e' keeps a process the run starts from inheriting the lock. The file is never removed. A new name, so an earlier version's cron.lock neither blocks nor is unlinked under a holder. health.php's isLocked asks the lock (an instant's LOCK_SH) instead of the file's existence; lockAge and the stale recommendation are gone, as the lock has no start time. CronLockTest runs the lock in child processes (four at once: one runs; a run eleven minutes old by the file's time still keeps the next out; a killed run frees it at once; a process the run starts does not keep it; every failure to open or lock is loud). EveryCronRunTakesTheLockTest holds index.php to taking it first after the bootstrap and keeping it, nothing else including index.php, every file that names the lock to a list, and every touch() presence lock in the tree to a list (sync-messaging.php is the one left). tests/live/cron-lock.sh runs index.php itself the same ways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…s one tests/live/cron-lock.sh asked 202_cronjobs how many minute runs there had been while its own other session held that table in LOCK TABLES … WRITE: the read waited for the hold to end, the held run then finished, and the next step's run took the free lock in a new minute, so the pass failed on a race of its own (found by running it again under load). The held run's own output now says it has not got past its first check, and the count is read once the hold is released. The eleven-minute step also sets back a cron.lock's time, the file the old lock read, so the step fails against the old lock (measured: its run went past and ran the minute tier beside the held one). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…e cron job's 202-cronjobs/sync-messaging.php kept its runs apart with the lock 202-cronjobs/index.php had before #299: file_exists(), then touch() of messaging-sync.lock, deleted as stale once 600 s old and on shutdown. Nothing kept it apart from index.php's hourly DeliverPendingAnalytics(), which runs the same MessagingService delivery over the same rows (202_messaging_events, _attributes, _sync). Measured on a live instance against a stand-in portal that holds each /track request: a run started with the lock file eleven minutes old, index.php's hourly run beside a delivering sync-messaging.php, and sync-messaging.php beside index.php's delivery each sent the same events a second time; a run killed with SIGKILL left the file, and the next run did nothing. Two deliveries at once read the same pending rows, so each event went out twice and, while the portal was down, spent two of its five attempts on one outage. The overlap now sits under one lock rather than under the cron job's: both deliver through MessagingService::deliverPendingExclusive(), which holds a CronLock on 202-cronjobs/analytics-delivery.lock (CronLock::ANALYTICS_DELIVERY_FILE; path() takes the file) for the delivery, and answers null while another run holds it. deliverPending() is private, so nothing delivers the backlog without the lock. The lock keeps CronLock's guarantees: no age, close-on-exec, 0644, the stream checked for lock support, and a failure to make, open or lock the file throws CronLockFailed rather than reading as busy. A run that finds it held delivers nothing: sync-messaging.php logs "Analytics delivery already running: another run holds …; this run did nothing." and exits 0; the hourly tier says it skipped and goes on. Not cron-run.lock itself: sync-messaging.php does only the delivery, so on it a delivery waiting out the portal's timeouts would have held back the minute tier and every page ping, and every index.php run would have turned it away. EveryCronRunTakesTheLockTest's list of touch() presence locks is gone: none remains in the tree. It now also holds deliverPending() private, deliverPendingExclusive()'s body, and both callers' statements (the same lock file, the held branch). The new OneAnalyticsDeliveryAtATimeIntegrationTest runs sync-messaging.php itself in child processes on a scratch database against a holding stand-in portal (LocalHttpRecorder can hold its answers now): of three runs started together one delivers and the portal is asked once; a run past eleven minutes (every lock file set 660 s back) keeps the next out; a killed run's lock is free at once; a directory where the file goes is exit 1 with the reason, nothing sent. With the old script planted back, the eleven-minute and killed-run cases fail on behaviour. tests/live/sync-messaging-lock.sh runs it beside index.php. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in #263 and #266. changelogs.txt, 202-config/changelogs.json and tests/live/README.md conflicted on adjacent lines: 1.9.79's kept, this stack's after them. 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. |
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
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
Brings in cron-lock (this branch's stacked base), and through it 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
Contributor
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: BT5Z
Stacked on #299 (and so on #285 and #273): merge after them. This branch is cut from
1.9.79-cron-lock(#299's head,ce1f233b, still its head when this was opened), withorigin/1.9.79(#263, #266) merged in by a merge commit; its own work is17dc3d98.What was wrong (measured on a live instance before changing anything)
202-cronjobs/sync-messaging.phpkept its runs apart with the lockindex.phphad before #299:file_exists(), thentouch()of202-cronjobs/messaging-sync.lock, deleted as stale once 600 s old and on shutdown. And nothing kept it apart fromindex.php's hourlyDeliverPendingAnalytics().They do the same work. Both call
MessagingService::deliverPending()over the same rows: every active user with a pending event in202_messaging_eventsor a dirty202_messaging_attributesrow, each user's events sent to the portal'sPOST /trackand markedsent, orsync_attempts + 1on a failure (five and the event is dropped), with202_messaging_syncstamped. Two deliveries at once read the same pending rows before either marks them, so each event goes out twice, and while the portal is down each failure costs two of an event's five attempts for one outage.Measured with
tests/live/sync-messaging-lock.sh(below) run against #299's files. A stand-in portal on loopback held each/trackrequest, so a run was held mid-delivery. 26 of 58 checks failed:index.php's hourly run beside a deliveringsync-messaging.phpsent them again (Analytics delivery: 1 delivered, portal asked twice).sync-messaging.phpbesideindex.php's delivering hourly run sent them again (portal asked twice).messaging-sync.lock: the next run said "Messaging sync already in progress" and the events stayed pending.What changed
The overlap is under one lock of its own, not the cron job's.
MessagingService::deliverPendingExclusive($db, $lockPath)takes aCronLockon$lockPathand delivers while holding it. It answersnullwhile another run holds the lock.deliverPending()is now private, so nothing delivers the backlog without the lock.sync-messaging.phpandindex.php'sDeliverPendingAnalytics()both call it withCronLock::path(CronLock::ANALYTICS_DELIVERY_FILE), which is202-cronjobs/analytics-delivery.lock.CronLock's guarantees carry over unchanged (same class, a second file name):'e'(close-on-exec) on every open;stream_supports_lock()checked beforeflock();LOCK_EX | LOCK_NB;CronLockFailed, never "busy".CronLock::path()now takes the file name (defaultFILE), and the class docblock names both locks.What each job does now:
sync-messaging.php: the presence lock is gone. A run that finds the lock held writesAnalytics delivery already running: another run holds <path>; this run did nothing.to the error log, says "Analytics delivery already running" and exits 0, asindex.php's held branch does. ACronLockFailedlands in its existing catch: "Analytics delivery failed: the cron job could not make its lock file …", exit 1.index.php: while the lock is held,DeliverPendingAnalytics()echoesAnalytics delivery: another run holds the lock; skippedand the hourly tier goes on. A lock failure is logged asAnalytics delivery failed: …(its existing catch) and the tier goes on with its other work.Housekeeping:
.gitignore,.dockerignore, the Dockerfile comment, thetrack.phpdocblock,202-config/Messaging/CENTRAL-API.md(operational note),documentation/api/15-system.md(the cron lock section #299 added, in its wording),changelogs.txtand202-config/changelogs.json("Fixed:"), andtests/live/README.md.Decisions a reviewer should check
Its own lock file, which both jobs take, rather than
cron-run.lock.sync-messaging.phpdoes only the delivery;index.phpdoes the delivery once an hour among everything else. Sharingcron-run.lockwould have costs in both directions:sync-messaging.phprun every 5 minutes would hold backindex.php's minute tier (click rollups, LTV webhooks) and every page's ping for as long as it delivers. With the portal down that is up to three users × two tries × the client's 10 s timeout.index.phprun in progress, a page ping's included, would turn away async-messaging.phprun that starts beside it.With the overlap under its own lock, neither holds the other's unrelated work. This also matches how the tree keeps the attribution worker, the Android intake and the LTV jobs apart from their
index.phptiers: a per-job lock (runExclusive()) taken by both. It is anflock()and notGET_LOCK()because the item asks for aCronLock. Nesting is safe:index.phptakes the delivery lock while holdingcron-run.lock, andsync-messaging.phptakes only the delivery lock. Both areLOCK_NB, so nothing waits and no cycle can form.A new file has to be openable. An install that followed 1.9.79: the cron job holds an flock() for the whole run #299's advice for a web server that cannot write
202-cronjobs/(make an emptycron-run.lockby hand) now also needs an emptyanalytics-delivery.lock. Without it, the hourly delivery logsAnalytics delivery failed: the cron job could not make its lock file …every hour and delivers nothing, while the rest of the cron runs. The changelog says so. The alternative, delivering unlocked, is the fail-open this item removes.When
index.php's hourly tier skips its delivery, it still records the hour. The holder is delivering the same backlog. Events recorded after the holder listed its users wait for the next run of either job.deliverPending()made private rather than a structural ban on calling it. The two tests that called it now calldeliverPendingExclusive()with a lock of their own in the temp dir.The old
messaging-sync.lockis not read and not removed (as 1.9.79: the cron job holds an flock() for the whole run #299 did withcron.lock). A leftover from a killed old run can be deleted; the changelog says so.The integration test runs the real script on the checkout's real lock file. Its runner boots
connect.phpagainst the scratch database, asaccount-scope-runner.phpdoes: the DB class reads globals the runner sets. A shutdown check fails the run with exit 2 if the script ran on any other database. It needs a202-config.php, whichtests/run-integration-suites.shwrites in CI.*.lockin202-cronjobs/660 s back, so it reads the old lock's clock whatever file a run takes.tearDown.LocalHttpRecorder(shared with the messaging tests) changes in two ways. It can hold its answers (hold()/release(), at most 8 s, insideMessagingClient's 10 s timeout so a held request is never retried). It also runs 4 workers. The hold loop callsclearstatcache(); measured,is_file()in a loop keeps answering true for a removed file without it.Tests
tests/Cron/OneAnalyticsDeliveryAtATimeIntegrationTest.php(new,@group integration) runs202-cronjobs/sync-messaging.phpitself in child processes (tests/Cron/fixtures/sync-messaging-runner.php). It uses a scratch database with the schema installed and a stand-in portal that holds each/trackrequest:sentonce with no attempt spent.tests/Cron/EveryCronRunTakesTheLockTest.php:PRESENCE_LOCKSis gone, so thetouch()scan now holds the whole tree to none.testTheAnalyticsDeliveryIsMadeOnlyUnderItsLockholds four things:deliverPending()is private (reflection);deliverPendingExclusive()'s body: take,nullwhen held, deliver while holding;deliverPending*areMessagingService,index.phpandsync-messaging.php;ANALYTICS_DELIVERY_FILEpath, and the held branch.NAMED_BYlists the two new files that name a cron lock, and the file-name regex and thermcheck coveranalytics-delivery.lock.tests/Cron/CronLockTest.php:path(ANALYTICS_DELIVERY_FILE)is beside the cron job.EveryCronJobIsScheduledByDefaultTest:sync-messaging.php's entry point isdeliverPendingExclusive.MessagingServiceIntegrationTest: its two delivery calls use the locked entry point.Plants (each backed up to a fresh
mktemp, restored andcmp-checked, the suites re-run clean after):EveryCronRunTakesTheLockTest)OneAnalyticsDeliveryAtATime…)deliverPending()public again, as it was), each case run alonedeliverPendingExclusive()unset($lock))index.phpdelivering underCronLock::path()(the cron's own lock: a secondtake()of it in the process that holds it answersnull, executed, so the hourly delivery would always skip)index.php; the live pass does)sync-messaging.phpreading a held lock as nothing to doVerification
Ladder on the working tree, just before committing (
--changed):Ladder as the brief asks (
--since origin/1.9.79 --changed), on the commit beforeorigin/1.9.79was merged in. It includes #273/#285/#299's files: their Go-tested files selectedgoandgolangci, which ran under a toolchain already in the shared cache, and #285'sLtvTables.phpselectedschema:The schema tier was then run alone against a scratch database made for it:
After merging
origin/1.9.79(#263, #266), the PHP tiers again (--since origin/1.9.79 --tier syntax --tier phpstan --tier phpcs --tier unit --tier patterns). Unit: 6210 tests, 6 skipped: the four documentedUserTestDB-singleton skips,CronLockTest's unreadable-file case as root, andCronEntryPointsFailLoudlyTest's no-install case (the live instance's202-config.phpis in the checkout). PHP 8.3.6.Integration, run alone on freshly created scratch databases, after the merge (MariaDB 10.11, PHP 8.3.6):
OneAnalyticsDeliveryAtATimeIntegrationTest: OK (4 tests).MessagingServiceIntegrationTest: OK (8 tests, 259 assertions).Live, on an instance from this branch. It was installed with
install.php's account steps without the licence form, because the licence stub is down.php -Sran on 8169 with every proxy variable pointed at the closed port 127.0.0.1:9. The jobs ran from the CLI as a crontab runs them; no web request is involved.tests/live/sync-messaging-lock.sh(new): 58/58, three runs in a row, and again after the merge. It covers the six steps above, plusindex.php's hourly tier with the lock unmakeable (logs it, finishes the tier, exit 0).sync-messaging.phpstarted twice in the same instant (go-file), ten times, against a portal that answers at once: each time one delivered, the other was turned away, and the portal was asked once.What could not run
--since origin/1.9.79ladder selected them for 1.9.79: the minutely cron sends LTV webhooks, within a budget #273/1.9.79: the cron job sweeps lapsed LTV subscriptions hourly and runs the rest of LTV maintenance daily #285's files; this change has no Go.Found outside this item
202-account/ajax/messaging/track.php's page delivery (MessagingService::sync(), per user, throttled by202_messaging_sync.last_sync) is not under this lock. A page's sync and the cron's delivery for the same user can still both read that user's pending events before either marks them. The throttle stamp narrows the window but is not a lock. Not changed here; a backlog candidate.Generated by Claude Code