Skip to content

Commit b00f11a

Browse files
committed
feat(outbox): add transactional side-effect delivery
1 parent 6b3c005 commit b00f11a

3 files changed

Lines changed: 591 additions & 0 deletions

File tree

docs/side-effect-outbox.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Payout and notification outbox
2+
3+
Payouts and notifications have two different reliability boundaries: the
4+
database transaction that records business state and the provider call that
5+
delivers a side effect. The side-effect outbox joins those boundaries by
6+
writing an outbox row in the same logical transaction as the business record.
7+
The worker delivers the row later and records the result.
8+
9+
## Transaction boundary
10+
11+
Use `SideEffectOutbox.transaction()` for a state change that requires an
12+
external payout or notification:
13+
14+
```ts
15+
outbox.transaction((transaction) => {
16+
transaction.putSideEffect(`payout:${payoutId}`, {
17+
predictionId,
18+
amount,
19+
});
20+
transaction.enqueue(
21+
`payout:${payoutId}`,
22+
OUTBOX_EVENT_TYPES.PAYOUT,
23+
{ predictionId, amount },
24+
);
25+
});
26+
```
27+
28+
The callback stages both the business-side effect and its delivery record.
29+
Neither becomes visible if the callback throws. The transaction is deliberately
30+
small and synchronous in the in-memory implementation; a database adapter can
31+
put the equivalent inserts in one SQL transaction. Provider calls must never
32+
run inside this commit callback.
33+
34+
This ordering addresses the crash window after commit and before delivery:
35+
36+
1. The business state and pending outbox event commit together.
37+
2. The process crashes before the provider call.
38+
3. A later worker claims the still-pending event.
39+
4. The provider receives the event and the worker marks it complete.
40+
41+
There is no silent “database committed, queue publish failed” branch. A
42+
duplicate idempotency key is a no-op and cannot create a second side effect.
43+
The first payload remains authoritative, so a caller cannot rewrite a pending
44+
event by repeating an enqueue with different data.
45+
46+
## Event types and keys
47+
48+
The current event catalog contains:
49+
50+
| Type | Key format | Example payload |
51+
| ---- | ---------- | --------------- |
52+
| `payout` | `payout:<payout-id>` | Prediction, amount, and currency |
53+
| `notification` | `notification:<notification-id>` | User, title, body, and data |
54+
55+
Use stable business IDs for keys. A retry, worker restart, HTTP retry, or
56+
replayed command must calculate the same key. `enqueuePayout` and
57+
`enqueueNotification` are small helpers that enforce the catalog prefix.
58+
Payloads are cloned at the transaction boundary and when returned from the
59+
store, preventing later caller mutation from changing an audit or delivery
60+
record.
61+
62+
## Worker lifecycle
63+
64+
The processor uses four states:
65+
66+
- `pending`: committed and waiting for delivery or its next retry;
67+
- `processing`: claimed by a worker and protected by a lease;
68+
- `completed`: the handler acknowledged the side effect;
69+
- `dead_letter`: the handler failed at the maximum attempt count.
70+
71+
`claim(limit, now, leaseMs)` orders pending rows by creation time and stable
72+
idempotency key,
73+
marks at most the bounded limit as processing, increments their attempts, and
74+
returns snapshots. A worker crash leaves the row processing only until the
75+
lease expires. A later claim reclaims it as pending. The claim operation is
76+
the point at which an attempt is counted, so an attempt that crashes before
77+
the provider call is still visible to operators.
78+
79+
`process(handler, options, now)` claims a bounded batch and handles every
80+
claimed row independently. A handler exception is converted into a safe error
81+
string. The row returns to pending with exponential delay until its attempt
82+
limit is reached; after that it becomes a terminal dead letter. Processing
83+
continues to the next row after either outcome, so one poison notification
84+
cannot starve an unrelated payout.
85+
86+
## Idempotency
87+
88+
The outbox deduplicates enqueue operations by `idempotencyKey`. It also avoids
89+
calling a handler for completed rows because only pending rows can be claimed.
90+
The downstream payout and notification handlers must still be idempotent: a
91+
worker can crash after a provider accepts a request but before the completion
92+
update. The handler should pass the outbox event ID or business key to a
93+
provider that supports idempotency and should use a database uniqueness guard
94+
for local effects.
95+
96+
Exactly-once execution is not promised by a networked worker. The design
97+
provides at-least-once delivery plus stable keys and completion state, which is
98+
the safe boundary for external effects.
99+
100+
## Retry and poison handling
101+
102+
The default maximum is five attempts. Backoff starts at 250 ms and is capped at
103+
60 seconds. Configuration is bounded inside the service: attempts are capped
104+
at 20, delays at 60 seconds, and a claim batch at 1,000 rows. Invalid negative,
105+
zero, fractional, or non-finite values raise `OutboxError` with the stable
106+
`INVALID_OPTIONS` code. The handler's internal exception text is retained only
107+
as the operator-facing `lastError` field; it is not thrown through the worker
108+
loop or returned as an HTTP response.
109+
110+
A poison event enters `dead_letter` after its final failed attempt. It remains
111+
visible through `list("dead_letter")` for a separate repair or replay tool.
112+
The worker never deletes poison rows automatically. Operators can correct a
113+
bad recipient, fix a payload contract, or explicitly replay with the same
114+
business key after investigation.
115+
116+
## Payout guidance
117+
118+
Payout state should be recorded in the same transaction as a `payout` event.
119+
The payout handler should submit the event using a provider idempotency key,
120+
persist any transaction hash, and treat an already-completed payout as a
121+
successful no-op. It must not infer payment completion merely because an
122+
outbox event was claimed. Chain confirmation remains a separate concern.
123+
124+
## Notification guidance
125+
126+
Notification rows and their `notification` outbox event should be committed
127+
together when a user-visible state change requires a notification. Email,
128+
push, and webhook adapters should be separate handlers or downstream fanout
129+
steps. A malformed address or permanently rejected template should exhaust
130+
only that event and leave other notifications eligible for delivery.
131+
132+
## Observability and operations
133+
134+
Expose counts by status and event type to metrics, and alert on pending age,
135+
processing leases that expire repeatedly, and dead-letter growth. Include the
136+
outbox event ID, idempotency key, business entity ID, attempt, and correlation
137+
ID in structured logs. Do not include access tokens or provider credentials in
138+
payloads or error strings.
139+
140+
The outbox is process-local in the development implementation. It is useful
141+
for deterministic tests and local behavior, but a production deployment must
142+
back it with durable database rows or a durable queue. The required properties
143+
for that adapter are the same: unique idempotency key, atomic business-plus-
144+
outbox commit, lease-based claiming, conditional completion, bounded retry,
145+
and a terminal dead-letter state.
146+
147+
## Test matrix
148+
149+
The regression suite covers atomic commit, rollback on callback failure,
150+
duplicate payout and notification requests, payload isolation, deterministic
151+
claiming, successful processing, crash/lease recovery, transient retry,
152+
terminal exhaustion, poison-message isolation, and invalid configuration.
153+
Together these cases protect both the database-to-outbox crash boundary and
154+
the outbox-to-provider delivery boundary without requiring a live provider.

0 commit comments

Comments
 (0)