Problem
The webhook retry sweep is not safe across API replicas, so subscribers receive duplicate deliveries whenever more than one API instance runs. WebhooksService.sweepRetries() (api/src/webhooks/webhooks.service.ts) is a @Interval-scheduled method that runs in every API process (the k8s Deployment k8s/20-api.yaml and the README's horizontal-scaling story assume replicas). Each sweep calls:
const due = await this.deliveries.findDuePending(RETRY_SWEEP_BATCH_SIZE)
findDuePending (api/src/webhooks/repository/webhook-deliveries-db.repository.ts) selects rows with status = 'pending' AND next_attempt_at <= CURRENT_TIMESTAMP with no locking and no claim state. Two replicas sweeping in the same minute both select the same due deliveries and both POST to the subscriber — attemptDelivery records each attempt independently, so webhook_deliveries.attempt_count increments twice as fast as intended and the subscriber receives the same webhook body twice (or more, with N replicas). The same race exists in the fire-and-forget path: dispatchStreamEvent creates a delivery row and calls attemptDelivery in every replica that handles the triggering status transition, so a single PATCH /streams/:id fan-out can be delivered once per replica.
Consequence: webhook subscribers (the integration point the platform sells, issue #392) cannot rely on at-most-once semantics; retry backoff ("up to 5 retries over 24 hours") collapses under replica count, and a replica restart mid-sweep can re-deliver a batch that another replica already delivered. With no idempotency key on the wire (X-Webhook-Signature proves authenticity, not uniqueness), subscribers must dedupe themselves or receive duplicates.
Root cause
// api/src/webhooks/repository/webhook-deliveries-db.repository.ts — findDuePending()
SELECT ... FROM webhook_deliveries
WHERE status = 'pending' AND next_attempt_at <= CURRENT_TIMESTAMP
ORDER BY created_at ASC
LIMIT $1 // ← no FOR UPDATE, no claim, no per-row atomicity
// api/src/webhooks/webhooks.service.ts — sweepRetries()
const due = await this.deliveries.findDuePending(RETRY_SWEEP_BATCH_SIZE)
for (const delivery of due) { ... await this.attemptDelivery(subscription, delivery) } // ← every replica does this
Why this is architecturally hard
- The fix requires an atomic claim, not a bigger batch: each due delivery must be claimed by exactly one replica before the POST. The Postgres-native pattern is
UPDATE webhook_deliveries SET status = 'claiming' (or next_attempt_at = NOW() + lease) WHERE id = ANY($1) AND status = 'pending' RETURNING id — or SELECT ... FOR UPDATE SKIP LOCKED inside a transaction — and the design must decide whether a crashed replica's in-flight delivery is recoverable (a lease timestamp that another sweep can take over) or risk being stuck forever.
- The
status column is constrained by webhook_deliveries_status_check to ('pending', 'success', 'failed') (database/schema.sql), so a new intermediate state (e.g. delivering) requires a migration to widen the check. Alternatively the lease can live in next_attempt_at without a new status — a design choice with schema impact either way.
dispatchStreamEvent's fire-and-forget path races too: deliveries.create() then attemptDelivery() per replica. The claim must apply at creation time (or the fan-out must be made idempotent per (subscription, event, payload)), which changes the meaning of a delivery row: one row per event per subscription, claimed once, versus one row per replica attempt.
- Any claim mechanism must survive
recordAttempt's update (which bumps attempt_count and rewrites next_attempt_at) without deadlocking two replicas on the same row — the existing UPDATE is unguarded and would happily overwrite a claim made by another replica.
Acceptance criteria
Behaviour
Tests
Documentation
Out of scope
An idempotency key for subscribers (wire change, separate decision), and delivery retry policy changes (the 1min/5min/30min/3h/20h schedule stays).
Getting started
Real files in scope: api/src/webhooks/webhooks.service.ts (sweepRetries, dispatchStreamEvent, attemptDelivery), api/src/webhooks/repository/webhook-deliveries-db.repository.ts (findDuePending, recordAttempt, create), database/schema.sql (webhook_deliveries status check), database/migrations/ (new migration if a status widens), api/src/webhooks/webhooks.service.spec.ts.
Verify with:
cd api && npm run typecheck && npm test
Good first files to read: api/src/webhooks/webhooks.service.ts (sweepRetries), api/src/webhooks/repository/webhook-deliveries-db.repository.ts (findDuePending), database/schema.sql (the webhook_deliveries_status_check constraint).
Problem
The webhook retry sweep is not safe across API replicas, so subscribers receive duplicate deliveries whenever more than one API instance runs.
WebhooksService.sweepRetries()(api/src/webhooks/webhooks.service.ts) is a@Interval-scheduled method that runs in every API process (the k8s Deploymentk8s/20-api.yamland the README's horizontal-scaling story assume replicas). Each sweep calls:findDuePending(api/src/webhooks/repository/webhook-deliveries-db.repository.ts) selects rows withstatus = 'pending' AND next_attempt_at <= CURRENT_TIMESTAMPwith no locking and no claim state. Two replicas sweeping in the same minute both select the same due deliveries and both POST to the subscriber —attemptDeliveryrecords each attempt independently, sowebhook_deliveries.attempt_countincrements twice as fast as intended and the subscriber receives the same webhook body twice (or more, with N replicas). The same race exists in the fire-and-forget path:dispatchStreamEventcreates a delivery row and callsattemptDeliveryin every replica that handles the triggering status transition, so a singlePATCH /streams/:idfan-out can be delivered once per replica.Consequence: webhook subscribers (the integration point the platform sells, issue #392) cannot rely on at-most-once semantics; retry backoff ("up to 5 retries over 24 hours") collapses under replica count, and a replica restart mid-sweep can re-deliver a batch that another replica already delivered. With no idempotency key on the wire (
X-Webhook-Signatureproves authenticity, not uniqueness), subscribers must dedupe themselves or receive duplicates.Root cause
Why this is architecturally hard
UPDATE webhook_deliveries SET status = 'claiming' (or next_attempt_at = NOW() + lease) WHERE id = ANY($1) AND status = 'pending' RETURNING id— orSELECT ... FOR UPDATE SKIP LOCKEDinside a transaction — and the design must decide whether a crashed replica's in-flight delivery is recoverable (a lease timestamp that another sweep can take over) or risk being stuck forever.statuscolumn is constrained bywebhook_deliveries_status_checkto('pending', 'success', 'failed')(database/schema.sql), so a new intermediate state (e.g.delivering) requires a migration to widen the check. Alternatively the lease can live innext_attempt_atwithout a new status — a design choice with schema impact either way.dispatchStreamEvent's fire-and-forget path races too:deliveries.create()thenattemptDelivery()per replica. The claim must apply at creation time (or the fan-out must be made idempotent per(subscription, event, payload)), which changes the meaning of a delivery row: one row per event per subscription, claimed once, versus one row per replica attempt.recordAttempt's update (which bumpsattempt_countand rewritesnext_attempt_at) without deadlocking two replicas on the same row — the existing UPDATE is unguarded and would happily overwrite a claim made by another replica.Acceptance criteria
Behaviour
PATCH /streams/:idstatus transition with two replicas results in exactly one delivery row and one POST per matching subscription.pendingstuck state).Tests
attempt_countincrements by one.api/src/webhooks/webhooks.service.spec.ts) pass with the new claim semantics.Documentation
WebhooksServiceJSDoc and the webhook README/docs so subscribers know what to expect.Out of scope
An idempotency key for subscribers (wire change, separate decision), and delivery retry policy changes (the 1min/5min/30min/3h/20h schedule stays).
Getting started
Real files in scope:
api/src/webhooks/webhooks.service.ts(sweepRetries,dispatchStreamEvent,attemptDelivery),api/src/webhooks/repository/webhook-deliveries-db.repository.ts(findDuePending,recordAttempt,create),database/schema.sql(webhook_deliveriesstatus check),database/migrations/(new migration if a status widens),api/src/webhooks/webhooks.service.spec.ts.Verify with:
Good first files to read:
api/src/webhooks/webhooks.service.ts(sweepRetries),api/src/webhooks/repository/webhook-deliveries-db.repository.ts(findDuePending),database/schema.sql(thewebhook_deliveries_status_checkconstraint).