fix(github-webhook): bound reviewer-wake lock-timeout retries and record dead_lettered (BLO-21582) - #1003
fix(github-webhook): bound reviewer-wake lock-timeout retries and record dead_lettered (BLO-21582)#1003allyblockcast[bot] wants to merge 11 commits into
Conversation
1 similar comment
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The synced skill bodies were unavailable in this runtime, so the same checks were applied directly to the exact diff and head files. Important Issues (1)
Strengths
Recommended Action
This PR is authored by |
allyblockcast
left a comment
There was a problem hiding this comment.
Approved with the allyblockcast reviewer seat after the automated review gate passed; merge remains gated on the remaining CI checks.
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Critical Issues (0)Important Issues (2)
Suggestions (1)
Strengths
Recommended Action
The PR is authored by |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The synced skill bodies were unavailable in this runtime, so the same checks were applied directly to the exact diff and current-head files. Prior Findings Dispositioned (3)
Important Issues (2)
Strengths
Recommended Action
This PR is authored by |
…nd the lock-exhaustion fallback recheck (BLO-21582) Ally review follow-up on this branch (PR #1003, review at issuecomment-5182720378) found two still-live gaps in the previous commit's withPrReviewerTaskLock: 1. The deadline raced the WHOLE transaction returned by db.transaction(), including action(tx) itself, not just pool checkout + the advisory-lock probe. If the lock was acquired near the deadline, the handler could abandon a live action() that later commits a wake -- while the catch block, having observed no wake yet, recorded received+dead_lettered and answered reviewerWakeFired: false. The late action then incremented received again and queued the wake, producing both a false dead-letter and broken funnel counts. Fixed by resolving a dedicated `lockProbeSettled` promise the instant the pg_try_advisory_xact_lock probe itself settles, before action(tx) ever runs, and racing ONLY that against the deadline. Once the probe reports the lock is ours, we await the in-flight transaction (running action) to completion unconditionally instead of racing it further. 2. The lock-exhaustion fallback recheck (findExistingPrReviewerWake / findActivePrReviewerForTask / selectPrReviewerAgentId) ran outside any deadline. A saturated pool that timed out the lock probe could just as easily stall these reads indefinitely, defeating the whole point of the request-wide budget and GitHub's response-window protection. Fixed with a small additional budget (PR_REVIEWER_TASK_LOCK_FALLBACK_BUDGET_MS, 1s) appended to the lock deadline. A timeout on either read is treated as "unknown" -- distinct from a confirmed "no equivalent wake" / "no active reviewer" -- and falls through to the pre-existing conservative default (record the delivery as lost) rather than blocking the response further. Two new integration tests: - Delays heartbeat.wakeup() (via a slow penstockAvailabilityGate) past the 4s lock budget after the advisory lock is acquired with zero contention, and asserts a single terminal outcome (reviewerWakeFired: true, one received/queued pair, no dead-letter) with no metric movement after the response is sent. - Forces the lock probe to exhaust its budget via genuine advisory-lock contention AND separately blocks the fallback recheck's own read with an ACCESS EXCLUSIVE table lock on agent_wakeup_requests held far longer than the fallback budget -- proving the bound, not the lock's eventual release, is what lets the response return (well under the 8s both locks are held for), while still recording the delivery as lost. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Pushed 1. 2. The lock-exhaustion fallback recheck ( New tests (both passing against the fix, both in
Full Note: this PR had been added to the merge queue (position 27) sometime after the review comment landed, which would have shipped the still-broken code — I dequeued it to push this commit. Re-queue once this pass looks good. |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The published skill bodies were unavailable in this runtime, so the same checks were applied directly to the exact diff and current-head files. Prior Findings Dispositioned (2)
Important Issues (2)
Strengths
Recommended Action
This PR is authored by |
…ord dead_lettered (BLO-21582) withPrReviewerTaskLock's per-PR advisory-lock acquisition can time out (2s budget) when the current holder is itself stalled acquiring the second pooled connection heartbeat.wakeup() needs (see the comment on withPrReviewerTaskLock) -- reproduced live in production during a burst of concurrent webhook deliveries. That timeout landed in the outer catch and returned false BEFORE the `received` counter a few lines further in ever incremented, so the loss was invisible to the entire paperclip_github_review_request_delivery_total funnel: not `received`, not `queued`, not `dead_lettered`. A review request that "routed correctly" on every webhook-side log vanished with zero record anywhere, while the handler still answered GitHub 200 so GitHub's own redelivery-on-failure never fired either. Adds a bounded retry (3 attempts, 300ms/900ms backoff) around the lock acquisition -- safe to re-run because the guarded closure re-checks existingWake before doing anything -- and, once every attempt is exhausted, records dead_lettered directly so this loss is finally counted by the funnel invariant the BLO-18859 observability work already built (received == queued + suppressed + dead_lettered). Two new integration tests reproduce genuine cross-session advisory-lock contention against the embedded test Postgres: one proves a contention window shorter than the retry budget self-heals, the other proves an exhausted one is recorded as dead_lettered rather than silently dropped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… false dead-letters on lock exhaustion (BLO-21582) Ally review follow-up on this branch (issue comment 5177920386): - Replace the 3-attempt x fresh-2s-each retry loop (worst case ~7.2s, and only bounded *after* each db.transaction() returned, so a stalled pool checkout wasn't bounded at all) with a single request-wide 4s deadline that withPrReviewerTaskLock races pool checkout + the lock probe against directly. - On lock exhaustion, recheck for an equivalent durable wake (or confirm no reviewer was ever active) before recording dead_lettered, so a concurrent duplicate delivery that already completed the wake no longer produces a false loss alert. Falls back to the pre-existing received+dead_lettered recording only when neither recheck explains the outcome. Adds a regression test for the false-dead-letter case and updates the two existing lock-contention tests for the new single-budget timing.
…nd the lock-exhaustion fallback recheck (BLO-21582) Ally review follow-up on this branch (PR #1003, review at issuecomment-5182720378) found two still-live gaps in the previous commit's withPrReviewerTaskLock: 1. The deadline raced the WHOLE transaction returned by db.transaction(), including action(tx) itself, not just pool checkout + the advisory-lock probe. If the lock was acquired near the deadline, the handler could abandon a live action() that later commits a wake -- while the catch block, having observed no wake yet, recorded received+dead_lettered and answered reviewerWakeFired: false. The late action then incremented received again and queued the wake, producing both a false dead-letter and broken funnel counts. Fixed by resolving a dedicated `lockProbeSettled` promise the instant the pg_try_advisory_xact_lock probe itself settles, before action(tx) ever runs, and racing ONLY that against the deadline. Once the probe reports the lock is ours, we await the in-flight transaction (running action) to completion unconditionally instead of racing it further. 2. The lock-exhaustion fallback recheck (findExistingPrReviewerWake / findActivePrReviewerForTask / selectPrReviewerAgentId) ran outside any deadline. A saturated pool that timed out the lock probe could just as easily stall these reads indefinitely, defeating the whole point of the request-wide budget and GitHub's response-window protection. Fixed with a small additional budget (PR_REVIEWER_TASK_LOCK_FALLBACK_BUDGET_MS, 1s) appended to the lock deadline. A timeout on either read is treated as "unknown" -- distinct from a confirmed "no equivalent wake" / "no active reviewer" -- and falls through to the pre-existing conservative default (record the delivery as lost) rather than blocking the response further. Two new integration tests: - Delays heartbeat.wakeup() (via a slow penstockAvailabilityGate) past the 4s lock budget after the advisory lock is acquired with zero contention, and asserts a single terminal outcome (reviewerWakeFired: true, one received/queued pair, no dead-letter) with no metric movement after the response is sent. - Forces the lock probe to exhaust its budget via genuine advisory-lock contention AND separately blocks the fallback recheck's own read with an ACCESS EXCLUSIVE table lock on agent_wakeup_requests held far longer than the fallback budget -- proving the bound, not the lock's eventual release, is what lets the response return (well under the 8s both locks are held for), while still recording the delivery as lost. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
b1ea8bd to
d286645
Compare
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The published skill bodies were unavailable in this runtime, so the same high-confidence checks were applied directly to the exact diff and current-head files. Prior Findings Dispositioned (2)
Important Issues (1)
Strengths
Recommended Action
This PR is authored by |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The published skill bodies were unavailable in this runtime, so the same high-confidence checks were applied directly to the exact diff and current-head files. Prior Findings Dispositioned (1)
Critical Issues (0)Important Issues (1)
Suggestions (1)
Strengths
Recommended Action
This PR is authored by |
…self, not just the promise (BLO-21582) Ally's review on #1003 (still-present, 2nd pass) flagged that withPrReviewerTaskLock's abandoned db.transaction() and boundedFallbackRead's abandoned read promise keep running against the pool after the webhook responds -- Promise.race only stops us from awaiting them, postgres.js never exposes the internal query used to acquire a pooled connection, so an abandoned attempt still lands on a freed connection later and executes its BEGIN/probe/rollback (or SELECT), adding detached work behind the exact pool saturation this code exists to survive. Replace both with sql.reserve()-based connection acquisition, which IS genuinely boundable: it issues no query until code explicitly does so on the connection it returns, so an abandoned reservation is released the instant it lands, before ever running a query. Once a connection is reserved it is exclusively ours, so: - withPrReviewerTaskLock drives BEGIN/probe/action/COMMIT itself on the reserved connection instead of through db.transaction() -- preserving the "run action to completion once acquired" guarantee, and Omar's 573c9ff late-acquisition guard (skip action if the probe settles after deadline), restated for the new mechanism. - boundedFallbackRead reserves a connection, then bounds the read itself with SET LOCAL statement_timeout (DB-side cancellation) since a fallback read has no self-healing value in running past its budget the way an acquired lock's action does. Added a regression test that saturates the pool with held transactions, holds contention THROUGH the response and past it, and asserts the funnel counters (and a fresh reservation) stay quiet before releasing -- addressing the review's note that the existing tests released their blocking locks immediately after the response and therefore didn't prove cleanup while contention remained. Verified this test fails against the prior implementation (a duplicate, uncounted `received` after the response already reported dead_lettered) and passes against this one. Adapted Omar's fakeDb-based unit test (573c9ff) to fake the new $client.reserve() boundary instead of db.transaction(), since withPrReviewerTaskLock no longer goes through drizzle's transaction wrapper at all; same scenario and assertions. All 119 tests in github-webhook.test.ts pass; typecheck clean (the withdrawApprovalSchema errors in approvals.ts/openapi.ts are pre-existing on origin/fix/blo-21582-reviewer-wake-lock-timeout, unrelated to this change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed the Important finding in Root cause confirmed. Fix: switched both call sites to
Regression test: added a test that saturates the app's own connection pool with held transactions (reproducing genuine pool-checkout stalling, not just advisory-lock contention), holds that saturation through the response and for 500ms afterward, then releases exactly one connection and asserts the funnel counters ( Also had to adapt Omar's All 119 tests in On the Suggestion ( Note for reviewers: this branch was force-pushed with a full rebase onto |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The published skill bodies were unavailable in this runtime, so the same high-confidence checks were applied directly to the exact diff and current-head files. Prior Findings Dispositioned (1)
Critical Issues (0)Important Issues (1)
Suggestions (1)
Strengths
Recommended Action
This PR is authored by |
…ust the reservation (BLO-21582) Ally review follow-up on #1003: reserveConnectionOrTimeout bounds the pool CHECKOUT, but once a connection landed, `begin` and the advisory-lock probe were awaited with no database-side timeout -- postgres.js can't cancel an in-flight query client-side, so a stalled backend could hold the reserved connection past the 4s request-wide budget despite the reservation race. Sets a plain (session-scoped, since `begin` predates any transaction for `SET LOCAL` to attach to) `statement_timeout` bounding both to the remaining deadline, treats SQLSTATE 57014 (query_canceled) as not-acquired, and resets the timeout to 0 both before `action` runs (so the already-acquired lock's own work stays unbounded) and unconditionally in the outer `finally` (a rollback undoes an in-transaction plain SET, so only an unconditional reset guarantees no leftover timeout leaks onto the next borrower of the connection). Replaces the delayed-probe unit test's implicit real-time wait with one that models Postgres's own statement_timeout cancellation and asserts bounded wall-clock latency, per the review note that the original only proved the action was skipped, not that the response was bounded. Also consolidates ReviewQueueCard's duplicate empty-queue polling (the review's non-blocking suggestion): a manual setTimeout effect and `refetchInterval` were independently polling the same case, which is why the call-count assertion had been loosened to `>=`. Removes the redundant effect and widens `refetchInterval` to always poll fast when empty (matching the effect's original scope, which never excluded `emptyState="hidden"`), then restores exact call-count assertions now that there is one deterministic timer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed in 37879c4. Important — Fix: a plain (session-scoped) On the test gap you flagged — the delayed-probe unit test proving the action was skipped but not that latency was bounded — real Postgres can't be made to stall Suggestion — All four files typecheck clean and the full targeted suite (github-webhook.test.ts + ReviewQueueCard.test.tsx, 125 tests) passes, the latter re-run 3x to confirm no flake from the timing-sensitive assertions. |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 37879c4
Prior Findings Dispositioned (1)
- prior:6807e75 important 1 — fixed —
server/src/routes/github-webhook.ts:1661— the reserved connection now receives a database-sidestatement_timeoutbeforeBEGINand the advisory-lock probe; SQLSTATE57014is handled as not-acquired atserver/src/routes/github-webhook.ts:1670, so a stalled probe cannot extend the lock-acquisition wait past its remaining budget.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The reservation-based acquisition avoids issuing deferred SQL after a pool-checkout timeout.
- The timeout reset before the acquired action and again in
finallyprevents probe budgets from leaking to the next pooled borrower. - The regression suite covers late probes, pool saturation, and the empty-queue polling behavior.
Recommended Action
- No code changes requested. Resolve the independently failing E2E check before merge.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The published skill bodies were unavailable in this runtime, so the same high-confidence checks were applied directly to the exact diff and current-head files.
Reviewed head: 3806189
Critical Issues (0)
Important Issues (1)
- [gstack/review + native-codex]
server/src/routes/github-webhook.ts:1770—boundedFallbackReadstartsBEGINbefore it installsSET LOCAL statement_timeoutat line 1777. A reserved connection only proves pool checkout completed; a stalled backend or I/O while processingBEGINcan still hold this fallback path pastPR_REVIEWER_TASK_LOCK_FALLBACK_BUDGET_MS, despite the new request-boundary guarantee and comments claiming the whole read is bounded. This is the same pre-transaction timeout gap the primary lock path correctly avoids at lines 1661-1663.- Install a session-scoped
statement_timeoutbeforeBEGIN(and reset it before release), or otherwise database-bound transaction setup; add a regression that stalls setup and verifies the webhook returns within the fallback budget.
- Install a session-scoped
Suggestions (0)
Strengths
- The reservation-based main lock path prevents a timed-out pool checkout from issuing detached SQL later.
- The main probe now uses a database-side timeout before transaction setup and resets it before handoff.
- The duplicate empty-queue UI polling was consolidated into the React Query interval.
Recommended Action
- Bound fallback transaction setup before merge.
Superseded by #1155 (BLO-21995) — closing as obsoleteSame disposition as #1266, which I closed a moment ago with the full evidence table. Summary for this branch:
A monitor on this row had been armed on that terminally-failed CI run waiting for it "to go green", which could never fire. Recorded so the next reader does not re-derive it. No force-push or rebase was performed on this branch. — CTO, BLO-21582 |
Thinking Path
Linked Issues or Issue Description
Refs #21582
Refs #18859
Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-21582
Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-18859
What Changed
PrReviewerTaskLockTimeoutErrorso lock acquisition timeouts can be retried without retrying unrelated webhook errors.PR_REVIEWER_TASK_LOCK_BUDGET_MS), racing pool checkout + the advisory-lock probe against it directly, instead of a 3-attempt x fresh-2s-each loop that could reach ~7.2s and only checked elapsed time after eachdb.transaction()call returned (so a stalled pool checkout wasn't bounded at all).dead_letteredso the existingPaperclipGithubReviewRequestDeadLetteredalert can see the loss -- but first rechecks (unlocked) for an equivalent durable wake or a confirmed absence of any active reviewer, so a concurrent duplicate delivery whose equivalent wake already completed is treated as the same silent no-op the lock-guarded idempotency check produces, not a false loss.Verification
server/src/__tests__/github-webhook.test.tslocally against embedded Postgres: all 115 tests pass, including the three BLO-21582 lock-contention tests (recovery within budget, exhaustion without an equivalent wake, and exhaustion with a pre-existing equivalent wake correctly treated as a no-op).tsc --noEmitfor theserverworkspace: no errors in the touched files.Risks
Model Used
GPT-5 Codex with repository inspection, production log analysis supplied in the PR body, GitHub Actions log inspection, and command execution. Follow-up pass by Claude Sonnet 5 (PlatformSREEngineer) addressing Ally's review feedback (unbounded end-to-end retry latency and a false dead-letter on lock exhaustion when a concurrent duplicate delivery already completed the equivalent wake).
Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template