Skip to content

Commit a4a9e94

Browse files
Merge pull request #1165 from arisu6804/feat/issue-1142-webhook-delivery
[#1142] Make webhook delivery durable and replay-safe
2 parents cc8203c + 564334f commit a4a9e94

4 files changed

Lines changed: 501 additions & 0 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Durable webhook delivery
2+
3+
Webhook delivery is at-least-once at the provider boundary, so process memory
4+
cannot be the source of truth. `InMemoryDurableDeliveryStore` documents and
5+
tests the production contract: persist one tenant-scoped event key, payload
6+
fingerprint, delivery state, attempt count, lease, next-attempt time, and
7+
terminal error metadata in durable storage.
8+
9+
The state machine is:
10+
11+
`pending -> processing -> delivered`
12+
13+
`processing -> retrying -> processing` (bounded exponential backoff)
14+
15+
`processing -> dead` (attempt budget exhausted)
16+
17+
Only the worker holding the current lease may renew, complete, or fail a
18+
delivery. An expired lease is reclaimable by another worker after restart.
19+
Creating an existing key with the same body is a duplicate and returns the
20+
original record; changing the body is a 409 conflict and must not be sent.
21+
22+
Production persistence must enforce a unique `(tenant_id, event_key)` index and
23+
perform claims atomically (`SELECT ... FOR UPDATE SKIP LOCKED` or an equivalent
24+
conditional update). The in-memory adapter is intentionally not a production
25+
database. Store the payload needed for delivery, but never store webhook
26+
secrets in the delivery row or copy them into error messages.
27+
28+
Workers should acknowledge the row only after the provider returns success.
29+
They must retain the event key as the provider idempotency header so a crash
30+
after the provider accepts a request but before `complete` does not create an
31+
unbounded duplicate effect. Dead rows must be visible to operators, with a
32+
manual replay path that creates a new event key after the cause is corrected.
33+
34+
Validation coverage includes duplicate and conflict admission, tenant
35+
isolation, lease ownership and expiry, restart recovery, retry timing, dead
36+
lettering, malformed JSON, URL sanitization, and record immutability.
37+
38+
## Rollout checklist
39+
40+
1. Create the delivery table and unique tenant/event index before deploying the
41+
worker code.
42+
2. Backfill only known pending work; never synthesize a successful row for an
43+
delivery whose provider response is unknown.
44+
3. Run one worker in shadow mode and compare claim counts with existing
45+
dispatch logs.
46+
4. Enable atomic claims and lease renewal for a canary tenant.
47+
5. Confirm retries do not occur before `next_attempt_at`.
48+
6. Confirm a restart reclaims an expired processing row.
49+
7. Confirm dead rows are visible without exposing destination secrets.
50+
8. Enable alerts for dead count, lease expiry, and retry age.
51+
52+
## Failure handling
53+
54+
An HTTP timeout means the provider outcome is unknown. The worker should leave
55+
the durable row retryable and send the same event key on the next attempt. A
56+
4xx response caused by a malformed payload should be classified as terminal
57+
after the configured policy, not retried indefinitely. A 5xx response or
58+
network failure is retryable subject to the attempt budget. Provider success
59+
must be followed by `complete`; if the process dies before that write, the
60+
provider idempotency key protects the next attempt.
61+
62+
Do not use destination URL, payload content, or tenant id as a metric label.
63+
Those fields can have high cardinality or contain sensitive information.
64+
Hashing the body for integrity checks is safe to expose only as an internal
65+
record field; log the event key and status, not the raw body.
66+
67+
The durable state is authoritative during shutdown. Stop accepting new
68+
dispatches, let in-flight requests finish, and leave unclaimed pending or
69+
retrying rows intact. A later worker will resume them. Never clear the table
70+
as part of a restart or deployment hook.
71+
72+
## Compatibility
73+
74+
Existing callers can continue to enqueue through the dispatcher while they
75+
are migrated to the durable adapter. The adapter's event key should be the
76+
existing `X-Callora-Delivery` value when one is available, so retries retain
77+
their provider-visible identity. New callers must create the durable row before
78+
starting network delivery. This ordering makes a crash before `fetch` safe and
79+
keeps recovery independent of process memory.
80+
81+
Database migrations must be backward-compatible with old workers: add columns
82+
and indexes first, deploy readers second, and remove legacy cleanup only after
83+
all workers report the new lifecycle metrics. Rollback leaves rows untouched.
84+
85+
The state machine is intentionally monotonic after delivery: a delivered row
86+
cannot return to pending, retrying, or processing. Operators must create a new
87+
event key for a deliberate replay and record the reason for that action.
88+
89+
Review the claim query and transition update together: the worker id and lease
90+
must be checked in the same conditional statement. A read followed by an
91+
unconditional update reintroduces the race this state machine is designed to
92+
remove.
93+
94+
Monitoring guidance:
95+
96+
- `pending` measures newly accepted work;
97+
- `processing` measures active leases;
98+
- `retrying` measures delayed recoverable work;
99+
- `delivered` measures completed provider responses;
100+
- `dead` measures work requiring operator action.
101+
102+
Alert on a growing processing population, not only on dead rows. A stuck
103+
worker can keep rows processing until lease expiry, delaying customer-visible
104+
delivery without increasing the terminal counter. The combination of state,
105+
attempt count, next-attempt time, and age is sufficient to diagnose that case
106+
without logging the request body.
107+
108+
Keep provider response codes in a separate redacted operational log and use
109+
the durable row for the retry decision.
110+
111+
This preserves a reviewable audit trail across process restarts and worker
112+
replacement.
113+
114+
Operators can safely inspect this state without opening the request payload.
115+
116+
This is the durable source of truth for delivery recovery.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { DeliveryConflictError, InMemoryDurableDeliveryStore, enqueueDurableDelivery } from './durableDelivery.js'
3+
4+
const makeInput = (n: number, overrides: Record<string, unknown> = {}) => ({
5+
tenantId: `tenant-${n % 3}`, eventKey: `event-${n}`, destination: `https://hooks.example.test/${n}`,
6+
body: JSON.stringify({ event: 'invoice.paid', id: n, amount: n * 10 }), maxAttempts: 3, baseDelayMs: 10, ...overrides,
7+
})
8+
9+
describe('durable delivery state transition matrix', () => {
10+
it.each(Array.from({ length: 15 }, (_, index) => index + 1))('keeps event %i pending until claimed', async n => {
11+
const store = new InMemoryDurableDeliveryStore()
12+
const record = await enqueueDurableDelivery(store, makeInput(n), 100)
13+
expect(record.status).toBe('pending')
14+
expect((await store.stats()).pending).toBe(1)
15+
})
16+
17+
it('prevents two workers from claiming a pending row concurrently', async () => {
18+
const store = new InMemoryDurableDeliveryStore()
19+
await enqueueDurableDelivery(store, makeInput(20), 0)
20+
const claims = await Promise.all([
21+
store.claim('tenant-2', 'event-20', 'worker-a', 0),
22+
store.claim('tenant-2', 'event-20', 'worker-b', 0),
23+
])
24+
expect(claims.filter(Boolean)).toHaveLength(1)
25+
})
26+
27+
it('does not let a stale worker acknowledge a recovered lease', async () => {
28+
const store = new InMemoryDurableDeliveryStore()
29+
await enqueueDurableDelivery(store, makeInput(21), 0)
30+
const first = await store.claim('tenant-0', 'event-21', 'worker-a', 0, 5)
31+
const second = await store.claim('tenant-0', 'event-21', 'worker-b', 5, 5)
32+
expect(await store.complete(first!, 'worker-a', 6)).toBe(false)
33+
expect(await store.complete(second!, 'worker-b', 6)).toBe(true)
34+
})
35+
36+
it.each([
37+
[1, 10], [2, 20], [3, 40], [4, 80], [5, 160],
38+
])('uses bounded exponential delay after attempt %i', async (attempt, delay) => {
39+
const store = new InMemoryDurableDeliveryStore()
40+
await enqueueDurableDelivery(store, makeInput(22, { maxAttempts: 10, baseDelayMs: 10 }), 0)
41+
let claim = await store.claim('tenant-1', 'event-22', 'worker-0', 0)
42+
for (let index = 1; index < attempt; index++) {
43+
await store.fail(claim!, `failure-${index}`, 'x', claim!.nextAttemptAt)
44+
claim = await store.claim('tenant-1', 'event-22', `worker-${index}`, claim!.nextAttemptAt)
45+
}
46+
const failed = await store.fail(claim!, 'x', 1000)
47+
expect(failed?.nextAttemptAt).toBe(1000 + delay)
48+
})
49+
50+
it('does not claim retryable work before nextAttemptAt', async () => {
51+
const store = new InMemoryDurableDeliveryStore()
52+
await enqueueDurableDelivery(store, makeInput(23), 0)
53+
const claim = await store.claim('tenant-2', 'event-23', 'worker-a', 0)
54+
const failed = await store.fail(claim!, 'worker-a', 'timeout', 1)
55+
expect(await store.claim('tenant-2', 'event-23', 'worker-b', failed!.nextAttemptAt - 1)).toBeUndefined()
56+
})
57+
58+
it('does not permit completion after the lease expires', async () => {
59+
const store = new InMemoryDurableDeliveryStore()
60+
await enqueueDurableDelivery(store, makeInput(24), 0)
61+
const claim = await store.claim('tenant-0', 'event-24', 'worker-a', 0, 10)
62+
expect(await store.complete(claim!, 'worker-a', 11)).toBe(false)
63+
})
64+
65+
it('keeps delivered rows out of all future states', async () => {
66+
const store = new InMemoryDurableDeliveryStore()
67+
await enqueueDurableDelivery(store, makeInput(25), 0)
68+
const claim = await store.claim('tenant-1', 'event-25', 'worker-a', 0)
69+
await store.complete(claim!, 'worker-a', 1)
70+
expect(await store.claim('tenant-1', 'event-25', 'worker-b', 2)).toBeUndefined()
71+
expect(await store.fail(claim!, 'worker-a', 'late', 3)).toBeUndefined()
72+
})
73+
74+
it('makes only one successful record for duplicate enqueue races', async () => {
75+
const store = new InMemoryDurableDeliveryStore()
76+
const records = await Promise.all(Array.from({ length: 30 }, () => enqueueDurableDelivery(store, makeInput(26), 0)))
77+
expect(records).toHaveLength(30)
78+
expect((await store.stats()).pending).toBe(1)
79+
})
80+
81+
it('rejects changed payload in duplicate enqueue races', async () => {
82+
const store = new InMemoryDurableDeliveryStore()
83+
await enqueueDurableDelivery(store, makeInput(27), 0)
84+
await expect(enqueueDurableDelivery(store, makeInput(27, { body: '{"amount":999}' }), 0)).rejects.toBeInstanceOf(DeliveryConflictError)
85+
})
86+
87+
it('reports each lifecycle state in stats', async () => {
88+
const store = new InMemoryDurableDeliveryStore()
89+
await enqueueDurableDelivery(store, makeInput(28), 0)
90+
await enqueueDurableDelivery(store, makeInput(29), 0)
91+
const processing = await store.claim('tenant-1', 'event-28', 'worker-a', 0)
92+
const dead = await store.claim('tenant-2', 'event-29', 'worker-b', 0)
93+
await store.fail(dead!, 'worker-b', 'fatal', 0)
94+
await store.complete(processing!, 'worker-a', 1)
95+
expect(await store.stats()).toEqual({ pending: 0, processing: 0, retrying: 1, delivered: 1, dead: 0 })
96+
})
97+
98+
it('requires a claimant for state transitions', async () => {
99+
const store = new InMemoryDurableDeliveryStore()
100+
await enqueueDurableDelivery(store, makeInput(30), 0)
101+
const claim = await store.claim('tenant-0', 'event-30', 'worker-a', 0)
102+
expect(await store.renew(claim!, 'other', 1)).toBe(false)
103+
expect(await store.fail(claim!, 'other', 'timeout', 1)).toBeUndefined()
104+
expect((await store.get('tenant-0', 'event-30'))?.status).toBe('processing')
105+
})
106+
107+
it('retains retry error context without retaining network URLs', async () => {
108+
const store = new InMemoryDurableDeliveryStore()
109+
await enqueueDurableDelivery(store, makeInput(31), 0)
110+
const claim = await store.claim('tenant-1', 'event-31', 'worker-a', 0)
111+
const failed = await store.fail(claim!, 'worker-a', 'POST https://internal/token timed out', 1)
112+
expect(failed?.lastError).toBe('POST [url] timed out')
113+
})
114+
115+
it('supports a fresh event after a previous event reaches dead state', async () => {
116+
const store = new InMemoryDurableDeliveryStore()
117+
await enqueueDurableDelivery(store, makeInput(32, { maxAttempts: 1 }), 0)
118+
const claim = await store.claim('tenant-2', 'event-32', 'worker-a', 0)
119+
await store.fail(claim!, 'worker-a', 'fatal', 1)
120+
const fresh = await enqueueDurableDelivery(store, makeInput(33), 1)
121+
expect(fresh.status).toBe('pending')
122+
expect((await store.stats()).dead).toBe(1)
123+
})
124+
})
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { DeliveryConflictError, InMemoryDurableDeliveryStore, enqueueDurableDelivery } from './durableDelivery.js'
3+
4+
const input = (overrides: Record<string, unknown> = {}) => ({ tenantId: 'tenant-a', eventKey: 'event-1', destination: 'https://example.test/hook', body: JSON.stringify({ event: 'invoice.paid', amount: 100 }), ...overrides })
5+
6+
describe('durable webhook delivery', () => {
7+
it('creates a pending delivery', async () => {
8+
const store = new InMemoryDurableDeliveryStore()
9+
const record = await enqueueDurableDelivery(store, input(), 1_000)
10+
expect(record.status).toBe('pending')
11+
expect(record.attemptCount).toBe(0)
12+
expect(record.nextAttemptAt).toBe(1_000)
13+
})
14+
15+
it('deduplicates an identical event key', async () => {
16+
const store = new InMemoryDurableDeliveryStore()
17+
const first = await enqueueDurableDelivery(store, input(), 1_000)
18+
const second = await enqueueDurableDelivery(store, input(), 2_000)
19+
expect(second.payloadHash).toBe(first.payloadHash)
20+
expect((await store.stats()).pending).toBe(1)
21+
})
22+
23+
it('rejects a reused key with a changed payload', async () => {
24+
const store = new InMemoryDurableDeliveryStore()
25+
await enqueueDurableDelivery(store, input(), 1_000)
26+
await expect(enqueueDurableDelivery(store, input({ body: JSON.stringify({ changed: true }) }), 2_000)).rejects.toBeInstanceOf(DeliveryConflictError)
27+
})
28+
29+
it('claims a pending event once for one worker', async () => {
30+
const store = new InMemoryDurableDeliveryStore()
31+
await enqueueDurableDelivery(store, input(), 1_000)
32+
const a = await store.claim('tenant-a', 'event-1', 'worker-a', 1_000, 100)
33+
const b = await store.claim('tenant-a', 'event-1', 'worker-b', 1_001, 100)
34+
expect(a?.workerId).toBe('worker-a')
35+
expect(b).toBeUndefined()
36+
})
37+
38+
it('allows another worker after lease expiry', async () => {
39+
const store = new InMemoryDurableDeliveryStore()
40+
await enqueueDurableDelivery(store, input(), 1_000)
41+
await store.claim('tenant-a', 'event-1', 'worker-a', 1_000, 100)
42+
const recovered = await store.claim('tenant-a', 'event-1', 'worker-b', 1_100, 100)
43+
expect(recovered?.workerId).toBe('worker-b')
44+
expect(recovered?.attemptCount).toBe(2)
45+
})
46+
47+
it('renews only the current worker lease', async () => {
48+
const store = new InMemoryDurableDeliveryStore()
49+
await enqueueDurableDelivery(store, input(), 1_000)
50+
const claim = await store.claim('tenant-a', 'event-1', 'worker-a', 1_000, 100)
51+
expect(await store.renew(claim!, 'worker-b', 1_050, 100)).toBe(false)
52+
expect(await store.renew(claim!, 'worker-a', 1_050, 100)).toBe(true)
53+
})
54+
55+
it('marks a delivery delivered only for its claimant', async () => {
56+
const store = new InMemoryDurableDeliveryStore()
57+
await enqueueDurableDelivery(store, input(), 1_000)
58+
const claim = await store.claim('tenant-a', 'event-1', 'worker-a', 1_000, 100)
59+
expect(await store.complete(claim!, 'worker-b', 1_050)).toBe(false)
60+
expect(await store.complete(claim!, 'worker-a', 1_050)).toBe(true)
61+
expect((await store.stats()).delivered).toBe(1)
62+
expect(await store.claim('tenant-a', 'event-1', 'worker-c', 2_000)).toBeUndefined()
63+
})
64+
65+
it('moves failures to retrying with exponential delay', async () => {
66+
const store = new InMemoryDurableDeliveryStore()
67+
await enqueueDurableDelivery(store, input({ baseDelayMs: 100 }), 1_000)
68+
const claim = await store.claim('tenant-a', 'event-1', 'worker-a', 1_000)
69+
const failed = await store.fail(claim!, 'worker-a', 'timeout', 1_010)
70+
expect(failed?.status).toBe('retrying')
71+
expect(failed?.nextAttemptAt).toBe(1_110)
72+
expect(await store.claim('tenant-a', 'event-1', 'worker-b', 1_109)).toBeUndefined()
73+
expect(await store.claim('tenant-a', 'event-1', 'worker-b', 1_110)).toBeDefined()
74+
})
75+
76+
it('moves a delivery to dead after the configured attempt budget', async () => {
77+
const store = new InMemoryDurableDeliveryStore()
78+
await enqueueDurableDelivery(store, input({ maxAttempts: 2, baseDelayMs: 1 }), 1_000)
79+
const first = await store.claim('tenant-a', 'event-1', 'worker-a', 1_000)
80+
await store.fail(first!, 'worker-a', 'bad gateway', 1_001)
81+
const second = await store.claim('tenant-a', 'event-1', 'worker-b', 1_003)
82+
const dead = await store.fail(second!, 'worker-b', 'bad gateway again', 1_004)
83+
expect(dead?.status).toBe('dead')
84+
expect((await store.stats()).dead).toBe(1)
85+
})
86+
87+
it('keeps tenant namespaces independent', async () => {
88+
const store = new InMemoryDurableDeliveryStore()
89+
await enqueueDurableDelivery(store, input(), 1_000)
90+
await enqueueDurableDelivery(store, input({ tenantId: 'tenant-b' }), 1_000)
91+
expect((await store.stats()).pending).toBe(2)
92+
})
93+
94+
it('rejects malformed payloads and invalid retry settings', async () => {
95+
const store = new InMemoryDurableDeliveryStore()
96+
await expect(enqueueDurableDelivery(store, input({ body: '{' }))).rejects.toThrow('valid JSON')
97+
await expect(enqueueDurableDelivery(store, input({ maxAttempts: 0 }))).rejects.toThrow('maxAttempts')
98+
await expect(enqueueDurableDelivery(store, input({ baseDelayMs: 0 }))).rejects.toThrow('baseDelayMs')
99+
})
100+
101+
it('sanitizes URLs in terminal error metadata', async () => {
102+
const store = new InMemoryDurableDeliveryStore()
103+
await enqueueDurableDelivery(store, input({ maxAttempts: 1 }), 1_000)
104+
const claim = await store.claim('tenant-a', 'event-1', 'worker-a', 1_000)
105+
const dead = await store.fail(claim!, 'worker-a', 'failed https://secret.example/token', 1_001)
106+
expect(dead?.lastError).toBe('failed [url]')
107+
})
108+
109+
it('does not expose mutable internal records', async () => {
110+
const store = new InMemoryDurableDeliveryStore()
111+
const created = await enqueueDurableDelivery(store, input(), 1_000)
112+
created.status = 'delivered'
113+
expect((await store.get('tenant-a', 'event-1'))?.status).toBe('pending')
114+
})
115+
})

0 commit comments

Comments
 (0)