|
| 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