Repository navigation
1.9.79: the cron job holds an flock() for the whole run - #299
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
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
This was referenced Oct 28, 2026
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
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: Q36S
Stacked on #285 (and so #273): merge after them. This branch carries #285 (
1.9.79-ltv-maintenance-scheduled, which carries #273) by a merge commit; its own work is the two commits after that merge.What was wrong (measured on a live instance before changing anything)
202-cronjobs/index.phpkept two runs apart by a file's presence:file_exists(), thentouch(), the file deleted as stale once it was 600 s old.202_cronjobsinLOCK TABLES … WRITE), withcron.lockbackdated eleven minutes: the next run deleted the file and ran; both then ran the minute and the hourly tiers, the named locks of LTV maintenance and the Android intake each turning one of them away, and the first run'sunlink()warned because the second had removed its file.touch()widened to 100 ms (scratch copy of the lines), two runs started together both ran.1.9.79 adds work to the tiers (#273's LTV webhooks, #285's LTV maintenance, #277's GeoIP refresh), which makes a run over ten minutes likelier.
What changed
Prosper202\Cron\CronLock:flock(LOCK_EX | LOCK_NB)on202-cronjobs/cron-run.lock, held while the object is, released by its destructor or by the kernel when the process dies. No age, nothing removes the file.take()answers the lock, ornullwhen another run holds it. Anything else throwsCronLockFailed: a file that cannot be made, one this user can neither write nor read, a stream without lock support (refused beforeflock(), whose would-block flag reads a stale errno there — 1.9.79: show the GeoIP database's age, and refresh it with the operator's MaxMind account #277'sGeoIpRefresh::tryLock()lesson), or a lock error other than would-block.'x'and thenchmod 0644, whatever the umask, and opened read-only when this user may not write it (flock()needs only a read handle). Every open uses'e'(close-on-exec): measured, without it a process the run started kept the lock after the run had exited.isHeld()(for health.php): an instant'sLOCK_SH | LOCK_NB;nullwhen it cannot tell.202-cronjobs/index.php: the old lock lines are replaced by$cronLock = CronLock::take(CronLock::path())right after the bootstrap, in the top-leveltryevery run goes through, followed by the held branch:error_log('Cron job already running: another run holds …; this run did nothing.')andexit("Cron job already running\n")(exit 0, no run recorded). Bothunlink()s are gone. ACronLockFailedlands in the existing catch:cronjob.log, exit 1 with the reason on stderr, 500 over HTTP. The page ping (202-cronjobs/?page_ping=1), the scheduler line (curl orphp), the Docker cron services and AutoCron all request or run this one file, so all take the lock.202-cronjobs/health.php:isLockedasks the lock instead of the file's existence (the file now stays between runs, so existence would read "locked" for ever).lockAge,lockAgeFormattedand the "Lock file is stale" recommendation are gone: an flock has no start time, and the stale case cannot happen. A new recommendation whenisLockedisnull(the web server cannot open or lock the file, so its own runs cannot either)..gitignore,.dockerignore, the Dockerfile comment;tests/live/cron-needs-upgrade.shandreclick-backfill-cron.shstop deletingcron.lock(the upgrade pass now asserts the lock is free); docs (documentation/api/15-system.md, Cron Status),changelogs.txt,202-config/changelogs.json.Decisions a reviewer should check
202-cronjobs/), under a new name. Every default scheduler (the installer's printed line andinstall.sh:curl …/202-cronjobs/index.php; both Docker compose files:wget http://web/…; AutoCron) runs as the web server's user, and the Docker image giveswww-datathe tree. A crontab'sphp …/index.phpas root is the mixed case: root's file is made 0644 even under umask 027 (measured: without the chmod,nobodycould not open a root-made file and every page ping would have failed), and the web user locks it read-only (measured across users withrunuser -u nobody, both directions). Not a shared temp dir: different users and a PrivateTmp web server would lock different files, and anyone could pre-create one there (CLAUDE.md [Maintenance] Remove deprecated PHP features #16).touch()warned and ran without a lock. That stops the cron on such installs until fixed; the changelog says so and what to do. The alternative, running unlocked, is the fail-open this item removes.cron.lock: not read and not removed. A new name means an earlier version's leftover file neither blocks nor is unlinked from under a holder (an in-flight old run at deploy would haveunlink()ed a same-named file a new run had locked, letting a third run lock a new inode). The live pass runs beside a freshcron.lock: it runs.GET_LOCK(?, 0)in the attribution worker, Android intake, LTV webhooks, LTV maintenance): every one is non-blocking and the outer lock isLOCK_NB, so no process ever waits on either kind and no cycle can form. Their meaning is unchanged: they keep a tier ofindex.phpapart from the same job's standalone entry point; twoindex.phpruns no longer meet there at all. The standalone entry points do not take the file lock.connect.php's upgrade stop still runs before the lock (the lock's class comes with its autoloader anyway);tests/live/cron-needs-upgrade.shpasses (77/77) and asserts the lock free after every job refused.isLockedsays a run holds it; but nothing reports how long (no start time is kept: a holder that opened the file read-only could not record one, and a stale one would read as an answer). The tiers each have budgets; a query waiting on a metadata lock or a request without a timeout is the case. Stated in the changelog.isHeld()'s docblock).GET_LOCKwould cover that and was not what the item asked for.Tests
tests/Cron/CronLockTest.php(child processes throughtests/Cron/fixtures/cron-lock-runner.php): four runs released by one go-file in the same instant: exactly one runs; a run whose lock file's time is set eleven minutes back (the old lock's only clock; the flock reads none, so no wait or fake clock stands in for ten minutes) keeps the next run out, which runs once it ends; a SIGKILLed run's lock is free at once (the file stays); a process the run starts does not keep it; released when the object goes; the file is 0644 under umask 077; a read-only file is locked read-only; a file that cannot be made, one that cannot be opened (non-root only: skipped as root, run here asnobody, 12/12), a stream without lock support and the same after a stale EAGAIN each throw, never "busy". A runner that waits for the lock instead of being turned away fails within ten seconds rather than hanging the suite.tests/Cron/EveryCronRunTakesTheLockTest.php(structural):index.phptakes the lock once, directly after its bootstrap in the top-level try, before every tier and the run's record; the held branch is exactly the log line and the exit;$cronLockis named twice (assigned, tested) and the file calls nounlink,unset,extract,$$or$GLOBALS; nothing else includesindex.php, andindex.phpis the directory's only index; every file outside tests/ that names the lock is listed; no script intests/live,buildorscriptsremoves it by name; and no other job keeps runs apart bytouch()ing a file, with202-cronjobs/sync-messaging.phplisted as the one left (the same lock; a backlog item). Thetouch()scan was planted in six spellings (touch,\touch,TOUCH,namespace\touch,@touch, in a condition): each caught;$o->touch()andFoo::touch()not.tests/Cron/CronEntryPointsFailLoudlyTest.php: the lock-after-bootstrap check reads the new statement.Plants (each restored and
cmp-checked):index.phplock lines back: 5 tests ofEveryCronRunTakesTheLockTestandCronEntryPointsFailLoudlyTest::testTheMinutelyCronTakesItsLockOnlyAfterItHasBootstrappedfail, and the live pass fails 17 of 40, among them "a run eleven minutes into the held one … is still turned away" (it ran the minute tier beside the held run: two minute rows) and, after a SIGKILL, "it was not turned away" (the next run said "already running");CronLock(run as root and asnobody): no close-on-exec, no chmod, nostream_supports_lock()check, every failure read as busy, no read-only fallback, a shared lock taken, a blocking lock, andisHeld()answering false when unsure: each fails a named test (the read-only fallback and theisHeld()one only as non-root, as CI runs).Verification
Ladder since the stacked base (
--since origin/1.9.79-ltv-maintenance-scheduled --changed, run on the first commit; the second changes onlytests/live/cron-lock.sh, for which--tier syntaxreports PASS):Ladder as the brief asks (
--since origin/1.9.79 --changed, which includes #273/#285's files):The schema tier was then run alone against a scratch database made for it (selected by #285's
LtvTables.php, not by this change):Unit: 6138 tests, 6 skipped (the four documented
UserTestDB-singleton skips;CronLockTest's unreadable-file case as root, run separately asnobody;CronEntryPointsFailLoudlyTest's no-install case, because the live instance's202-config.phpis in the checkout). PHP 8.3.6, MariaDB 10.11.Live, against an instance on this branch (installed without the licence form, see below):
tests/live/cron-lock.sh(new): 40/40, three runs in a row. A real run held in its first tier turns away a second command-line run (exit 0, "already running", the log line on stderr), a signed-in page's ping (POST 202-cronjobs/?page_ping=1) and a scheduler's/AutoCron'sGET 202-cronjobs/index.php, none recorded, health.phpisLocked: true; with the lock file (and acron.lock) set eleven minutes back, still turned away; the held run then finishes as the one minute run and the one recorded; a SIGKILLed run's lock is free and the next run takes it at once; a run beside a freshcron.lockruns; a directory where the lock file goes ends the run with exit 1, the reason on stderr, nothing run or recorded.index.phpstarted twice in the same instant (go-file), ten times: each time exactly one ran the minute tier (9 times the other was turned away; once it started after the first had finished and found the minute done).php -Srequest is free for the next request in the same server process after a normal end, an uncaughtError, a fatal error,exitandmax_execution_time.tests/live/cron-needs-upgrade.sh: 77/77 (the upgrade window: every job refused, the lock free).tests/live/reclick-backfill-cron.sh(only itsrm -f cron.lockline changed), after one campaign was created through the API for it: 11/12; the failure is pre-existing and not this change: it greps the log for "stepped over after failing in 10 runs" whileReClickBackfillwrites "stepped over after failing with its own error in 10 runs" onorigin/1.9.79as well.#277: a local merge of this branch with
origin/1.9.79-geoip-refreshconflicts in202-config/changelogs.json(as every 1.9.79 PR), and in202-cronjobs/index.phpandtests/Validation/OutboundUrlGuardTest.phpbetween #285/#273 and #277, not on this change's lines (index.php's lock lines andhealth.phpmerge cleanly). With those resolved by keeping both sides:tests/GeoIp/RefreshLockTest.phpOK (4 tests, including the hourly-tier ordering testtestTheCronJobRecordsItsHourBeforeTheRefresh),tests/GeoIp/OK (104),tests/Cron/OK (49, 2 skipped as above),OutboundUrlGuardTestOK (29). #277's comments that the cron reads its lock as stale after ten minutes (GeoIpRefresh's docblock,RefreshLockTest's, the hourly-tier comment) stop being true once both merge; the ordering they protect is still harmless.What could not run
install.php's own account steps without the licence form (the installer's POST, the Go CLI and the seeder were not run). Nothing here touches the licence or the Go CLI; the agent eval and the Go tiers did not run (no Go or agent-facing change).Generated by Claude Code