@openlup/outbox 0.0.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/worker.ts ADDED
@@ -0,0 +1,281 @@
1
+ import { executeHandler } from "./trace.js";
2
+ import type {
3
+ Clock,
4
+ OutboxDispatchConfig,
5
+ OutboxDispatchRunResult,
6
+ OutboxEventRow,
7
+ OutboxHandlerRegistry,
8
+ OutboxQueueDiagnostics,
9
+ OutboxStore,
10
+ OutboxLogger,
11
+ OutboxObservation,
12
+ } from "./contracts.js";
13
+
14
+ // Tail reserved per row: worst-case handler.timeoutMs + this mark-RPC margin.
15
+ const MARK_MARGIN_MS = 2000;
16
+ // R-8: a hung queueStats RPC must never starve finishJobRun past Vercel's
17
+ // kill — best-effort stats race a hard deadline (try/catch only covers throws).
18
+ const QUEUE_STATS_DEADLINE_MS = 3000;
19
+ const DISCARDED_EVENT_IDS_CAP = 25;
20
+ const SAFE_MESSAGE_MAX_CHARS = 300;
21
+
22
+ interface ClaimedItem {
23
+ eventId: string;
24
+ claimToken: string;
25
+ }
26
+
27
+ type PerTypeCounter =
28
+ | "processed"
29
+ | "retried"
30
+ | "snoozed"
31
+ | "discarded"
32
+ | "leaseLost"
33
+ | "duplicateInRun";
34
+
35
+ export async function runOutboxDispatchWorker(deps: {
36
+ store: OutboxStore;
37
+ registry: OutboxHandlerRegistry;
38
+ config: OutboxDispatchConfig;
39
+ knownEventTypes?: readonly string[];
40
+ diagnostics?: OutboxQueueDiagnostics;
41
+ clock?: Clock;
42
+ logger?: OutboxLogger;
43
+ observe?: (observation: OutboxObservation) => void;
44
+ }): Promise<OutboxDispatchRunResult> {
45
+ const { store, registry, config } = deps;
46
+ const suppliedLogger = deps.logger ?? console;
47
+ const log = (method: "warn" | "error", args: unknown[]): void => {
48
+ try { suppliedLogger[method](...args); } catch { /* Diagnostic failures cannot interrupt dispatch. */ }
49
+ };
50
+ const logger = { warn: (...args: unknown[]) => log("warn", args), error: (...args: unknown[]) => log("error", args) };
51
+ const observe = (event: "claimed" | "finished", count: number): void => {
52
+ try { deps.observe?.({ event, count }); } catch { /* Diagnostics cannot rewrite an outcome. */ }
53
+ };
54
+ const clock = deps.clock ?? { now: () => Date.now() };
55
+ const startedAt = clock.now();
56
+
57
+ const counters = {
58
+ claimed: 0,
59
+ processed: 0,
60
+ retried: 0,
61
+ snoozed: 0,
62
+ discarded: 0,
63
+ released: 0,
64
+ duplicateInRun: 0,
65
+ leaseLost: 0,
66
+ batches: 0,
67
+ };
68
+ const byEventType: Record<string, Record<string, number>> = {};
69
+ const discardedEventIds: string[] = [];
70
+
71
+ const bump = (eventType: string, key: PerTypeCounter): void => {
72
+ const bucket = (byEventType[eventType] ??= {});
73
+ bucket[key] = (bucket[key] ?? 0) + 1;
74
+ };
75
+
76
+ const finish = async (input: {
77
+ skipped: boolean;
78
+ reason?: string;
79
+ }): Promise<OutboxDispatchRunResult> => {
80
+ let queue: Record<string, unknown> | undefined;
81
+ if (!input.skipped && deps.diagnostics) {
82
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
83
+ try {
84
+ queue = await Promise.race([
85
+ deps.diagnostics.queueStats(),
86
+ new Promise<undefined>((resolve) => {
87
+ deadlineTimer = setTimeout(() => resolve(undefined), QUEUE_STATS_DEADLINE_MS);
88
+ }),
89
+ ]);
90
+ if (queue === undefined) logger.warn("[outbox-dispatch] queue_stats_failed", "deadline_exceeded");
91
+ } catch (error) {
92
+ logger.warn("[outbox-dispatch] queue_stats_failed", safeMessage(error));
93
+ } finally {
94
+ if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
95
+ }
96
+ }
97
+ observe("finished", counters.processed);
98
+ return {
99
+ ok: true,
100
+ checked: counters.claimed,
101
+ updated: counters.processed,
102
+ failures: counters.retried + counters.discarded,
103
+ skipped: input.skipped,
104
+ ...(input.reason !== undefined ? { reason: input.reason } : {}),
105
+ ...counters,
106
+ runMs: clock.now() - startedAt,
107
+ byEventType,
108
+ discardedEventIds,
109
+ ...(queue !== undefined ? { queue } : {}),
110
+ };
111
+ };
112
+
113
+ const applyMarkFailed = async (
114
+ row: OutboxEventRow,
115
+ claimToken: string,
116
+ outcome: "retry" | "discard" | "snooze",
117
+ reason: string,
118
+ benign = false,
119
+ ): Promise<"failed" | "discarded" | "snoozed" | "missed"> => {
120
+ const { status } = await store.markFailed({
121
+ eventId: row.id,
122
+ claimToken,
123
+ error: reason,
124
+ outcome,
125
+ baseDelaySeconds: config.backoffBaseSeconds,
126
+ maxDelaySeconds: config.backoffCapSeconds,
127
+ maxAttempts: config.maxAttempts,
128
+ snoozeSeconds: config.snoozeSeconds,
129
+ });
130
+ if (status === "failed") {
131
+ counters.retried += 1;
132
+ bump(row.event_type, "retried");
133
+ } else if (status === "discarded") {
134
+ counters.discarded += 1;
135
+ bump(row.event_type, "discarded");
136
+ if (discardedEventIds.length < DISCARDED_EVENT_IDS_CAP) discardedEventIds.push(row.id);
137
+ // Benign discard = expected terminal drop (deleted aggregate): same tag, logged at warn not error so it does not read as a failure.
138
+ const payload = { eventId: row.id, eventType: row.event_type, aggregateId: row.aggregate_id, attempts: row.attempts, reason, ...(benign ? { benign: true } : {}) };
139
+ (benign ? logger.warn : logger.error)("[outbox-dispatch] event_discarded", JSON.stringify(payload));
140
+ } else if (status === "missed") {
141
+ counters.leaseLost += 1;
142
+ bump(row.event_type, "leaseLost");
143
+ logger.warn("[outbox-dispatch] outbox_lease_lost", row.id);
144
+ } else {
145
+ counters.snoozed += 1;
146
+ bump(row.event_type, "snoozed");
147
+ }
148
+ return status;
149
+ };
150
+
151
+ const allowlist = [...registry.keys()];
152
+ if (allowlist.length === 0) {
153
+ return finish({ skipped: true, reason: "outbox_registry_empty" });
154
+ }
155
+
156
+ const seen = new Set<string>();
157
+ let circuitBroken = false;
158
+
159
+ drain: while (clock.now() - startedAt < config.softBudgetMs) {
160
+ const claimed = await store.claimBatch({
161
+ eventTypes: allowlist,
162
+ knownEventTypes: deps.knownEventTypes,
163
+ batchSize: config.batchSize,
164
+ visibilitySeconds: config.visibilitySeconds,
165
+ maxAttempts: config.maxAttempts,
166
+ });
167
+ if (claimed.length === 0) break;
168
+ counters.batches += 1;
169
+ counters.claimed += claimed.length;
170
+ observe("claimed", claimed.length);
171
+
172
+ // RETURNING order is unspecified — re-establish (created_at, id) order.
173
+ const batch = [...claimed].sort(compareByCreatedAtThenId);
174
+ const unstarted: ClaimedItem[] = [];
175
+
176
+ for (let index = 0; index < batch.length; index += 1) {
177
+ const row = batch[index];
178
+ const claimToken = row.metadata?.claimToken;
179
+ if (typeof claimToken !== "string" || claimToken.length === 0) {
180
+ counters.leaseLost += 1;
181
+ bump(row.event_type, "leaseLost");
182
+ logger.error("[outbox-dispatch] missing_claim_token", row.id);
183
+ continue;
184
+ }
185
+
186
+ if (seen.has(row.id)) {
187
+ // An in-process aborted orphan may still race — never re-execute in-run.
188
+ counters.released += await store.releaseUnprocessed(
189
+ [{ eventId: row.id, claimToken }],
190
+ config.backoffBaseSeconds,
191
+ );
192
+ counters.duplicateInRun += 1;
193
+ bump(row.event_type, "duplicateInRun");
194
+ continue;
195
+ }
196
+ seen.add(row.id);
197
+
198
+ const handler = registry.get(row.event_type);
199
+ if (!handler) {
200
+ await applyMarkFailed(row, claimToken, "retry", "outbox_handler_missing");
201
+ continue;
202
+ }
203
+
204
+ const elapsed = clock.now() - startedAt;
205
+ if (elapsed + handler.timeoutMs + MARK_MARGIN_MS > config.softBudgetMs) {
206
+ unstarted.push({ eventId: row.id, claimToken });
207
+ continue;
208
+ }
209
+
210
+ const outcome = await executeHandler(handler, row);
211
+
212
+ if (outcome.kind === "processed") {
213
+ const { applied } = await store.markProcessed({
214
+ eventId: row.id,
215
+ claimToken,
216
+ metadata: outcome.detail,
217
+ });
218
+ if (applied) {
219
+ counters.processed += 1;
220
+ bump(row.event_type, "processed");
221
+ } else {
222
+ counters.leaseLost += 1;
223
+ bump(row.event_type, "leaseLost");
224
+ logger.warn("[outbox-dispatch] outbox_lease_lost", row.id);
225
+ }
226
+ continue;
227
+ }
228
+
229
+ if (outcome.kind === "snooze") {
230
+ const snoozeCount = Number(row.metadata?.snoozeCount) || 0;
231
+ if (snoozeCount >= config.maxSnoozes) {
232
+ // Liveness bound: a misclassified permanent error must not snooze
233
+ // forever — consume an attempt instead. No circuit-break.
234
+ await applyMarkFailed(
235
+ row,
236
+ claimToken,
237
+ "retry",
238
+ `snooze_budget_exhausted:${outcome.reason}`,
239
+ );
240
+ continue;
241
+ }
242
+ await applyMarkFailed(row, claimToken, "snooze", outcome.reason);
243
+ // Circuit-break: the provider is down — refund everything still
244
+ // claimed (tail-gated rows included) and stop hammering it.
245
+ const remainder = [...unstarted];
246
+ for (let rest = index + 1; rest < batch.length; rest += 1) {
247
+ const restToken = batch[rest].metadata?.claimToken;
248
+ if (typeof restToken === "string" && restToken.length > 0) {
249
+ remainder.push({ eventId: batch[rest].id, claimToken: restToken });
250
+ }
251
+ }
252
+ if (remainder.length > 0) {
253
+ counters.released += await store.releaseUnprocessed(remainder, config.snoozeSeconds);
254
+ }
255
+ circuitBroken = true;
256
+ break drain;
257
+ }
258
+
259
+ await applyMarkFailed(row, claimToken, outcome.kind, outcome.reason, outcome.kind === "discard" && outcome.benign === true);
260
+ }
261
+
262
+ if (unstarted.length > 0) {
263
+ counters.released += await store.releaseUnprocessed(unstarted, 0);
264
+ break;
265
+ }
266
+ if (claimed.length < config.batchSize) break;
267
+ }
268
+
269
+ return finish({
270
+ skipped: false,
271
+ ...(circuitBroken ? { reason: "provider_outage" } : {}),
272
+ });
273
+ }
274
+ function compareByCreatedAtThenId(a: OutboxEventRow, b: OutboxEventRow): number {
275
+ if (a.created_at < b.created_at) return -1;
276
+ if (a.created_at > b.created_at) return 1;
277
+ if (a.id < b.id) return -1;
278
+ if (a.id > b.id) return 1;
279
+ return 0;
280
+ }
281
+ function safeMessage(error: unknown): string { return (error instanceof Error ? error.message : String(error)).slice(0, SAFE_MESSAGE_MAX_CHARS); }