fix(queue): fence stale outcome writes with attempts-generation (HT-43) - #44
Merged
Conversation
`drainOnce` leases a batch upfront, then processes rows sequentially. If a handler outlives its lease, a concurrent drainer can reclaim the row (bumping `attempts`) while the original worker's outcome write still matched only `WHERE id = $1` — so a stale ack could DELETE, or a stale retry reset `locked_until`/`run_after` on, a row another worker now owns. Bounded today to redundant processing (the sole handler, gmail-reconcile, is idempotent), but the queue is a general seam and should be fenced. Use the claimed row's post-increment `attempts` as an optimistic-concurrency generation: every outcome write (ack DELETE, retry UPDATE, deadLetterJob UPDATE) now carries `AND attempts = <claimed>` + `RETURNING id`. A 0-row write means the row was reclaimed — logged (`queue_stale_skip`), counted as the new `DrainReport.staleSkipped`, and NOT counted as ack/retry/deadLetter. This holds the never-drop invariant (charter §2) structurally, independent of handler idempotency. Complements the existing config-level defense (vercel.json caps maxDuration at 50s, below the 60s lease/cron interval) with an SQL-level fence that survives even if that config changes. Tested with a REAL cross-connection race (PGLiteSocketServer + two independent pg pools + short lease + barrier, mirroring src/db/postgres.test.ts) proving a stale worker cannot delete a reclaimed+rescheduled row (never-drop) or release the lease its new owner holds; both tests fail against the unfenced code. The existing 12 PGlite tests are behaviorally unchanged (the added clause is a no-op absent a reclaim) — only their exact-match report literals gain `staleSkipped: 0`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThe Postgres queue adds attempt-generation fencing to ACK, retry, and dead-letter writes, reports stale outcomes through ChangesPostgres queue stale-outcome fencing
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant WorkerA
participant PostgresQueue
participant WorkerB
WorkerA->>PostgresQueue: Claim job
WorkerB->>PostgresQueue: Reclaim expired job
WorkerA->>PostgresQueue: Submit fenced ACK or retry
PostgresQueue-->>WorkerA: Report stale skip
WorkerB->>PostgresQueue: Complete reclaimed job
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
zaridan
added a commit
that referenced
this pull request
Jul 16, 2026
… SQL-fenced refresh guard, rollback test Addresses the three open CodeRabbit findings on PR #46: - revokeToken no longer reads a non-2xx response body into the thrown error at all (the endpoint can echo the submitted refresh token back; a length cap bounds, it does not redact). Error is the status line only. Fixture added where the error body echoes the token, asserting neither the exception nor the disconnect failure logs contain it. - The refresh-vs-disconnect resurrection race is now closed at the SQL layer instead of narrowed in JS: new MailboxTokenStore.upsertTokensUnlessDisconnected runs the status check and token write as ONE guarded INSERT..SELECT..FOR UPDATE..ON CONFLICT statement (zero rows = fence held, nothing written), and the disconnect transaction now flips the mailbox status FIRST so both sides serialize on the mailboxes row lock — same fence discipline as the outbound queue's attempts-generation fence (PR #44). - markDisconnected's tx test now proves rollback PARTICIPATION: a forced transaction rollback takes the status write with it. specs/mail/gmail-connect.md §8 updated to match (§8b step 3 flip-first ordering, §8d race now closed rather than narrowed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zaridan
added a commit
that referenced
this pull request
Jul 16, 2026
… SQL-fenced refresh guard, rollback test Addresses the three open CodeRabbit findings on PR #46: - revokeToken no longer reads a non-2xx response body into the thrown error at all (the endpoint can echo the submitted refresh token back; a length cap bounds, it does not redact). Error is the status line only. Fixture added where the error body echoes the token, asserting neither the exception nor the disconnect failure logs contain it. - The refresh-vs-disconnect resurrection race is now closed at the SQL layer instead of narrowed in JS: new MailboxTokenStore.upsertTokensUnlessDisconnected runs the status check and token write as ONE guarded INSERT..SELECT..FOR UPDATE..ON CONFLICT statement (zero rows = fence held, nothing written), and the disconnect transaction now flips the mailbox status FIRST so both sides serialize on the mailboxes row lock — same fence discipline as the outbound queue's attempts-generation fence (PR #44). - markDisconnected's tx test now proves rollback PARTICIPATION: a forced transaction rollback takes the status write with it. specs/mail/gmail-connect.md §8 updated to match (§8b step 3 flip-first ordering, §8d race now closed rather than narrowed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
What & why
CodeRabbit flagged a lease-expiry race on the Postgres durable queue adapter (PR #43, HT-43).
drainOnceleases a whole batch upfront (claimBatch:UPDATE … SET locked_until = now()+lease, attempts = attempts+1 … FOR UPDATE SKIP LOCKED RETURNING *), then processes rows sequentially. If a handler run outlives its lease, a concurrent drainer (overlapping cron tick, a retry racing a slow run) can reclaim the row — bumpingattempts— while the original worker's outcome write still matched onlyWHERE id = $1. So a stale ack couldDELETE, or a stale retry resetlocked_until/run_afteron, a row another worker now owns.Bounded today to redundant processing (the only handler,
gmail-reconcile, is idempotent and advances its cursor only on terminal outcomes — so no mail is lost), but the queue is a general seam and should be fenced at the SQL level.The fix
Use the claimed row's post-increment
attemptsas an optimistic-concurrency generation. Every outcome write now carriesAND attempts = <the value this worker claimed>+RETURNING id:DELETE(index.ts~L427)UPDATE(~L477)deadLetterJobUPDATE(~L310) — now returns a boolean, checked at both the explicit-deadLetter and retry-past-ceiling call sitesA reclaim has bumped
attemptspast that value, so a stale write matches 0 rows and touches nothing. Zero affected rows is treated as "reclaimed, not mine": logged (queue_stale_skip, house-style structuredconsole.warn), counted as the newDrainReport.staleSkipped, and not counted as ack/retry/deadLetter. New partition invariant:claimed === acked + retried + deadLettered + staleSkipped.This is the SQL-level guarantee that complements the config-level defense already shipped in #43 (
vercel.jsoncapsmaxDurationat 50s, below the 60sDEFAULT_LEASE_MS/cron interval) — it holds even if that config changes.Never-drop invariant (charter §2) — verified, not asserted
pgconnections showed the original code losing a job: worker B reclaimed + rescheduled a row, worker A's stale ackDELETEd it → 0 rows left.index.race.test.tsproves the job survives (countRows === 1) under that exact race with the fence in place, and that a stale retry cannot release the lease its new owner holds (the real owner then completes its ack cleanly).Testing
New
index.race.test.tsuses the repo's blessed real-Postgres harness (PGLiteSocketServer+ two independentpgpools, mirroringsrc/db/postgres.test.ts) with a short lease + a deterministic barrier — genuine cross-connectionFOR UPDATE SKIP LOCKEDlease expiry that in-process PGlite can't exercise. No wall-clock sleeps (lease expiry is forced vialocked_until = now() - 1s, the same trickforceAllDueuses), so it's not flaky.tsc --noEmit: clean ·biome check .: clean (180 files)Reviewer note (one judgment call)
The ticket said the existing 12 PGlite tests must "pass unchanged." I added
staleSkippedtoDrainReport(for the clean partition invariant + cron-log observability of the fence firing), which required extending their 11 exact-match report literals withstaleSkipped: 0. I read "unchanged" as the fence must not alter their outcomes — verified: with the fence in, the only failures were the added key, proving theAND attempts=clause is a no-op absent a reclaim. If you'd prefer that file zero-touch, dropping the field for log-only is a small revert.🤖 Generated with Claude Code
Summary by CodeRabbit