@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.
package/src/factory.ts ADDED
@@ -0,0 +1,107 @@
1
+ import metadata from "../package.json" with { type: "json" };
2
+ import type { Contribution, RequiredSchema } from "@openlup/core/readiness";
3
+ import type { JobLeasePort, PlatformJobInvocation, PlatformJobFinishSummary } from "@openlup/core/platform-runtime";
4
+ import type { OutboxHandlerDescriptor } from "@openlup/core/outbox";
5
+ import type { OutboxDispatchConfig, OutboxDispatchRunResult, OutboxHandlerRegistry, OutboxLogger, OutboxStore, OutboxQueueDiagnostics, Clock } from "./contracts.js";
6
+ import { validateScheduledConfig } from "./config.js";
7
+ import { runOutboxDispatchWorker } from "./worker.js";
8
+ /** @beta */
9
+ export interface CompactionPort { compact(input: { processedDays: number; discardedDays: number; limit: number }): Promise<number> }
10
+ /** @beta The builder owns partially acquired resources until this scope is returned. */
11
+ export interface HandlerScope { handlers: OutboxHandlerRegistry; close?(): Promise<void> | void }
12
+ /** @beta */
13
+ export interface OutboxFactoryPorts {
14
+ store: OutboxStore; lease: JobLeasePort;
15
+ descriptors: readonly OutboxHandlerDescriptor[];
16
+ buildHandlers(run: { runId: string; invocation: PlatformJobInvocation }): Promise<OutboxHandlerRegistry | HandlerScope> | OutboxHandlerRegistry | HandlerScope;
17
+ beforeDispatch?(run: { runId: string; invocation: PlatformJobInvocation }): Promise<Record<string, unknown>>;
18
+ diagnostics?: OutboxQueueDiagnostics; clock?: Clock; logger?: OutboxLogger;
19
+ compactor?: CompactionPort;
20
+ }
21
+ /** @beta Schema requirements describe the selected adapters, not a guessed default. */
22
+ export interface OutboxFactoryOptions {
23
+ config: OutboxDispatchConfig; knownEventTypes: readonly string[];
24
+ hostLimitSeconds: number; leaseSeconds: number; cadenceSeconds: number;
25
+ requiredSchema: RequiredSchema;
26
+ prune?: { enabled: boolean; cadenceSeconds: number; processedDays?: number; discardedDays?: number; limit?: number };
27
+ }
28
+ /** @beta Finish/cleanup evidence is distinct from the accepted worker result. */
29
+ export interface OutboxScheduleResult {
30
+ outcome: "success" | "failed" | "skipped" | "disabled";
31
+ detail: OutboxDispatchRunResult | { ok: boolean; checked: number; updated: number; failures: number; skipped: boolean; reason?: string };
32
+ finishApplied?: boolean;
33
+ }
34
+ export function createOutbox(ports: OutboxFactoryPorts & { compactor: CompactionPort }, options: OutboxFactoryOptions): Contribution;
35
+ export function createOutbox(ports: OutboxFactoryPorts, options: OutboxFactoryOptions & { prune?: { enabled: false; cadenceSeconds: number } }): Contribution;
36
+ /** @beta Lease-bound dispatch and terminal compaction, with no host entrypoint. */
37
+ export function createOutbox(ports: OutboxFactoryPorts, options: OutboxFactoryOptions): Contribution {
38
+ options = { ...options, config: { ...options.config }, knownEventTypes: [...options.knownEventTypes],
39
+ requiredSchema: { ...options.requiredSchema, objects: options.requiredSchema.objects.map((o) => ({ ...o })) },
40
+ ...(options.prune ? { prune: { ...options.prune } } : {}) };
41
+ ports = { ...ports };
42
+ const descriptors = ports.descriptors.map((d) => Object.freeze({ eventType: d.eventType, timeoutMs: d.timeoutMs }));
43
+ validateScheduledConfig(options.config, options.hostLimitSeconds, descriptors.map((d) => d.timeoutMs));
44
+ const positive = (key: string, value: number, maximum: number): void => {
45
+ if (!Number.isInteger(value) || value <= 0 || value > maximum) throw new Error(`@openlup/outbox: ${key}: use a positive integer at most ${maximum}`);
46
+ };
47
+ positive("leaseSeconds", options.leaseSeconds, 3600);
48
+ if (options.leaseSeconds < options.hostLimitSeconds) throw new Error("@openlup/outbox: leaseSeconds: cover the full host execution window, including finish time"); positive("cadenceSeconds", options.cadenceSeconds, 86400);
49
+ if (new Set(descriptors.map((d) => d.eventType)).size !== descriptors.length || descriptors.some((d) => !d.eventType.trim())) throw new Error("@openlup/outbox: descriptors: supply one descriptor for each composed event handler");
50
+ if (!ports.store || !ports.lease || typeof ports.buildHandlers !== "function") throw new Error("@openlup/outbox: ports: supply store, lease and handler builder");
51
+ const suppliedLogger = ports.logger ?? console;
52
+ const log = (method: "warn" | "error", args: unknown[]): void => { try { suppliedLogger[method](...args); } catch { /* Diagnostics cannot skip finish or cleanup. */ } };
53
+ const logger = { warn: (...args: unknown[]) => log("warn", args), error: (...args: unknown[]) => log("error", args) };
54
+ const config = { ...options.config }, knownEventTypes = [...options.knownEventTypes];
55
+ const pruning = options.prune?.enabled === true;
56
+ const retention = { processedDays: options.prune?.processedDays ?? 30, discardedDays: options.prune?.discardedDays ?? 90, limit: options.prune?.limit ?? 500 };
57
+ if (pruning) {
58
+ if (!ports.compactor) throw new Error("@openlup/outbox: compactor: bind terminal compaction when prune is selected");
59
+ positive("prune.cadenceSeconds", options.prune!.cadenceSeconds, 604800);
60
+ positive("processedDays", retention.processedDays, 3650); positive("discardedDays", retention.discardedDays, 3650); positive("limit", retention.limit, 10000);
61
+ }
62
+ const summary = (reason: string): PlatformJobFinishSummary & { ok: false } => ({ ok: false, checked: 0, updated: 0, failures: 1, skipped: false, reason });
63
+ const run = async (jobName: string, invocation: PlatformJobInvocation, dispatch: boolean): Promise<OutboxScheduleResult> => {
64
+ const claim = await ports.lease.claimJobRun(jobName, invocation, options.leaseSeconds);
65
+ if (!claim.acquired) return { outcome: claim.reason === "job_disabled" ? "disabled" : "skipped", detail: { ok: true, checked: 0, updated: 0, failures: 0, skipped: true, reason: claim.reason } };
66
+ if (!claim.runId) throw new Error("@openlup/outbox: acquired lease has no run id");
67
+ let scope: HandlerScope | undefined; let hookMetadata: Record<string, unknown> = {};
68
+ let detail: OutboxScheduleResult["detail"] = summary("outbox_dispatch_failed");
69
+ let failure = false;
70
+ try {
71
+ try {
72
+ if (dispatch) {
73
+ const context = { runId: claim.runId, invocation };
74
+ hookMetadata = await ports.beforeDispatch?.(context) ?? {};
75
+ const built = await ports.buildHandlers(context);
76
+ scope = "handlers" in built ? built : { handlers: built };
77
+ const actual = scope.handlers;
78
+ if (actual.size !== descriptors.length || descriptors.some((d) => actual.get(d.eventType)?.eventType !== d.eventType || actual.get(d.eventType)?.timeoutMs !== d.timeoutMs)) throw new Error("outbox_registry_descriptor_mismatch");
79
+ detail = await runOutboxDispatchWorker({ store: ports.store, registry: actual, config, knownEventTypes, diagnostics: ports.diagnostics, clock: ports.clock, logger });
80
+ } else {
81
+ const count = await ports.compactor!.compact(retention);
82
+ detail = { ok: true, checked: count, updated: count, failures: 0, skipped: false };
83
+ }
84
+ } catch (error) {
85
+ failure = true;
86
+ detail = summary(message(error));
87
+ logger.error("[outbox-dispatch] schedule_failed", message(error));
88
+ }
89
+ // Reserved run identity and worker counters override hook metadata.
90
+ const extra = { ...hookMetadata, ...detail, runId: claim.runId };
91
+ const finishApplied = await ports.lease.finishJobRun(jobName, claim.runId, invocation, failure ? "failed" : "success", detail, extra);
92
+ return { outcome: failure || !finishApplied ? "failed" : detail.skipped ? "skipped" : "success", detail, finishApplied };
93
+ } catch (error) {
94
+ logger.error("[outbox-dispatch] finish_job_run_failed", message(error));
95
+ throw error;
96
+ } finally {
97
+ try { await scope?.close?.(); } catch (error) { logger.error("[outbox-dispatch] handler_scope_close_failed", message(error)); }
98
+ }
99
+ };
100
+ const schedules = [{ id: "outbox-dispatch", cadenceSeconds: options.cadenceSeconds, run: (invocation: PlatformJobInvocation) => run("outbox-dispatch", invocation, true) },
101
+ ...(pruning ? [{ id: "outbox-prune", cadenceSeconds: options.prune!.cadenceSeconds, run: (invocation: PlatformJobInvocation) => run("outbox-prune", invocation, false) }] : [])];
102
+ return { handlers: descriptors, schedules, routes: [], manifest: {
103
+ name: metadata.name, version: metadata.version, kind: "rail", emits: [], handles: descriptors.map((d) => d.eventType),
104
+ schedules: schedules.map(({id,cadenceSeconds}) => ({id,cadenceSeconds})), routes: [], requiredPorts: ["store", "lease", ...(pruning ? ["compactor"] : [])], env: [], requiredSchema: options.requiredSchema,
105
+ } };
106
+ }
107
+ function message(error: unknown): string { return (error instanceof Error ? error.message : String(error)).slice(0,180); }
package/src/index.ts ADDED
@@ -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";
@@ -0,0 +1,76 @@
1
+ import type { SqlExecutor, RequiredSchema } from "@openlup/core/readiness";
2
+ import type { OutboxStore, OutboxEventRow, OutboxMarkFailedStatus } from "./contracts.js";
3
+ import type { CompactionPort } from "./factory.js";
4
+ /** @beta These identities describe the shipped default adapter only. */
5
+ export const POSTGRES_OUTBOX_SCHEMA: RequiredSchema = {
6
+ version: "outbox-1", migration: "sql/0001_outbox.sql", objects: [
7
+ { kind: "table", name: "public.outbox_events" }, { kind: "table", name: "public.outbox_dormant_event_types" },
8
+ ...["id", "created_at", "available_at", "processed_at", "aggregate_type", "aggregate_id", "event_type", "idempotency_key", "status", "attempts", "payload", "error", "metadata"].map((name) => ({ kind: "column" as const, name: `public.outbox_events.${name}` })),
9
+ { kind: "function", name: "public.outbox_claim_batch", signature: "text[],text[],integer,integer,integer" },
10
+ { kind: "function", name: "public.outbox_mark_processed", signature: "uuid,text,jsonb" },
11
+ { kind: "function", name: "public.outbox_mark_failed", signature: "uuid,text,text,text,integer,integer,integer,integer" },
12
+ { kind: "function", name: "public.outbox_release_unprocessed", signature: "uuid[],text[],integer" },
13
+ { kind: "function", name: "pg_catalog.pg_input_is_valid", signature: "text,text" },
14
+ ],
15
+ };
16
+ /** @beta Supply an autocommit executor or a per-operation transaction that commits before resolve.
17
+ * Never pass a producer transaction or retain an operation transaction across handler I/O. */
18
+ export function createPostgresOutboxStore(executor: SqlExecutor): OutboxStore {
19
+ const scalar = async (text: string, values: readonly unknown[]): Promise<unknown> => {
20
+ const { rows } = await executor.query(text, values);
21
+ if (rows.length !== 1 || !Object.hasOwn(rows[0], "value")) throw new Error("outbox_store_invalid_scalar");
22
+ return rows[0].value;
23
+ };
24
+ return {
25
+ async claimBatch(input) {
26
+ 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,
27
+ to_char(available_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as available_at,
28
+ to_char(processed_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as processed_at,
29
+ aggregate_type, aggregate_id::text, event_type, idempotency_key, status, attempts, payload, error, metadata
30
+ from public.outbox_claim_batch($1::text[], $2::text[], $3::integer, $4::integer, $5::integer)`,
31
+ [input.eventTypes, input.knownEventTypes ?? [], input.batchSize, input.visibilitySeconds, input.maxAttempts]);
32
+ return rows as unknown as OutboxEventRow[];
33
+ },
34
+ async markProcessed(input) {
35
+ 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 ?? {})]);
36
+ if (typeof value !== "boolean") throw new Error("outbox_mark_processed_invalid_response");
37
+ return { applied: value };
38
+ },
39
+ async markFailed(input) {
40
+ 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]);
41
+ if (!["failed","discarded","snoozed","missed"].includes(String(value))) throw new Error("outbox_mark_failed_unexpected_status");
42
+ return { status: value as OutboxMarkFailedStatus };
43
+ },
44
+ async releaseUnprocessed(items, delaySeconds) {
45
+ if (!items.length) 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) throw new Error("outbox_release_invalid_response");
48
+ return value;
49
+ },
50
+ async hasPendingEventType(eventType) {
51
+ 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]);
52
+ if (typeof value !== "boolean") throw new Error("outbox_pending_invalid_response");
53
+ return value;
54
+ },
55
+ };
56
+ }
57
+ /** @beta Compacts terminal payload/error, retaining the event and its unique key. */
58
+ export function createPostgresOutboxCompactor(executor: SqlExecutor): CompactionPort {
59
+ return { async compact(input) {
60
+ const { rows } = await executor.query(`with targets as (
61
+ select id from public.outbox_events
62
+ where metadata->>'retentionCompactedAt' is null and (
63
+ (status = 'processed' and coalesce(processed_at, created_at) < now() - make_interval(days => $1::integer))
64
+ or (status = 'discarded' and case when pg_catalog.pg_input_is_valid(metadata->>'discardedAt', 'timestamp with time zone')
65
+ then (metadata->>'discardedAt')::timestamptz else null end < now() - make_interval(days => $2::integer)))
66
+ order by created_at, id limit $3::integer for update skip locked
67
+ ), compacted as (
68
+ update public.outbox_events e set payload = '{}'::jsonb, error = null,
69
+ metadata = e.metadata || jsonb_build_object('retentionCompactedAt', now(), 'retentionCompactedBy', 'outbox_compact_terminal', 'retentionCompactedStatus', e.status)
70
+ from targets t where e.id = t.id returning e.id
71
+ ) select count(*)::integer as value from compacted`, [input.processedDays,input.discardedDays,input.limit]);
72
+ const value = rows[0]?.value;
73
+ if (rows.length !== 1 || typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error("outbox_compaction_invalid_response");
74
+ return value;
75
+ } };
76
+ }
@@ -0,0 +1,49 @@
1
+ import type { OutboxHandler, OutboxHandlerRegistry } from "./contracts.js";
2
+ export function claimAllowlist(registry: OutboxHandlerRegistry): string[] {
3
+ return [...registry.keys()];
4
+ }
5
+
6
+ export function uniqueRegistry(handlers: readonly OutboxHandler[]): OutboxHandlerRegistry {
7
+ const registry = new Map<string, OutboxHandler>();
8
+ for (const handler of handlers) {
9
+ const existing = registry.get(handler.eventType);
10
+ registry.set(handler.eventType, existing ? composeHandlers(existing, handler) : handler);
11
+ }
12
+ return registry;
13
+ }
14
+
15
+ // Preserve top-level delivery observations, with first-component precedence.
16
+ const DELIVERY_LIFECYCLE_METADATA_KEYS = ["resendId", "providerMessageId", "skipped", "dedupe"] as const;
17
+
18
+ function hoistDeliveryLifecycleKeys(detail: Record<string, unknown> | undefined): Record<string, unknown> {
19
+ if (!detail) return {};
20
+ const hoisted: Record<string, unknown> = {};
21
+ for (const key of DELIVERY_LIFECYCLE_METADATA_KEYS) {
22
+ if (detail[key] !== undefined) hoisted[key] = detail[key];
23
+ }
24
+ return hoisted;
25
+ }
26
+
27
+ export function composeHandlers(first: OutboxHandler, second: OutboxHandler): OutboxHandler {
28
+ return {
29
+ eventType: first.eventType,
30
+ timeoutMs: first.timeoutMs + second.timeoutMs,
31
+ async handle(row, signal) {
32
+ const firstOutcome = await first.handle(row, signal);
33
+ if (firstOutcome.kind !== "processed") return firstOutcome;
34
+ const secondOutcome = await second.handle(row, signal);
35
+ if (secondOutcome.kind !== "processed") return secondOutcome;
36
+ return {
37
+ kind: "processed",
38
+ detail: {
39
+ // Hoist second first, then first, so the primary (email) handler wins on
40
+ // the rare chance both emit a lifecycle key.
41
+ ...hoistDeliveryLifecycleKeys(secondOutcome.detail),
42
+ ...hoistDeliveryLifecycleKeys(firstOutcome.detail),
43
+ first: firstOutcome.detail ?? {},
44
+ second: secondOutcome.detail ?? {},
45
+ },
46
+ };
47
+ },
48
+ };
49
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { OutboxStore, OutboxEventRow } from "./contracts.js";
2
+ /** @beta Framework-free refusal suite. The caller supplies a real processing row. */
3
+ export async function assertOutboxStoreFences(store: OutboxStore, processing: OutboxEventRow): Promise<void> {
4
+ const token = processing.metadata.claimToken;
5
+ if (processing.status !== "processing" || typeof token !== "string" || !token) throw new Error("conformance_requires_processing_row");
6
+ if ((await store.markProcessed({ eventId: processing.id, claimToken: `stale-${token}` })).applied) throw new Error("conformance_stale_processed_applied");
7
+ if ((await store.markFailed({ eventId: processing.id, claimToken: `stale-${token}`, error: "synthetic", outcome: "retry", baseDelaySeconds: 5, maxDelaySeconds: 10, maxAttempts: 3, snoozeSeconds: 60 })).status !== "missed") throw new Error("conformance_stale_failure_applied");
8
+ if (await store.releaseUnprocessed([{ eventId: processing.id, claimToken: `stale-${token}` }], 0) !== 0) throw new Error("conformance_stale_release_applied");
9
+ }
10
+ /** @beta Synthetic envelope; never use for deployed queue observations. */
11
+ export function syntheticOutboxRow(overrides: Partial<OutboxEventRow> = {}): OutboxEventRow {
12
+ 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 };
13
+ }
package/src/trace.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { OutboxHandler, OutboxEventRow, OutboxHandlerOutcome, OutboxHandlerExecutionContext } from "./contracts.js";
2
+ const SAFE_MESSAGE_MAX_CHARS = 300;
3
+ export class OutboxHandlerExecutionTrace implements OutboxHandlerExecutionContext {
4
+ private lastPhase: string | undefined;
5
+
6
+ setPhase(phase: string): void {
7
+ const sanitized = phase.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 48);
8
+ if (sanitized) this.lastPhase = sanitized;
9
+ }
10
+
11
+ timeoutReason(): string {
12
+ return this.lastPhase ? `outbox_handler_timeout:${this.lastPhase}` : "outbox_handler_timeout";
13
+ }
14
+
15
+ qualifyTimeout(outcome: OutboxHandlerOutcome): OutboxHandlerOutcome {
16
+ return this.lastPhase && outcome.kind === "retry" && outcome.reason === "outbox_handler_timeout"
17
+ ? { ...outcome, reason: this.timeoutReason() }
18
+ : outcome;
19
+ }
20
+ }
21
+
22
+ export async function executeHandler(
23
+ handler: OutboxHandler,
24
+ row: OutboxEventRow,
25
+ ): Promise<OutboxHandlerOutcome> {
26
+ const controller = new AbortController();
27
+ const execution = new OutboxHandlerExecutionTrace();
28
+ let abortTimer: ReturnType<typeof setTimeout> | undefined;
29
+ let backstopTimer: ReturnType<typeof setTimeout> | undefined;
30
+ try {
31
+ // Abort is the primary timeout mechanism (handlers must honor the
32
+ // signal); the race is only a backstop for non-abortable code, firing
33
+ // 500ms later so abort-aware handlers get to settle first.
34
+ const backstop = new Promise<OutboxHandlerOutcome>((resolve) => {
35
+ backstopTimer = setTimeout(
36
+ () => resolve({ kind: "retry", reason: execution.timeoutReason() }),
37
+ handler.timeoutMs + 500,
38
+ );
39
+ });
40
+ abortTimer = setTimeout(() => controller.abort(), handler.timeoutMs);
41
+ const outcome = await Promise.race([handler.handle(row, controller.signal, execution), backstop]);
42
+ return execution.qualifyTimeout(outcome);
43
+ } catch (error) {
44
+ return { kind: "retry", reason: safeMessage(error) };
45
+ } finally {
46
+ if (abortTimer !== undefined) clearTimeout(abortTimer);
47
+ if (backstopTimer !== undefined) clearTimeout(backstopTimer);
48
+ }
49
+ }
50
+
51
+
52
+ function safeMessage(error: unknown): string { return (error instanceof Error ? error.message : String(error)).slice(0, SAFE_MESSAGE_MAX_CHARS); }
@@ -0,0 +1,10 @@
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 function withPayloadSchema<Payload extends Record<string, unknown>>(handler: OutboxHandler, schema: StandardSchemaV1<unknown, Payload>): OutboxHandler {
5
+ return { eventType: handler.eventType, timeoutMs: handler.timeoutMs, async handle(row, signal, execution) {
6
+ const result = await schema["~standard"].validate(row.payload);
7
+ if (result.issues) return { kind: "retry", reason: "outbox_payload_invalid" };
8
+ return handler.handle({ ...row, payload: result.value }, signal, execution);
9
+ } };
10
+ }
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); }