Repository navigation
1.9.79: LTV Settings reads the row a button names exactly - #296
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
… in the last 7 days A webhook is marked dead when one delivery fails 6 times, about an hour of retries now that #273 sends them, and nothing could make it active again: LTV Settings said to register it again, which for the Landing Page Optimizer, whose pairing keeps the webhook's id and secret, meant pairing again. A dead webhook was also queued nothing, and the deliveries it had pending spent their remaining attempts on "endpoint not active", so the events of an outage were lost for good. - MysqlWebhookRepository::revive() makes a webhook that is not active active again with its id, URL and secret, resets its pending deliveries' attempts to 0, and (requeue, the default) makes every delivery of it not delivered and queued within the dispatcher's 7 days pending again, due now: the one that killed it and the ones held while it was dead. A delivered one is never touched. It locks the webhook row first, and an active webhook is left as it is. - While dead, enqueue() still queues its events, and the dispatcher holds each (holdDelivery(): failed, attempts as they were, "held: …"), never trying it. The hold asks the webhook's status again in its own UPDATE, so a delivery read while the webhook was dead and revived since is sent. - PUT /ltv/webhooks/{id} {"status": "active", "requeue": bool}, LTV Settings' Revive button on a dead row (its confirm names how many it missed), p202 ltv webhooks revive <id> [--requeue=false] and bin/p202 ltv:webhook:revive <id> [--no-requeue]. requeue is the default: the receiver was down, the events it missed are what keeps a subscriber (the optimizer's conversions, a CRM's revenue) consistent, the 7 days are the window the dispatcher already applies, and every one goes with its own X-P202-Delivery, so a receiver that did take one de-duplicates it. requeue false is there for a receiver that was replaced and wants new events only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…webhook-id This branch is stacked on #289: its Revive action reads the webhook id strictly, and the fix here reads Delete's id the same way. No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
LTV Settings' Delete read webhook_id with (int), so an id sent as 12abc deleted webhook 12: one of the account's own, and not the one named (CLAUDE.md #4). Measured live against the page before this change: webhook_id=5abc answered "Webhook deleted." and webhook 5 was gone. Delete now reads the id as #289's Revive does, through one helper both use (Prosper202\Http\PostedId::read(): digits only, no sign, no leading zero, round-tripped so a value past PHP_INT_MAX is no id), and refuses anything else with "Pick the webhook to delete from the list.". The page's other casts are swept the same way: the field's and the integration's Delete, the delivery log's webhook id, the customer c-param (1abc saved c1), the Required box (!empty() read "false" as ticked), the webhook events (a string, or no box ticked, subscribed the webhook to every event), and every text field, which (string) read as "Array" when sent as a list (an integration's provider was stored as "array"). ForbidNumericCastOfRequestParamRule now reads the request superglobals outside api/ as well, naming PostedId::read() there; the 28 pages that still cast (66 sites) are listed by count in phpstan-baseline.neon, so the counts only shrink. CLAUDE.md #4 says so, and what the rule does not see. Tests: PostedIdTest; LtvSettingsPostedValuesIntegrationTest runs the page as the account (page-runner) and posts each spelling; the rule's fixture now plants superglobal casts outside api/. tests/live/ ltv-settings-posted-ids.sh is the live pass. 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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 44591e2168
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… and waits for a run that is sending revive() with requeue false only reset the pending deliveries' attempts and made the webhook active, so the next run sent every delivery queued while it was dead that no run had held yet, and every one waiting out a retry's backoff: "new events only" sent old ones (Codex, on #296). Without requeue, every delivery of the webhook still pending is now held in the revival's transaction, as a dead webhook's are: failed, its attempts as they were, "held: its webhook was revived for new events only, …", never sent. The answer says how many (held). With requeue, the pending ones are sent: their attempts go back to 0 and each keeps its due time, so one waiting out a backoff waits at most 32 minutes. A claim (claimDelivery()) reads the same as a backoff, so a due time is never shortened. The failed ones it sends again are due at their queueing rather than at the revival, so they go oldest first with the pending ones no run has held yet. A delivery a run has claimed and not yet recorded must not be held or made due under it: a hold would be overwritten by the run's record (a held delivery sent anyway), and a requeued one could be claimed again while the first send is in flight. revive() now takes the dispatcher's lock (WebhookDispatcher::betweenRuns()), waiting up to 20 seconds for a run in flight; a run still sending then is an LtvConflictException, a 409 through the API with nothing changed, and the Go CLI hints to run it again in a minute rather than "update it instead". Tests: WebhookReviveIntegrationTest gains a pending row at revival time both ways and a revival from a second session while a run has claimed a delivery; each failed against a78e59a. The live pass revives with a delivery still pending, both ways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…v-delete-webhook-id Brings in #289's fix for a revival without requeue, which takes the dispatcher's lock and can answer a conflict. The conflicts were only in changelogs.txt and tests/live/README.md: #289's revised revive lines are kept as it has them, and this branch's lines follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
#289's revive() now waits up to 20 seconds for a dispatcher run still sending and then throws LtvConflictException with nothing changed. The page showed the repository's API-worded sentence; it now says, in its own words, that webhook #N was not revived because deliveries are being sent right now, nothing was changed, and to revive it again in a minute. LtvSettingsPostedValuesIntegrationTest holds the dispatcher's lock while the page revives a dead webhook: the sentence, and the webhook still dead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
|
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-webhook-revive (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
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0ad7dfcbc6
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
missedCounts() counted the failed deliveries a revival requeues and not the pending ones it leaves to be sent: those queued after the webhook died but before the next run held them, and retries waiting out their backoff, whose attempts revive() resets (found by review). The Settings confirmation could then promise fewer deliveries than the revival sends. It counts both, queued within the dispatcher's 7 days, the same set revive() sends; the integration test reads the count between the queueing and the run, and fails with the old count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Stacked on #289 (8YRG,
1.9.79-ltv-webhook-revive, now at 7ac4aa9), which is stacked on #273. Review and merge this after #289. Until #289 merges, this diff includes #273's and #289's commits. This PR's own commits are:5609f2a and 78afac8 are merges of #289's branch.
Backlog: 960Z
What was wrong
LTV Settings' Delete button posts
webhook_id, and the page read it with(int)(CLAUDE.md #4). An id sent as12abctherefore deleted webhook 12: one of the account's own webhooks, but not the one named. I measured this live against the page as #289 left it:webhook_id=5abcanswered "Webhook deleted." and webhook 5 was gone.The same page cast its other POST fields too:
field_id,integration_id(Delete)(int)3abcdeleted field or integration 3 (measured live)show_deliveries(Log)(int)6abcshowed webhook 6's delivery log (measured)cparam(int)1abcsaved c1 (measured)field_required!empty()falsemade the field requiredwebhook_eventsis_array ? strval… : [][], whichcreate()reads as every event (measured: no box ticked registered a webhook)(string)name[]=…) became"Array"; an integration's provider was stored asarrayWhat changed
Prosper202\Http\PostedId::read()reads the id a form or a row's button posts, or returns null for the page to refuse in its own words. It accepts digits only, with no sign and no leading zero, and round-trips the value, so a value pastPHP_INT_MAXis no id. (1.9.79: a dead LTV webhook can be revived, and is sent what it missed in the last 7 days #289's regex alone read it asPHP_INT_MAX.)webhook_idthrough the same helper.12abc,0,-1, an empty id and an absent one with "Pick the webhook to delete from the list."show_deliveriesat all is still no log and no refusal.cparammust be exactly0–4, the menu's values. Absent is refused, where it was read as Off.1or absent; anything else is refused.webhook_eventsmust be a non-empty list of strings.ltv_settings_posted(), which refuses a list by name.actionsent as a list is "Unknown action." with no cast and no warning.ForbidNumericCastOfRequestParamRulenow reads the request superglobals ($_GET,$_POST,$_REQUEST,$_COOKIE) everywhere, not only inapi/. Outsideapi/its message namesPostedId::read().$params/$queryParamsare still read only inapi/, because elsewhere those names say nothing.phpstan-baseline.neon, one entry per file by identifier and count. A page can lose a cast but cannot gain one.$_POSTfirst, and(string).Decisions to check
[]andcreate()reads[]as all. The API's documented[]= every event is unchanged.cparamis refused rather than read as Off. The form's select always posts it (CLAUDE.md subid missing in pixel url #4: a form that posts every field rewrites every field).ltv_maintenance.phpentries just below where202-cronjobs/dej.php's entry goes, so expect a trivial adjacent-line conflict there.(string)casts and the falsy test are fixed on this page by hand and tested. No rule was extended to cover them tree-wide, because the falsy test alone has far more legacy sites.Tests
tests/Ltv/LtvSettingsPostedValuesIntegrationTest.php(@group integration, scratch database, 9 tests). It runs the real page as the account throughtests/Setup/fixtures/page-runner.php, the way the web server runs it, and posts every spelling.Nabc,0,-1,'',N,N,N.0,0N,+N,[N]and absent. Each must answer the page's sentence and leave the record.tests/Http/PostedIdTest.php.NumericCastOfRequestParamRuleTest. The fixture's file outsideapi/now plants four superglobal casts, which must be reported, beside$paramsand(string), which must not.mktemp, compared withcmpand restored. A plant counted as caught only when a named test failed (for the two baseline rows, the phpstan tier). The harness made 18 runs, and two first runs missed. Neither removed a fix:field_requiredplant changed the payload line while thematchstill refused. It was planted again with thematchremoved, and was caught.PostedId's/Dalone is caught by nothing, because its round trip refuses"12\n"anyway. Removing the round trip is caught.The plants
(int) $_POST['webhook_id']testDeleteReadsTheWebhookIdExactly(int)testReviveReadsTheWebhookIdAsDeleteDoes(int)testAFieldAndAnIntegrationAreDeletedOnlyByTheirExactIds(int)show_deliveries(int)testTheDeliveryLogIsShownOnlyForTheWebhookNamed(int)rangetestTheCustomerCparamIsOneOfTheMenusValues!empty()testAFieldIsRequiredOnlyWhenItsBoxIsTicked[]testAWebhookIsSentTheEventsTickedAndNoneTickedIsRefused(string)testATextFieldSentAsAListIsRefusedNotStoredAsArray(string)PostedIdwithout its round tripPostedIdTest::testAnythingElseIsNoIdapi/onlyNumericCastOfRequestParamRuleTest::testEveryPlantedShapeIsReportedOnceAndNothingElseIs$paramsoutsideapi/$_POST['webhook_id'] is read with (int)inltv_settings.phpltv_companies.php, 7 → 8)The revival-conflict sentence was added after the plants ran. Its test fails without the catch, because the page then shows the repository's words.
Live pass
tests/live/ltv-settings-posted-ids.sh(new) ran on an instance installed from this branch: PHP 8.3.6php -Sthrough the eval router, MariaDB 10.11, port 8321. 27 passed, 0 failed, on 44591e2 and again on ba57261 after the merge. The same script against the page as #289 had it gave 10 passed and 17 failed;5abcdeleted webhook 5.202-login.php's own form (form-body.py), then makes two webhooks, a field and an integration through the API.ltvSettingsAction: the action, the page's token, the id) withNabc,0,-1, empty and absent. Each is refused with "Pick the webhook to delete from the list.", and both webhooks are still listed byGET /ltv/webhooks. ThenNdeletes that webhook and only that one.Nabcis refused and the webhook stays dead;Nmakes it active. The field's and the integration's Delete refuseNabcand keep the row, thenNdeletes it.Nabcis refused and no log is shown;Nshows webhook N's log.cparam=1abcis refused, and the stored c-param is unchanged;The revival conflict was not driven live: it needs a dispatcher run held in flight. The integration test drives it through the real page.
Licence (the stub is down)
install.php's POST checks the key with my.tracking202.com, so I did not run it. A scratch script ran the installer's own steps without the licence form:INSTALL::install_databases(),InstallAccount::create(), the202_api_keysrow andinstall_database_partitions(). AutoCron and the daily email registration, which call outside services, were left out.HTTPS_PROXY,https_proxy,HTTP_PROXY,http_proxy) pointed athttp://127.0.0.1:9, a closed port, and withNO_PROXY=localhost,127.0.0.1, so there was no bypass for my.tracking202.com. The licence check then gets no answer, andAUTH::is_valid_api_key()fails open, as it does on any network failure. Every licence check in the server log isFailed to connect to 127.0.0.1 port 9. Nothing reached the licence service, and no code or session was changed for the pass.Verification
.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed, on ba57261 (after the merge of 7ac4aa9), PHP 8.3.6. The schema tier ran on a scratch MariaDB 10.11 database. The unit tier ran 6,118 tests with 5 skipped: the 4 documented "User class requires DB singleton" skips, andCronEntryPointsFailLoudlyTest's executed case, which skips while the worktree holds the live instance's202-config.php. This range includes #273's and #289's changes, which is why the Go and openapi tiers ran.Beyond the script, after the merge, each suite against a scratch MariaDB 10.11 database:
LtvSettingsPostedValuesIntegrationTestWebhookReviveIntegrationTestWebhookDispatchIntegrationTestshellcheck --severity=warningandbash -non the new live script: clean.Not run:
INSERT … SET,DELETE,UPDATEandGET_LOCK, all valid on MySQL 8;tests/run-integration-suites.sh;Found outside this item (not fixed here)
ltv_companies.php(7 sites): merge readssource_company_idandcompany_idwith(int), so12abcmerges company 12;ltv_products.php(5);ltv_customer.php(3):alias_idandsource_customer_id;ltv_subscriptions.php,ltv_merge_search.phpandsort_ltv.php(1 each).AUTH::require_permissions): any signed-in role can delete or revive the account's webhooks, fields and integrations.🤖 Generated with Claude Code
https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr