@openlup/outbox 0.0.0 → 0.13.1

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.
@@ -0,0 +1,99 @@
1
+ import metadata from "../package.json" with { type: "json" };
2
+ import { validateScheduledConfig } from "./config.js";
3
+ import { runOutboxDispatchWorker } from "./worker.js";
4
+ /** @beta Lease-bound dispatch and terminal compaction, with no host entrypoint. */
5
+ export function createOutbox(ports, options) {
6
+ options = { ...options, config: { ...options.config }, knownEventTypes: [...options.knownEventTypes],
7
+ requiredSchema: { ...options.requiredSchema, objects: options.requiredSchema.objects.map((o) => ({ ...o })) },
8
+ ...(options.prune ? { prune: { ...options.prune } } : {}) };
9
+ ports = { ...ports };
10
+ const descriptors = ports.descriptors.map((d) => Object.freeze({ eventType: d.eventType, timeoutMs: d.timeoutMs }));
11
+ validateScheduledConfig(options.config, options.hostLimitSeconds, descriptors.map((d) => d.timeoutMs));
12
+ const positive = (key, value, maximum) => {
13
+ if (!Number.isInteger(value) || value <= 0 || value > maximum)
14
+ throw new Error(`@openlup/outbox: ${key}: use a positive integer at most ${maximum}`);
15
+ };
16
+ positive("leaseSeconds", options.leaseSeconds, 3600);
17
+ if (options.leaseSeconds < options.hostLimitSeconds)
18
+ throw new Error("@openlup/outbox: leaseSeconds: cover the full host execution window, including finish time");
19
+ positive("cadenceSeconds", options.cadenceSeconds, 86400);
20
+ if (new Set(descriptors.map((d) => d.eventType)).size !== descriptors.length || descriptors.some((d) => !d.eventType.trim()))
21
+ throw new Error("@openlup/outbox: descriptors: supply one descriptor for each composed event handler");
22
+ if (!ports.store || !ports.lease || typeof ports.buildHandlers !== "function")
23
+ throw new Error("@openlup/outbox: ports: supply store, lease and handler builder");
24
+ const suppliedLogger = ports.logger ?? console;
25
+ const log = (method, args) => { try {
26
+ suppliedLogger[method](...args);
27
+ }
28
+ catch { /* Diagnostics cannot skip finish or cleanup. */ } };
29
+ const logger = { warn: (...args) => log("warn", args), error: (...args) => log("error", args) };
30
+ const config = { ...options.config }, knownEventTypes = [...options.knownEventTypes];
31
+ const pruning = options.prune?.enabled === true;
32
+ const retention = { processedDays: options.prune?.processedDays ?? 30, discardedDays: options.prune?.discardedDays ?? 90, limit: options.prune?.limit ?? 500 };
33
+ if (pruning) {
34
+ if (!ports.compactor)
35
+ throw new Error("@openlup/outbox: compactor: bind terminal compaction when prune is selected");
36
+ positive("prune.cadenceSeconds", options.prune.cadenceSeconds, 604800);
37
+ positive("processedDays", retention.processedDays, 3650);
38
+ positive("discardedDays", retention.discardedDays, 3650);
39
+ positive("limit", retention.limit, 10000);
40
+ }
41
+ const summary = (reason) => ({ ok: false, checked: 0, updated: 0, failures: 1, skipped: false, reason });
42
+ const run = async (jobName, invocation, dispatch) => {
43
+ const claim = await ports.lease.claimJobRun(jobName, invocation, options.leaseSeconds);
44
+ if (!claim.acquired)
45
+ return { outcome: claim.reason === "job_disabled" ? "disabled" : "skipped", detail: { ok: true, checked: 0, updated: 0, failures: 0, skipped: true, reason: claim.reason } };
46
+ if (!claim.runId)
47
+ throw new Error("@openlup/outbox: acquired lease has no run id");
48
+ let scope;
49
+ let hookMetadata = {};
50
+ let detail = summary("outbox_dispatch_failed");
51
+ let failure = false;
52
+ try {
53
+ try {
54
+ if (dispatch) {
55
+ const context = { runId: claim.runId, invocation };
56
+ hookMetadata = await ports.beforeDispatch?.(context) ?? {};
57
+ const built = await ports.buildHandlers(context);
58
+ scope = "handlers" in built ? built : { handlers: built };
59
+ const actual = scope.handlers;
60
+ if (actual.size !== descriptors.length || descriptors.some((d) => actual.get(d.eventType)?.eventType !== d.eventType || actual.get(d.eventType)?.timeoutMs !== d.timeoutMs))
61
+ throw new Error("outbox_registry_descriptor_mismatch");
62
+ detail = await runOutboxDispatchWorker({ store: ports.store, registry: actual, config, knownEventTypes, diagnostics: ports.diagnostics, clock: ports.clock, logger });
63
+ }
64
+ else {
65
+ const count = await ports.compactor.compact(retention);
66
+ detail = { ok: true, checked: count, updated: count, failures: 0, skipped: false };
67
+ }
68
+ }
69
+ catch (error) {
70
+ failure = true;
71
+ detail = summary(message(error));
72
+ logger.error("[outbox-dispatch] schedule_failed", message(error));
73
+ }
74
+ // Reserved run identity and worker counters override hook metadata.
75
+ const extra = { ...hookMetadata, ...detail, runId: claim.runId };
76
+ const finishApplied = await ports.lease.finishJobRun(jobName, claim.runId, invocation, failure ? "failed" : "success", detail, extra);
77
+ return { outcome: failure || !finishApplied ? "failed" : detail.skipped ? "skipped" : "success", detail, finishApplied };
78
+ }
79
+ catch (error) {
80
+ logger.error("[outbox-dispatch] finish_job_run_failed", message(error));
81
+ throw error;
82
+ }
83
+ finally {
84
+ try {
85
+ await scope?.close?.();
86
+ }
87
+ catch (error) {
88
+ logger.error("[outbox-dispatch] handler_scope_close_failed", message(error));
89
+ }
90
+ }
91
+ };
92
+ const schedules = [{ id: "outbox-dispatch", cadenceSeconds: options.cadenceSeconds, run: (invocation) => run("outbox-dispatch", invocation, true) },
93
+ ...(pruning ? [{ id: "outbox-prune", cadenceSeconds: options.prune.cadenceSeconds, run: (invocation) => run("outbox-prune", invocation, false) }] : [])];
94
+ return { handlers: descriptors, schedules, routes: [], manifest: {
95
+ name: metadata.name, version: metadata.version, kind: "rail", emits: [], handles: descriptors.map((d) => d.eventType),
96
+ schedules: schedules.map(({ id, cadenceSeconds }) => ({ id, cadenceSeconds })), routes: [], requiredPorts: ["store", "lease", ...(pruning ? ["compactor"] : [])], env: [], requiredSchema: options.requiredSchema,
97
+ } };
98
+ }
99
+ function message(error) { return (error instanceof Error ? error.message : String(error)).slice(0, 180); }
@@ -0,0 +1,10 @@
1
+ export * from "./contracts.js";
2
+ export { runOutboxDispatchWorker } from "./worker.js";
3
+ export { OutboxHandlerExecutionTrace } from "./trace.js";
4
+ export { uniqueRegistry, composeHandlers, claimAllowlist } from "./registry.js";
5
+ export { readOutboxDispatchConfig, validateScheduledConfig } from "./config.js";
6
+ export { buildOutboxEnqueue } from "./enqueue.js";
7
+ export type { EnqueueEvent } from "./enqueue.js";
8
+ export { withPayloadSchema } from "./typedEvents.js";
9
+ export { createOutbox } from "./factory.js";
10
+ export type { OutboxFactoryPorts, OutboxFactoryOptions, OutboxScheduleResult, HandlerScope, CompactionPort } from "./factory.js";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./contracts.js";
2
+ export { runOutboxDispatchWorker } from "./worker.js";
3
+ export { OutboxHandlerExecutionTrace } from "./trace.js";
4
+ export { uniqueRegistry, composeHandlers, claimAllowlist } from "./registry.js";
5
+ export { readOutboxDispatchConfig, validateScheduledConfig } from "./config.js";
6
+ export { buildOutboxEnqueue } from "./enqueue.js";
7
+ export { withPayloadSchema } from "./typedEvents.js";
8
+ export { createOutbox } from "./factory.js";
@@ -0,0 +1,10 @@
1
+ import type { SqlExecutor, RequiredSchema } from "@openlup/core/readiness";
2
+ import type { OutboxStore } from "./contracts.js";
3
+ import type { CompactionPort } from "./factory.js";
4
+ /** @beta These identities describe the shipped default adapter only. */
5
+ export declare const POSTGRES_OUTBOX_SCHEMA: RequiredSchema;
6
+ /** @beta Supply an autocommit executor or a per-operation transaction that commits before resolve.
7
+ * Never pass a producer transaction or retain an operation transaction across handler I/O. */
8
+ export declare function createPostgresOutboxStore(executor: SqlExecutor): OutboxStore;
9
+ /** @beta Compacts terminal payload/error, retaining the event and its unique key. */
10
+ export declare function createPostgresOutboxCompactor(executor: SqlExecutor): CompactionPort;
@@ -0,0 +1,79 @@
1
+ /** @beta These identities describe the shipped default adapter only. */
2
+ export const POSTGRES_OUTBOX_SCHEMA = {
3
+ version: "outbox-1", migration: "sql/0001_outbox.sql", objects: [
4
+ { kind: "table", name: "public.outbox_events" }, { kind: "table", name: "public.outbox_dormant_event_types" },
5
+ ...["id", "created_at", "available_at", "processed_at", "aggregate_type", "aggregate_id", "event_type", "idempotency_key", "status", "attempts", "payload", "error", "metadata"].map((name) => ({ kind: "column", name: `public.outbox_events.${name}` })),
6
+ { kind: "function", name: "public.outbox_claim_batch", signature: "text[],text[],integer,integer,integer" },
7
+ { kind: "function", name: "public.outbox_mark_processed", signature: "uuid,text,jsonb" },
8
+ { kind: "function", name: "public.outbox_mark_failed", signature: "uuid,text,text,text,integer,integer,integer,integer" },
9
+ { kind: "function", name: "public.outbox_release_unprocessed", signature: "uuid[],text[],integer" },
10
+ { kind: "function", name: "pg_catalog.pg_input_is_valid", signature: "text,text" },
11
+ ],
12
+ };
13
+ /** @beta Supply an autocommit executor or a per-operation transaction that commits before resolve.
14
+ * Never pass a producer transaction or retain an operation transaction across handler I/O. */
15
+ export function createPostgresOutboxStore(executor) {
16
+ const scalar = async (text, values) => {
17
+ const { rows } = await executor.query(text, values);
18
+ if (rows.length !== 1 || !Object.hasOwn(rows[0], "value"))
19
+ throw new Error("outbox_store_invalid_scalar");
20
+ return rows[0].value;
21
+ };
22
+ return {
23
+ async claimBatch(input) {
24
+ const { rows } = await executor.query(`select id::text, to_char(created_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as created_at,
25
+ to_char(available_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as available_at,
26
+ to_char(processed_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as processed_at,
27
+ aggregate_type, aggregate_id::text, event_type, idempotency_key, status, attempts, payload, error, metadata
28
+ from public.outbox_claim_batch($1::text[], $2::text[], $3::integer, $4::integer, $5::integer)`, [input.eventTypes, input.knownEventTypes ?? [], input.batchSize, input.visibilitySeconds, input.maxAttempts]);
29
+ return rows;
30
+ },
31
+ async markProcessed(input) {
32
+ const value = await scalar("select public.outbox_mark_processed($1::uuid, $2::text, $3::jsonb) as value", [input.eventId, input.claimToken, JSON.stringify(input.metadata ?? {})]);
33
+ if (typeof value !== "boolean")
34
+ throw new Error("outbox_mark_processed_invalid_response");
35
+ return { applied: value };
36
+ },
37
+ async markFailed(input) {
38
+ const value = await scalar("select public.outbox_mark_failed($1::uuid,$2::text,$3::text,$4::text,$5::integer,$6::integer,$7::integer,$8::integer) as value", [input.eventId, input.claimToken, input.error, input.outcome, input.baseDelaySeconds, input.maxDelaySeconds, input.maxAttempts, input.snoozeSeconds]);
39
+ if (!["failed", "discarded", "snoozed", "missed"].includes(String(value)))
40
+ throw new Error("outbox_mark_failed_unexpected_status");
41
+ return { status: value };
42
+ },
43
+ async releaseUnprocessed(items, delaySeconds) {
44
+ if (!items.length)
45
+ return 0;
46
+ const value = await scalar("select public.outbox_release_unprocessed($1::uuid[],$2::text[],$3::integer) as value", [items.map((i) => i.eventId), items.map((i) => i.claimToken), delaySeconds]);
47
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0)
48
+ throw new Error("outbox_release_invalid_response");
49
+ return value;
50
+ },
51
+ async hasPendingEventType(eventType) {
52
+ const value = await scalar("select exists (select 1 from public.outbox_events where event_type=$1 and status in ('pending','failed','processing')) as value", [eventType]);
53
+ if (typeof value !== "boolean")
54
+ throw new Error("outbox_pending_invalid_response");
55
+ return value;
56
+ },
57
+ };
58
+ }
59
+ /** @beta Compacts terminal payload/error, retaining the event and its unique key. */
60
+ export function createPostgresOutboxCompactor(executor) {
61
+ return { async compact(input) {
62
+ const { rows } = await executor.query(`with targets as (
63
+ select id from public.outbox_events
64
+ where metadata->>'retentionCompactedAt' is null and (
65
+ (status = 'processed' and coalesce(processed_at, created_at) < now() - make_interval(days => $1::integer))
66
+ or (status = 'discarded' and case when pg_catalog.pg_input_is_valid(metadata->>'discardedAt', 'timestamp with time zone')
67
+ then (metadata->>'discardedAt')::timestamptz else null end < now() - make_interval(days => $2::integer)))
68
+ order by created_at, id limit $3::integer for update skip locked
69
+ ), compacted as (
70
+ update public.outbox_events e set payload = '{}'::jsonb, error = null,
71
+ metadata = e.metadata || jsonb_build_object('retentionCompactedAt', now(), 'retentionCompactedBy', 'outbox_compact_terminal', 'retentionCompactedStatus', e.status)
72
+ from targets t where e.id = t.id returning e.id
73
+ ) select count(*)::integer as value from compacted`, [input.processedDays, input.discardedDays, input.limit]);
74
+ const value = rows[0]?.value;
75
+ if (rows.length !== 1 || typeof value !== "number" || !Number.isInteger(value) || value < 0)
76
+ throw new Error("outbox_compaction_invalid_response");
77
+ return value;
78
+ } };
79
+ }
@@ -0,0 +1,4 @@
1
+ import type { OutboxHandler, OutboxHandlerRegistry } from "./contracts.js";
2
+ export declare function claimAllowlist(registry: OutboxHandlerRegistry): string[];
3
+ export declare function uniqueRegistry(handlers: readonly OutboxHandler[]): OutboxHandlerRegistry;
4
+ export declare function composeHandlers(first: OutboxHandler, second: OutboxHandler): OutboxHandler;
@@ -0,0 +1,48 @@
1
+ export function claimAllowlist(registry) {
2
+ return [...registry.keys()];
3
+ }
4
+ export function uniqueRegistry(handlers) {
5
+ const registry = new Map();
6
+ for (const handler of handlers) {
7
+ const existing = registry.get(handler.eventType);
8
+ registry.set(handler.eventType, existing ? composeHandlers(existing, handler) : handler);
9
+ }
10
+ return registry;
11
+ }
12
+ // Preserve top-level delivery observations, with first-component precedence.
13
+ const DELIVERY_LIFECYCLE_METADATA_KEYS = ["resendId", "providerMessageId", "skipped", "dedupe"];
14
+ function hoistDeliveryLifecycleKeys(detail) {
15
+ if (!detail)
16
+ return {};
17
+ const hoisted = {};
18
+ for (const key of DELIVERY_LIFECYCLE_METADATA_KEYS) {
19
+ if (detail[key] !== undefined)
20
+ hoisted[key] = detail[key];
21
+ }
22
+ return hoisted;
23
+ }
24
+ export function composeHandlers(first, second) {
25
+ return {
26
+ eventType: first.eventType,
27
+ timeoutMs: first.timeoutMs + second.timeoutMs,
28
+ async handle(row, signal) {
29
+ const firstOutcome = await first.handle(row, signal);
30
+ if (firstOutcome.kind !== "processed")
31
+ return firstOutcome;
32
+ const secondOutcome = await second.handle(row, signal);
33
+ if (secondOutcome.kind !== "processed")
34
+ return secondOutcome;
35
+ return {
36
+ kind: "processed",
37
+ detail: {
38
+ // Hoist second first, then first, so the primary (email) handler wins on
39
+ // the rare chance both emit a lifecycle key.
40
+ ...hoistDeliveryLifecycleKeys(secondOutcome.detail),
41
+ ...hoistDeliveryLifecycleKeys(firstOutcome.detail),
42
+ first: firstOutcome.detail ?? {},
43
+ second: secondOutcome.detail ?? {},
44
+ },
45
+ };
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,5 @@
1
+ import type { OutboxStore, OutboxEventRow } from "./contracts.js";
2
+ /** @beta Framework-free refusal suite. The caller supplies a real processing row. */
3
+ export declare function assertOutboxStoreFences(store: OutboxStore, processing: OutboxEventRow): Promise<void>;
4
+ /** @beta Synthetic envelope; never use for deployed queue observations. */
5
+ export declare function syntheticOutboxRow(overrides?: Partial<OutboxEventRow>): OutboxEventRow;
@@ -0,0 +1,16 @@
1
+ /** @beta Framework-free refusal suite. The caller supplies a real processing row. */
2
+ export async function assertOutboxStoreFences(store, processing) {
3
+ const token = processing.metadata.claimToken;
4
+ if (processing.status !== "processing" || typeof token !== "string" || !token)
5
+ throw new Error("conformance_requires_processing_row");
6
+ if ((await store.markProcessed({ eventId: processing.id, claimToken: `stale-${token}` })).applied)
7
+ throw new Error("conformance_stale_processed_applied");
8
+ if ((await store.markFailed({ eventId: processing.id, claimToken: `stale-${token}`, error: "synthetic", outcome: "retry", baseDelaySeconds: 5, maxDelaySeconds: 10, maxAttempts: 3, snoozeSeconds: 60 })).status !== "missed")
9
+ throw new Error("conformance_stale_failure_applied");
10
+ if (await store.releaseUnprocessed([{ eventId: processing.id, claimToken: `stale-${token}` }], 0) !== 0)
11
+ throw new Error("conformance_stale_release_applied");
12
+ }
13
+ /** @beta Synthetic envelope; never use for deployed queue observations. */
14
+ export function syntheticOutboxRow(overrides = {}) {
15
+ return { id: "00000000-0000-4000-8000-000000000001", created_at: "2026-01-01T00:00:00.000001Z", available_at: "2026-01-01T00:00:00.000001Z", processed_at: null, aggregate_type: "example", aggregate_id: "00000000-0000-4000-8000-000000000002", event_type: "example.created", idempotency_key: "synthetic-1", status: "processing", attempts: 1, payload: {}, error: null, metadata: { claimToken: "synthetic-token" }, ...overrides };
16
+ }
@@ -0,0 +1,8 @@
1
+ import type { OutboxHandler, OutboxEventRow, OutboxHandlerOutcome, OutboxHandlerExecutionContext } from "./contracts.js";
2
+ export declare class OutboxHandlerExecutionTrace implements OutboxHandlerExecutionContext {
3
+ private lastPhase;
4
+ setPhase(phase: string): void;
5
+ timeoutReason(): string;
6
+ qualifyTimeout(outcome: OutboxHandlerOutcome): OutboxHandlerOutcome;
7
+ }
8
+ export declare function executeHandler(handler: OutboxHandler, row: OutboxEventRow): Promise<OutboxHandlerOutcome>;
package/dist/trace.js ADDED
@@ -0,0 +1,44 @@
1
+ const SAFE_MESSAGE_MAX_CHARS = 300;
2
+ export class OutboxHandlerExecutionTrace {
3
+ lastPhase;
4
+ setPhase(phase) {
5
+ const sanitized = phase.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 48);
6
+ if (sanitized)
7
+ this.lastPhase = sanitized;
8
+ }
9
+ timeoutReason() {
10
+ return this.lastPhase ? `outbox_handler_timeout:${this.lastPhase}` : "outbox_handler_timeout";
11
+ }
12
+ qualifyTimeout(outcome) {
13
+ return this.lastPhase && outcome.kind === "retry" && outcome.reason === "outbox_handler_timeout"
14
+ ? { ...outcome, reason: this.timeoutReason() }
15
+ : outcome;
16
+ }
17
+ }
18
+ export async function executeHandler(handler, row) {
19
+ const controller = new AbortController();
20
+ const execution = new OutboxHandlerExecutionTrace();
21
+ let abortTimer;
22
+ let backstopTimer;
23
+ try {
24
+ // Abort is the primary timeout mechanism (handlers must honor the
25
+ // signal); the race is only a backstop for non-abortable code, firing
26
+ // 500ms later so abort-aware handlers get to settle first.
27
+ const backstop = new Promise((resolve) => {
28
+ backstopTimer = setTimeout(() => resolve({ kind: "retry", reason: execution.timeoutReason() }), handler.timeoutMs + 500);
29
+ });
30
+ abortTimer = setTimeout(() => controller.abort(), handler.timeoutMs);
31
+ const outcome = await Promise.race([handler.handle(row, controller.signal, execution), backstop]);
32
+ return execution.qualifyTimeout(outcome);
33
+ }
34
+ catch (error) {
35
+ return { kind: "retry", reason: safeMessage(error) };
36
+ }
37
+ finally {
38
+ if (abortTimer !== undefined)
39
+ clearTimeout(abortTimer);
40
+ if (backstopTimer !== undefined)
41
+ clearTimeout(backstopTimer);
42
+ }
43
+ }
44
+ function safeMessage(error) { return (error instanceof Error ? error.message : String(error)).slice(0, SAFE_MESSAGE_MAX_CHARS); }
@@ -0,0 +1,4 @@
1
+ import type { StandardSchemaV1 } from "@openlup/core/standard-schema";
2
+ import type { OutboxHandler } from "./contracts.js";
3
+ /** @beta Opt-in validation retries invalid payloads; it never changes untyped handlers. */
4
+ export declare function withPayloadSchema<Payload extends Record<string, unknown>>(handler: OutboxHandler, schema: StandardSchemaV1<unknown, Payload>): OutboxHandler;
@@ -0,0 +1,9 @@
1
+ /** @beta Opt-in validation retries invalid payloads; it never changes untyped handlers. */
2
+ export function withPayloadSchema(handler, schema) {
3
+ return { eventType: handler.eventType, timeoutMs: handler.timeoutMs, async handle(row, signal, execution) {
4
+ const result = await schema["~standard"].validate(row.payload);
5
+ if (result.issues)
6
+ return { kind: "retry", reason: "outbox_payload_invalid" };
7
+ return handler.handle({ ...row, payload: result.value }, signal, execution);
8
+ } };
9
+ }
@@ -0,0 +1,11 @@
1
+ import type { Clock, OutboxDispatchConfig, OutboxDispatchRunResult, OutboxHandlerRegistry, OutboxQueueDiagnostics, OutboxStore, OutboxLogger, OutboxObservation } from "./contracts.js";
2
+ export declare function runOutboxDispatchWorker(deps: {
3
+ store: OutboxStore;
4
+ registry: OutboxHandlerRegistry;
5
+ config: OutboxDispatchConfig;
6
+ knownEventTypes?: readonly string[];
7
+ diagnostics?: OutboxQueueDiagnostics;
8
+ clock?: Clock;
9
+ logger?: OutboxLogger;
10
+ observe?: (observation: OutboxObservation) => void;
11
+ }): Promise<OutboxDispatchRunResult>;
package/dist/worker.js ADDED
@@ -0,0 +1,232 @@
1
+ import { executeHandler } from "./trace.js";
2
+ // Tail reserved per row: worst-case handler.timeoutMs + this mark-RPC margin.
3
+ const MARK_MARGIN_MS = 2000;
4
+ // R-8: a hung queueStats RPC must never starve finishJobRun past Vercel's
5
+ // kill — best-effort stats race a hard deadline (try/catch only covers throws).
6
+ const QUEUE_STATS_DEADLINE_MS = 3000;
7
+ const DISCARDED_EVENT_IDS_CAP = 25;
8
+ const SAFE_MESSAGE_MAX_CHARS = 300;
9
+ export async function runOutboxDispatchWorker(deps) {
10
+ const { store, registry, config } = deps;
11
+ const suppliedLogger = deps.logger ?? console;
12
+ const log = (method, args) => {
13
+ try {
14
+ suppliedLogger[method](...args);
15
+ }
16
+ catch { /* Diagnostic failures cannot interrupt dispatch. */ }
17
+ };
18
+ const logger = { warn: (...args) => log("warn", args), error: (...args) => log("error", args) };
19
+ const observe = (event, count) => {
20
+ try {
21
+ deps.observe?.({ event, count });
22
+ }
23
+ catch { /* Diagnostics cannot rewrite an outcome. */ }
24
+ };
25
+ const clock = deps.clock ?? { now: () => Date.now() };
26
+ const startedAt = clock.now();
27
+ const counters = {
28
+ claimed: 0,
29
+ processed: 0,
30
+ retried: 0,
31
+ snoozed: 0,
32
+ discarded: 0,
33
+ released: 0,
34
+ duplicateInRun: 0,
35
+ leaseLost: 0,
36
+ batches: 0,
37
+ };
38
+ const byEventType = {};
39
+ const discardedEventIds = [];
40
+ const bump = (eventType, key) => {
41
+ const bucket = (byEventType[eventType] ??= {});
42
+ bucket[key] = (bucket[key] ?? 0) + 1;
43
+ };
44
+ const finish = async (input) => {
45
+ let queue;
46
+ if (!input.skipped && deps.diagnostics) {
47
+ let deadlineTimer;
48
+ try {
49
+ queue = await Promise.race([
50
+ deps.diagnostics.queueStats(),
51
+ new Promise((resolve) => {
52
+ deadlineTimer = setTimeout(() => resolve(undefined), QUEUE_STATS_DEADLINE_MS);
53
+ }),
54
+ ]);
55
+ if (queue === undefined)
56
+ logger.warn("[outbox-dispatch] queue_stats_failed", "deadline_exceeded");
57
+ }
58
+ catch (error) {
59
+ logger.warn("[outbox-dispatch] queue_stats_failed", safeMessage(error));
60
+ }
61
+ finally {
62
+ if (deadlineTimer !== undefined)
63
+ clearTimeout(deadlineTimer);
64
+ }
65
+ }
66
+ observe("finished", counters.processed);
67
+ return {
68
+ ok: true,
69
+ checked: counters.claimed,
70
+ updated: counters.processed,
71
+ failures: counters.retried + counters.discarded,
72
+ skipped: input.skipped,
73
+ ...(input.reason !== undefined ? { reason: input.reason } : {}),
74
+ ...counters,
75
+ runMs: clock.now() - startedAt,
76
+ byEventType,
77
+ discardedEventIds,
78
+ ...(queue !== undefined ? { queue } : {}),
79
+ };
80
+ };
81
+ const applyMarkFailed = async (row, claimToken, outcome, reason, benign = false) => {
82
+ const { status } = await store.markFailed({
83
+ eventId: row.id,
84
+ claimToken,
85
+ error: reason,
86
+ outcome,
87
+ baseDelaySeconds: config.backoffBaseSeconds,
88
+ maxDelaySeconds: config.backoffCapSeconds,
89
+ maxAttempts: config.maxAttempts,
90
+ snoozeSeconds: config.snoozeSeconds,
91
+ });
92
+ if (status === "failed") {
93
+ counters.retried += 1;
94
+ bump(row.event_type, "retried");
95
+ }
96
+ else if (status === "discarded") {
97
+ counters.discarded += 1;
98
+ bump(row.event_type, "discarded");
99
+ if (discardedEventIds.length < DISCARDED_EVENT_IDS_CAP)
100
+ discardedEventIds.push(row.id);
101
+ // Benign discard = expected terminal drop (deleted aggregate): same tag, logged at warn not error so it does not read as a failure.
102
+ const payload = { eventId: row.id, eventType: row.event_type, aggregateId: row.aggregate_id, attempts: row.attempts, reason, ...(benign ? { benign: true } : {}) };
103
+ (benign ? logger.warn : logger.error)("[outbox-dispatch] event_discarded", JSON.stringify(payload));
104
+ }
105
+ else if (status === "missed") {
106
+ counters.leaseLost += 1;
107
+ bump(row.event_type, "leaseLost");
108
+ logger.warn("[outbox-dispatch] outbox_lease_lost", row.id);
109
+ }
110
+ else {
111
+ counters.snoozed += 1;
112
+ bump(row.event_type, "snoozed");
113
+ }
114
+ return status;
115
+ };
116
+ const allowlist = [...registry.keys()];
117
+ if (allowlist.length === 0) {
118
+ return finish({ skipped: true, reason: "outbox_registry_empty" });
119
+ }
120
+ const seen = new Set();
121
+ let circuitBroken = false;
122
+ drain: while (clock.now() - startedAt < config.softBudgetMs) {
123
+ const claimed = await store.claimBatch({
124
+ eventTypes: allowlist,
125
+ knownEventTypes: deps.knownEventTypes,
126
+ batchSize: config.batchSize,
127
+ visibilitySeconds: config.visibilitySeconds,
128
+ maxAttempts: config.maxAttempts,
129
+ });
130
+ if (claimed.length === 0)
131
+ break;
132
+ counters.batches += 1;
133
+ counters.claimed += claimed.length;
134
+ observe("claimed", claimed.length);
135
+ // RETURNING order is unspecified — re-establish (created_at, id) order.
136
+ const batch = [...claimed].sort(compareByCreatedAtThenId);
137
+ const unstarted = [];
138
+ for (let index = 0; index < batch.length; index += 1) {
139
+ const row = batch[index];
140
+ const claimToken = row.metadata?.claimToken;
141
+ if (typeof claimToken !== "string" || claimToken.length === 0) {
142
+ counters.leaseLost += 1;
143
+ bump(row.event_type, "leaseLost");
144
+ logger.error("[outbox-dispatch] missing_claim_token", row.id);
145
+ continue;
146
+ }
147
+ if (seen.has(row.id)) {
148
+ // An in-process aborted orphan may still race — never re-execute in-run.
149
+ counters.released += await store.releaseUnprocessed([{ eventId: row.id, claimToken }], config.backoffBaseSeconds);
150
+ counters.duplicateInRun += 1;
151
+ bump(row.event_type, "duplicateInRun");
152
+ continue;
153
+ }
154
+ seen.add(row.id);
155
+ const handler = registry.get(row.event_type);
156
+ if (!handler) {
157
+ await applyMarkFailed(row, claimToken, "retry", "outbox_handler_missing");
158
+ continue;
159
+ }
160
+ const elapsed = clock.now() - startedAt;
161
+ if (elapsed + handler.timeoutMs + MARK_MARGIN_MS > config.softBudgetMs) {
162
+ unstarted.push({ eventId: row.id, claimToken });
163
+ continue;
164
+ }
165
+ const outcome = await executeHandler(handler, row);
166
+ if (outcome.kind === "processed") {
167
+ const { applied } = await store.markProcessed({
168
+ eventId: row.id,
169
+ claimToken,
170
+ metadata: outcome.detail,
171
+ });
172
+ if (applied) {
173
+ counters.processed += 1;
174
+ bump(row.event_type, "processed");
175
+ }
176
+ else {
177
+ counters.leaseLost += 1;
178
+ bump(row.event_type, "leaseLost");
179
+ logger.warn("[outbox-dispatch] outbox_lease_lost", row.id);
180
+ }
181
+ continue;
182
+ }
183
+ if (outcome.kind === "snooze") {
184
+ const snoozeCount = Number(row.metadata?.snoozeCount) || 0;
185
+ if (snoozeCount >= config.maxSnoozes) {
186
+ // Liveness bound: a misclassified permanent error must not snooze
187
+ // forever — consume an attempt instead. No circuit-break.
188
+ await applyMarkFailed(row, claimToken, "retry", `snooze_budget_exhausted:${outcome.reason}`);
189
+ continue;
190
+ }
191
+ await applyMarkFailed(row, claimToken, "snooze", outcome.reason);
192
+ // Circuit-break: the provider is down — refund everything still
193
+ // claimed (tail-gated rows included) and stop hammering it.
194
+ const remainder = [...unstarted];
195
+ for (let rest = index + 1; rest < batch.length; rest += 1) {
196
+ const restToken = batch[rest].metadata?.claimToken;
197
+ if (typeof restToken === "string" && restToken.length > 0) {
198
+ remainder.push({ eventId: batch[rest].id, claimToken: restToken });
199
+ }
200
+ }
201
+ if (remainder.length > 0) {
202
+ counters.released += await store.releaseUnprocessed(remainder, config.snoozeSeconds);
203
+ }
204
+ circuitBroken = true;
205
+ break drain;
206
+ }
207
+ await applyMarkFailed(row, claimToken, outcome.kind, outcome.reason, outcome.kind === "discard" && outcome.benign === true);
208
+ }
209
+ if (unstarted.length > 0) {
210
+ counters.released += await store.releaseUnprocessed(unstarted, 0);
211
+ break;
212
+ }
213
+ if (claimed.length < config.batchSize)
214
+ break;
215
+ }
216
+ return finish({
217
+ skipped: false,
218
+ ...(circuitBroken ? { reason: "provider_outage" } : {}),
219
+ });
220
+ }
221
+ function compareByCreatedAtThenId(a, b) {
222
+ if (a.created_at < b.created_at)
223
+ return -1;
224
+ if (a.created_at > b.created_at)
225
+ return 1;
226
+ if (a.id < b.id)
227
+ return -1;
228
+ if (a.id > b.id)
229
+ return 1;
230
+ return 0;
231
+ }
232
+ function safeMessage(error) { return (error instanceof Error ? error.message : String(error)).slice(0, SAFE_MESSAGE_MAX_CHARS); }