Skip to content

Commit 891e5fc

Browse files
authored
Merge pull request #675 from tako0614/feature/TASK-0084-delivery-durable-recovery
fix(delivery): persist bounded reconciliation and recovery intent
2 parents 57dca9e + 621ceb0 commit 891e5fc

15 files changed

Lines changed: 1224 additions & 63 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Durable endpoint reconciliation
2+
3+
`delivery_queue` owns endpoint retry state. Queue messages are at-least-once
4+
wakeups; their optional `reconcileAttempt` cannot grant or reset a retry budget.
5+
New endpoint materialization starts at generation zero. A delivery series has
6+
the existing eight-attempt budget. After retry exhaustion, one conditional
7+
update records `dead_letter`, its error, a six-hour `next_attempt_at` due time,
8+
and `dlq_pending = 1` when its generation is below five. Generation five is
9+
terminal: no additional reconciliation intent is created.
10+
11+
The structured DLQ notification remains useful for diagnostics and a delayed
12+
reconcile wakeup. Failed publication can be retried by an incoming endpoint
13+
wakeup without another HTTP POST. Its successful send does not consume the
14+
repair intent. In particular, the local in-memory Queue can accept a message
15+
and lose it on process restart. Independently, cron/local-startup endpoint
16+
sweeping scans up to 50 due repair intents and republishes their exact successor
17+
to MAIN. This scan is separate from the up-to-500 active endpoint scan. Sweeping
18+
retains the intent even when the send succeeds or throws; if MAIN automatically
19+
dead-letters or loses that wakeup, a later sweep can recover it again.
20+
21+
The reconcile worker consumes the intent with one conditional update requiring
22+
`dead_letter`, pending intent, the exact predecessor generation, and the due time
23+
having elapsed. It increments once, resets attempts and claim ownership, clears
24+
the intent, and marks the row pending. Duplicate, older, future, permanently
25+
failed, delivered or deleted jobs cannot reset attempts or resurrect a row. An
26+
early valid wakeup is deferred; invalid durable timestamps require operator
27+
repair. A failed or lost post-transition send leaves a pending row that the
28+
ordinary sweep recovers at the persisted generation. Terminal retention excludes
29+
unconsumed intents in both its selection and conditional delete.
30+
31+
Delivery admission reads the persisted generation and conditionally claims the
32+
same generation, endpoint, Activity and due job with a new random processing
33+
token. Every post-claim state write checks the token, generation and processing
34+
status, so an older claimant cannot overwrite a replacement claim. This does
35+
not promise exactly-once remote delivery: an HTTP acceptance followed by failed
36+
bookkeeping or a crashed worker can still leave ambiguous remote acceptance.
37+
Activity IDs remain the remote deduplication identity. HTTP success is ACKed
38+
without an immediate repeat POST even when bookkeeping throws.
39+
40+
## Migration and recovery
41+
42+
Apply migration `0033_delivery_reconciliation.sql` before serving this engine,
43+
with delivery writers/consumers closed during the transition. It adds columns
44+
and an index without rebuilding tables or removing history or recipients.
45+
The old engine persisted reconciliation history only in Queue messages. Existing
46+
rows therefore receive generation five and no intent; existing statuses and
47+
attempts stay intact. Active legacy rows can finish their current delivery
48+
series, but receive no additional reconcile cycles. Older in-flight messages
49+
cannot lower that conservative value. New producer inserts explicitly choose
50+
zero; conflicts do not reset a pre-existing budget. The SQL and Drizzle defaults
51+
both remain five so omitted-generation writers do not silently mint a new budget.
52+
53+
This availability tradeoff avoids claiming an exact recovered generation from
54+
unavailable history. Historical dead-letter rows and dropped pre-migration DLQ
55+
notifications require exact operator-data audit and adjudication; this migration
56+
does not revive them. No automatic backfill infers their intent. Snapshot the
57+
operator-owned data and inspect pending/terminal jobs before application. Do not
58+
overlap old consumers: they ignore durable generations and can unconditionally
59+
revive rows. New code requires the columns. Old code can read the additive schema
60+
but ignores its bounds and intents, so code rollback alone does not preserve this
61+
contract. Use a compatible forward repair, or restore a qualified pre-mutation
62+
snapshot with writers closed and explicitly account for intervening deliveries.
63+
64+
Portable tests model bounded D1 statements; disposable actual-source D1/workerd
65+
checks qualify SQL/CAS semantics separately. Neither qualifies real Queue
66+
delivery, federation, cron, monitoring, production migration or operator recovery.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- Additive: no table rebuild, row deletion, recipient rewrite, or Activity
2+
-- history change. Legacy reconciliation generations only existed in Queue
3+
-- messages and cannot be reconstructed safely. Preserve their status/attempts
4+
-- but conservatively exhaust the additional reconciliation budget (five).
5+
-- New endpoint materialization explicitly sets reconcile_attempt to zero.
6+
ALTER TABLE delivery_queue
7+
ADD COLUMN reconcile_attempt INTEGER NOT NULL DEFAULT 5
8+
CHECK (reconcile_attempt BETWEEN 0 AND 5);
9+
ALTER TABLE delivery_queue
10+
ADD COLUMN dlq_pending INTEGER NOT NULL DEFAULT 0
11+
CHECK (dlq_pending IN (0, 1));
12+
ALTER TABLE delivery_queue ADD COLUMN processing_token TEXT;
13+
14+
-- next_attempt_at is the persisted six-hour reconciliation due time while
15+
-- status=dead_letter and dlq_pending=1. The intent survives accepted-but-lost
16+
-- in-memory Queue sends and is consumed only by the reconcile CAS.
17+
CREATE INDEX delivery_queue_reconcile_due_idx
18+
ON delivery_queue(next_attempt_at)
19+
WHERE status = 'dead_letter' AND dlq_pending = 1;

‎migrations/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ SQL migrations for the yurucommu social engine. Deployable product repos
1616

1717
## Naming convention
1818

19+
Migration 0033 makes endpoint reconciliation generations and repair intents
20+
durable without rebuilding tables. Legacy jobs retain their current series but
21+
receive no inferred additional reconciliation budget. Apply it with delivery
22+
writers/consumers closed before serving its engine. See [durable delivery
23+
reconciliation](../docs/design/delivery-reconciliation.md) for the conservative
24+
legacy policy, notification-loss recovery and rollback limitations.
25+
1926
Migration 0032 adds atomic public-outbox revocation for deleted-object Activity
2027
history without rebuilding tables or rewriting ledger rows. Apply it before
2128
serving its engine. It cannot classify pre-existing absent targets; exact

0 commit comments

Comments
 (0)