fix(server): claim lifecycle with TTL for webhook event dedup - #1999
Draft
serenakeyitan wants to merge 1 commit into
Draft
fix(server): claim lifecycle with TTL for webhook event dedup#1999serenakeyitan wants to merge 1 commit into
serenakeyitan wants to merge 1 commit into
Conversation
A webhook delivery was claimed by inserting into processed_events before
any side effects ran, with only a best-effort delete on failure. A crash
between the claim commit and completion left the row in place, so every
redelivery of that event was deduped forever and the event was lost.
Claims now carry a status ('pending'/'done') and an expiry:
- claimEvent inserts a 'pending' claim with a 5-minute TTL, or atomically
takes over an expired 'pending' claim via INSERT ... ON CONFLICT DO
UPDATE; 'done' rows and unexpired in-flight 'pending' rows still dedupe.
- processScmWebhookDelivery marks the claim 'done' on success (covers the
GitHub App and GitLab webhook paths through the shared seam); the
failure path keeps the best-effort unclaim, but correctness no longer
depends on it.
- background tasks sweep expired 'pending' claims every 60s to keep the
table bounded; redelivery correctness does not depend on the sweep.
- existing rows are backfilled to 'done' by the column default so they
keep deduping after the migration.
Closes #317
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <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.
Closes #317
GoF competition entry (Fable). Implements the issue-approved Fix shape 2: claim with status + TTL.
Problem
processScmWebhookDeliveryclaimed a delivery by inserting intoprocessed_eventsbefore side effects ran, with only a best-effortunclaimEventon failure. A crash between the claim commit and completion (process kill, OOM, deploy restart, DB connection drop mid-handler) left the claim row behind, so every provider redelivery was answered withdeduped— the event was lost forever.Solution
processed_eventsrows become claims with a lifecycle:status text NOT NULL DEFAULT 'done'+expires_at timestamptz+(status, expires_at)index. Existing rows backfill todonevia the column default, so they keep deduping.claimEventINSERT ... ON CONFLICT (event_id, platform) DO UPDATE ... WHERE status = 'pending' AND expires_at <= now(): inserts apendingclaim with a 5-minute TTL, or takes over an expiredpendingclaim (crashed processor).donerows and unexpired in-flightpendingrows still dedupe. The conflicting row is locked during the upsert, so concurrent deliveries of the same id serialize and exactly one wins.completeEvent(new)pending→done(and clearsexpires_at) on the success path of the shared seamprocessScmWebhookDelivery— this covers both webhook paths, GitHub App (/webhooks/github-app) and GitLab, since both route through the seam. (The per-org/webhooks/github/:orgIdroute mentioned in the issue no longer exists onmain; the SaaS-wide GitHub App endpoint replaced it.)unclaimEventstatus = 'pending'so it can never delete adonerecord. Correctness no longer depends on it.background-tasks.tsdeletes expiredpendingclaims every 60s. Redelivery correctness does not depend on the sweep either (claimEventtakes expired claims over in place); it just keeps the table bounded.Migration note (disclosed deviation)
processed_eventspredates drizzle-kit management: it was created by the hand-written0003_feishu_adapter.sqland is intentionally absent from the drizzle snapshot /schema/index.tsexports (same as theadapter_*tables). A regulardrizzle-kit generatetherefore reports "no schema changes" and cannot emit ALTERs for it — and exporting the table would make the nextgenerateemit a conflictingCREATE TABLE. So migration0084was scaffolded with drizzle's sanctioned escape hatchdrizzle-kit generate --custom --name=processed_events_claim_lifecycle(journal + snapshot managed by drizzle-kit,LATESTsynced viasync-migration-head.mjs,check-migrations.mjspasses) and the SQL body filled with idempotent ALTERs. The declarative shape insrc/db/schema/processed-events.tsis kept in sync as documentation, with a NOTE explaining the situation.Acceptance checklist (from the GoF scope comment)
done; correctness does not depend onreleaseClaimOnErrorrunninggithub-app-webhook-route.test.ts+ seam-level test inscm-webhook-processing.test.ts)donestill dedupes permanently (incl. attempts with TTL 0)pendingrows only, covered by unit tests (service + timer wiring)Verification
pnpm check— clean (remaining warnings pre-exist on untouched files)pnpm typecheck— 10/10 tasks passpnpm build— 5/5 tasks passpnpm --filter @first-tree/server test— 244 files / 2659 tests pass against real Postgres (includes 11 event-dedup lifecycle/race/sweep tests, 4 new seam tests incl. crash injection + concurrency, background-tasks sweep tests, and a route-level redelivery-after-crash test)🤖 Generated with Claude Code