UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

814 lines 30.7 kB
/** * @beignet/core/outbox * * Durable outbox primitives for transactionally recording events and jobs that * should be delivered after the owning database transaction commits. */ import { parseEventPayload, } from "../events/index.js"; import { getJobRetryDelayMs, getJobRetryMaxAttempts, parseJobPayload, SINGLE_ATTEMPT_DISPATCH, shouldRetryJob, } from "../jobs/index.js"; import { createProviderInstrumentation, } from "../providers/index.js"; import { captureTraceCarrier, parseTraceCarrier, resolveTracingPort, runWithTracing, } from "../tracing/index.js"; /** * Default lease duration for claimed outbox messages. */ export const DEFAULT_OUTBOX_LEASE_MS = 30_000; /** * Default maximum delivery attempts before a message is dead-lettered. */ export const DEFAULT_OUTBOX_MAX_ATTEMPTS = 3; /** * Default maximum messages returned from outbox admin list calls. */ export const DEFAULT_OUTBOX_ADMIN_LIST_LIMIT = 50; /** * Error thrown when an outbox payload is not JSON serializable. */ export class OutboxSerializationError extends Error { constructor(message) { super(message); this.name = "OutboxSerializationError"; } } /** * Error thrown when an outbox message cannot be resolved through the registry. */ export class OutboxRegistryError extends Error { constructor(message) { super(message); this.name = "OutboxRegistryError"; } } /** * Error thrown when a claimed message cannot be updated with the supplied token. */ export class OutboxClaimError extends Error { /** * Message ID involved in the claim error. */ id; constructor(args) { super(args.message); this.name = "OutboxClaimError"; this.id = args.id; } } /** * Error thrown when an outbox admin operation cannot be completed safely. */ export class OutboxAdminError extends Error { /** * Message ID involved in the admin error, when applicable. */ id; constructor(args) { super(args.message); this.name = "OutboxAdminError"; this.id = args.id; } } function assertNonEmptyString(name, value) { if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`${name} must be a non-empty string`); } } function assertPositiveInteger(name, value) { if (!Number.isInteger(value) || value <= 0) { throw new Error(`${name} must be a positive integer`); } } function assertValidDate(name, value) { if (!(value instanceof Date) || Number.isNaN(value.getTime())) { throw new Error(`${name} must be a valid Date`); } } function cloneDate(value) { return new Date(value.getTime()); } function createId() { if (!globalThis.crypto?.randomUUID) { throw new Error("crypto.randomUUID is required to create outbox IDs."); } return globalThis.crypto.randomUUID(); } function assertJsonValue(value, path = [], seen = new WeakSet()) { const label = path.length > 0 ? path.join(".") : "payload"; if (value === null) return null; if (typeof value === "string" || typeof value === "boolean" || typeof value === "number") { if (typeof value === "number" && !Number.isFinite(value)) { throw new OutboxSerializationError(`Outbox ${label} must be a finite number.`); } return value; } if (Array.isArray(value)) { if (seen.has(value)) { throw new OutboxSerializationError(`Outbox ${label} must be JSON serializable. Circular references are not supported.`); } seen.add(value); try { return value.map((item, index) => assertJsonValue(item, [...path, String(index)], seen)); } finally { seen.delete(value); } } if (typeof value === "object") { if (value instanceof Date) { throw new OutboxSerializationError(`Outbox ${label} must be JSON serializable. Convert Date values to strings before enqueueing.`); } if (seen.has(value)) { throw new OutboxSerializationError(`Outbox ${label} must be JSON serializable. Circular references are not supported.`); } seen.add(value); try { const record = value; const output = {}; for (const key of Object.keys(record)) { const child = record[key]; if (child === undefined) { throw new OutboxSerializationError(`Outbox ${[...path, key].join(".")} cannot be undefined.`); } output[key] = assertJsonValue(child, [...path, key], seen); } return output; } finally { seen.delete(value); } } throw new OutboxSerializationError(`Outbox ${label} must be JSON serializable. Received ${typeof value}.`); } /** * Convert an unknown value to an outbox-safe JSON value. * * Dates, undefined values, functions, non-finite numbers, symbols, and circular * references are rejected so durable adapters can store the payload safely. */ export function toOutboxJsonValue(value) { const jsonValue = assertJsonValue(value); return JSON.parse(JSON.stringify(jsonValue)); } /** * Serialize an unknown delivery error into outbox error metadata. */ export function serializeOutboxError(error) { if (error instanceof Error) { return { name: error.name, message: error.message, stack: error.stack, }; } if (typeof error === "string") { return { message: error }; } return { message: "Unknown outbox delivery error" }; } function copyMessage(message) { return { ...message, availableAt: cloneDate(message.availableAt), claimedAt: message.claimedAt ? cloneDate(message.claimedAt) : null, lockedUntil: message.lockedUntil ? cloneDate(message.lockedUntil) : null, deliveredAt: message.deliveredAt ? cloneDate(message.deliveredAt) : null, createdAt: cloneDate(message.createdAt), updatedAt: cloneDate(message.updatedAt), lastError: message.lastError ? { ...message.lastError } : null, }; } function normalizeStatusFilter(status) { if (status === undefined) return undefined; return new Set(Array.isArray(status) ? status : [status]); } function messageMatchesQuery(message, query = {}) { const statuses = normalizeStatusFilter(query.status); if (statuses && !statuses.has(message.status)) return false; if (query.kind !== undefined && message.kind !== query.kind) return false; if (query.name !== undefined && message.name !== query.name) return false; if (query.updatedBefore !== undefined && message.updatedAt.getTime() >= query.updatedBefore.getTime()) { return false; } if (query.deliveredBefore !== undefined && (message.deliveredAt === null || message.deliveredAt.getTime() >= query.deliveredBefore.getTime())) { return false; } return true; } function compareMessagesForAdminList(left, right) { const updated = right.updatedAt.getTime() - left.updatedAt.getTime(); if (updated !== 0) return updated; const created = right.createdAt.getTime() - left.createdAt.getTime(); if (created !== 0) return created; return right.id.localeCompare(left.id); } function compareMessagesForAdminCleanup(left, right) { const updated = left.updatedAt.getTime() - right.updatedAt.getTime(); if (updated !== 0) return updated; const created = left.createdAt.getTime() - right.createdAt.getTime(); if (created !== 0) return created; return left.id.localeCompare(right.id); } function compareDeliveredMessagesForPrune(left, right) { const delivered = (left.deliveredAt?.getTime() ?? 0) - (right.deliveredAt?.getTime() ?? 0); if (delivered !== 0) return delivered; return compareMessagesForAdminCleanup(left, right); } function resolveAdminListLimit(limit) { const resolved = limit ?? DEFAULT_OUTBOX_ADMIN_LIST_LIMIT; assertPositiveInteger("limit", resolved); return resolved; } function toClaimedMessage(message) { if (message.status !== "claimed" || !message.claimToken || !message.claimedAt || !message.lockedUntil) { throw new OutboxClaimError({ id: message.id, message: `Outbox message "${message.id}" is not claimed.`, }); } return { ...copyMessage(message), status: "claimed", claimToken: message.claimToken, claimedAt: cloneDate(message.claimedAt), lockedUntil: cloneDate(message.lockedUntil), }; } /** * Create a validated pending outbox message. */ export function createOutboxMessage(input, options = {}) { assertNonEmptyString("kind", input.kind); assertNonEmptyString("name", input.name); if (input.id !== undefined) assertNonEmptyString("id", input.id); if (input.maxAttempts !== undefined) { assertPositiveInteger("maxAttempts", input.maxAttempts); } const now = options.now ?? new Date(); const trace = parseTraceCarrier(input.trace); return { id: options.id ?? input.id ?? options.createId?.() ?? createId(), kind: input.kind, name: input.name, payload: toOutboxJsonValue(input.payload), ...(trace ? { trace } : {}), status: "pending", attempts: 0, maxAttempts: input.maxAttempts ?? DEFAULT_OUTBOX_MAX_ATTEMPTS, availableAt: input.availableAt ? cloneDate(input.availableAt) : cloneDate(now), claimedAt: null, lockedUntil: null, claimToken: null, deliveredAt: null, lastError: null, createdAt: cloneDate(now), updatedAt: cloneDate(now), }; } function isEligible(message, now) { if (message.status === "pending") { return message.availableAt.getTime() <= now.getTime(); } return (message.status === "claimed" && message.lockedUntil !== null && message.lockedUntil.getTime() <= now.getTime()); } /** * Create an in-memory outbox for tests and local examples. * * The memory outbox is process-local and not durable. */ export function createMemoryOutbox(storeOptions = {}) { const createStoreId = storeOptions.id ?? createId; const storeNow = storeOptions.now ?? (() => new Date()); const messages = new Map(); function getClaimedOrThrow(id, claimToken) { const message = messages.get(id); if (!message) { throw new OutboxClaimError({ id, message: `Outbox message "${id}" does not exist.`, }); } if (message.status !== "claimed" || message.claimToken !== claimToken) { throw new OutboxClaimError({ id, message: `Outbox message "${id}" is not claimed by this worker.`, }); } return message; } return { get messages() { return [...messages.values()].map(copyMessage); }, async listMessages(options = {}) { const limit = resolveAdminListLimit(options.limit); if (options.updatedBefore) { assertValidDate("updatedBefore", options.updatedBefore); } if (options.deliveredBefore) { assertValidDate("deliveredBefore", options.deliveredBefore); } return [...messages.values()] .filter((message) => messageMatchesQuery(message, options)) .sort(compareMessagesForAdminList) .slice(0, limit) .map(copyMessage); }, async countMessages(options = {}) { if (options.updatedBefore) { assertValidDate("updatedBefore", options.updatedBefore); } if (options.deliveredBefore) { assertValidDate("deliveredBefore", options.deliveredBefore); } return [...messages.values()].filter((message) => messageMatchesQuery(message, options)).length; }, async getMessage(id) { assertNonEmptyString("id", id); const message = messages.get(id); return message ? copyMessage(message) : null; }, async requeueMessage(input) { assertNonEmptyString("id", input.id); const message = messages.get(input.id); if (!message) { throw new OutboxAdminError({ id: input.id, message: `Outbox message "${input.id}" does not exist.`, }); } if (message.status !== "deadLettered") { throw new OutboxAdminError({ id: input.id, message: `Outbox message "${input.id}" is not dead-lettered.`, }); } const now = input.now ?? storeNow(); const availableAt = input.availableAt ?? now; assertValidDate("now", now); assertValidDate("availableAt", availableAt); message.status = "pending"; message.availableAt = cloneDate(availableAt); message.claimToken = null; message.claimedAt = null; message.lockedUntil = null; if (input.resetAttempts) message.attempts = 0; message.updatedAt = cloneDate(now); return copyMessage(message); }, async purgeDeadLettered(input = {}) { const limit = input.limit === undefined ? undefined : resolveAdminListLimit(input.limit); if (input.before) assertValidDate("before", input.before); const candidates = [...messages.values()] .filter((message) => message.status === "deadLettered" && (input.before === undefined || message.updatedAt.getTime() < input.before.getTime())) .sort(compareMessagesForAdminCleanup) .slice(0, limit); for (const message of candidates) { messages.delete(message.id); } return { deleted: candidates.length }; }, async pruneDelivered(input) { assertValidDate("before", input.before); const limit = input.limit === undefined ? undefined : resolveAdminListLimit(input.limit); const candidates = [...messages.values()] .filter((message) => message.status === "delivered" && message.deliveredAt !== null && message.deliveredAt.getTime() < input.before.getTime()) .sort(compareDeliveredMessagesForPrune) .slice(0, limit); for (const message of candidates) { messages.delete(message.id); } return { deleted: candidates.length }; }, async enqueue(input) { const message = createOutboxMessage(input, { createId: createStoreId, now: storeNow(), }); if (messages.has(message.id)) { throw new Error(`Outbox message "${message.id}" already exists.`); } messages.set(message.id, message); return copyMessage(message); }, async claimBatch(options) { assertPositiveInteger("limit", options.limit); const now = options.now ?? storeNow(); const leaseMs = options.leaseMs ?? DEFAULT_OUTBOX_LEASE_MS; assertPositiveInteger("leaseMs", leaseMs); const lockedUntil = new Date(now.getTime() + leaseMs); const claimed = []; const eligible = [...messages.values()] .filter((message) => isEligible(message, now)) .sort((a, b) => { const available = a.availableAt.getTime() - b.availableAt.getTime(); if (available !== 0) return available; return a.createdAt.getTime() - b.createdAt.getTime(); }) .slice(0, options.limit); for (const message of eligible) { message.status = "claimed"; message.attempts += 1; message.claimToken = createStoreId(); message.claimedAt = cloneDate(now); message.lockedUntil = cloneDate(lockedUntil); message.updatedAt = cloneDate(now); claimed.push(toClaimedMessage(message)); } return claimed; }, async markDelivered(input) { assertNonEmptyString("id", input.id); assertNonEmptyString("claimToken", input.claimToken); const message = getClaimedOrThrow(input.id, input.claimToken); const now = input.now ?? storeNow(); message.status = "delivered"; message.deliveredAt = cloneDate(now); message.claimToken = null; message.claimedAt = null; message.lockedUntil = null; message.updatedAt = cloneDate(now); }, async markFailed(input) { assertNonEmptyString("id", input.id); assertNonEmptyString("claimToken", input.claimToken); const message = getClaimedOrThrow(input.id, input.claimToken); const now = input.now ?? storeNow(); message.status = input.deadLetter ? "deadLettered" : "pending"; message.lastError = serializeOutboxError(input.error); message.availableAt = input.retryAt ? cloneDate(input.retryAt) : cloneDate(now); message.claimToken = null; message.claimedAt = null; message.lockedUntil = null; message.updatedAt = cloneDate(now); }, clear() { messages.clear(); }, }; } function mapDefinitions(kind, defs) { const map = new Map(); for (const def of defs) { if (map.has(def.name)) { throw new OutboxRegistryError(`Duplicate ${kind} definition "${def.name}" in outbox registry.`); } map.set(def.name, def); } return map; } /** * Define the events and jobs that an outbox drain worker can deliver. * * Duplicate names throw because message delivery resolves by name. */ export function defineOutboxRegistry(input) { return { events: mapDefinitions("event", input.events ?? []), jobs: mapDefinitions("job", input.jobs ?? []), }; } /** * Validate an event payload and enqueue it as an outbox message. */ export async function enqueueEvent(outbox, event, payload, options = {}) { await parseEventPayload(event, payload); const trace = parseTraceCarrier(options.trace) ?? captureTraceCarrier(options.tracing); return outbox.enqueue({ id: options.id, kind: "event", name: event.name, payload: toOutboxJsonValue(payload), trace, availableAt: options.availableAt, maxAttempts: options.maxAttempts, }); } /** * Validate a job payload and enqueue it as an outbox message. */ export async function enqueueJob(outbox, job, payload, options = {}) { await parseJobPayload(job, payload); const trace = parseTraceCarrier(options.trace) ?? captureTraceCarrier(options.tracing); return outbox.enqueue({ id: options.id, kind: "job", name: job.name, payload: toOutboxJsonValue(payload), trace, availableAt: options.availableAt, maxAttempts: options.maxAttempts ?? getJobRetryMaxAttempts(job.retry), }); } /** * Create a domain event recorder that writes events to the outbox. */ export function createOutboxEventRecorder(outbox, options = {}) { return { async record(event, payload, publishOptions) { await enqueueEvent(outbox, event, payload, { ...options, trace: publishOptions?.trace ?? options.trace, }); }, }; } /** * Create a job dispatcher that writes jobs to the outbox. */ export function createOutboxJobDispatcher(outbox, options = {}) { return { async dispatch(job, payload, dispatchOptions) { await enqueueJob(outbox, job, payload, { ...options, trace: dispatchOptions?.trace ?? options.trace, }); }, }; } function resolveRetryDelayMs(options, message, error, now) { if (typeof options.retryDelayMs === "function") { const delay = options.retryDelayMs({ message, error, now }); assertPositiveInteger("retryDelayMs", delay); return delay; } if (options.retryDelayMs !== undefined) { assertPositiveInteger("retryDelayMs", options.retryDelayMs); return options.retryDelayMs; } if (message.kind === "job") { const job = options.registry.jobs.get(message.name); if (job?.retry) { return getJobRetryDelayMs(job.retry, { attempt: message.attempts, error, jobName: message.name, }); } } return Math.min(60_000, 1000 * 2 ** Math.max(0, message.attempts - 1)); } function shouldRetryOutboxMessage(options, message, error) { if (message.kind !== "job") { return message.attempts < message.maxAttempts; } const job = options.registry.jobs.get(message.name); return shouldRetryJob(job?.retry, { attempt: message.attempts, error, jobName: message.name, maxAttempts: message.maxAttempts, }); } function outboxInstrumentationDetails(message, details) { return { attempt: message.attempts, maxAttempts: message.maxAttempts, messageId: message.id, messageKind: message.kind, messageName: message.name, ...details, }; } async function deliverOutboxMessage(options, message, trace) { if (message.kind === "event") { if (!options.eventBus) { throw new OutboxRegistryError(`Cannot deliver event "${message.name}" without an event bus.`); } const event = options.registry.events.get(message.name); if (!event) { throw new OutboxRegistryError(`Outbox registry does not include event "${message.name}".`); } await parseEventPayload(event, message.payload); await options.eventBus.publish(event, message.payload, trace ? { trace } : undefined); return; } if (!options.jobs) { throw new OutboxRegistryError(`Cannot deliver job "${message.name}" without a job dispatcher.`); } const job = options.registry.jobs.get(message.name); if (!job) { throw new OutboxRegistryError(`Outbox registry does not include job "${message.name}".`); } await parseJobPayload(job, message.payload); // The drain owns execution retries: failed deliveries are rescheduled with // the job's own policy via markFailed/retryAt. When the dispatcher exposes // a single-attempt dispatch (the inline dispatcher does), use it so the // retry policy runs in exactly one layer. Durable providers do not expose // it — for them dispatch is an enqueue and the queue owns execution. const singleAttempt = options.jobs[SINGLE_ATTEMPT_DISPATCH]; if (singleAttempt) { await singleAttempt(job, message.payload, { attempt: message.attempts, maxAttempts: message.maxAttempts, trace, }); return; } await options.jobs.dispatch(job, message.payload, trace ? { trace } : undefined); } /** * Claim and deliver one batch of outbox messages. * * This does not loop forever; production workers should call it on their own * polling cadence. Event and job messages require matching registry entries. * Failed messages are retried with backoff until `maxAttempts`, then * dead-lettered. */ export async function drainOutbox(options) { const batchSize = options.batchSize ?? 100; assertPositiveInteger("batchSize", batchSize); const instrumentation = createProviderInstrumentation(options.instrumentation, { providerName: "outbox", watcher: "outbox", }); const jobInstrumentation = createProviderInstrumentation(options.instrumentation, { providerName: "outbox", watcher: "jobs", }); const tracing = resolveTracingPort(options.instrumentation); const now = options.now ?? new Date(); const messages = await options.outbox.claimBatch({ limit: batchSize, now, leaseMs: options.leaseMs, }); const result = { claimed: messages.length, delivered: 0, retried: 0, deadLettered: 0, }; for (const message of messages) { try { const parentTrace = parseTraceCarrier(message.trace); const traceAttributes = { "beignet.outbox.message_kind": message.kind, "beignet.outbox.message_name": message.name, }; await runWithTracing(tracing, { name: `beignet.outbox deliver ${message.name}`, type: "outbox", kind: "consumer", parent: parentTrace, attributes: traceAttributes, metricAttributes: traceAttributes, }, (span) => deliverOutboxMessage(options, message, captureTraceCarrier(span?.context ?? parentTrace))); await options.outbox.markDelivered({ id: message.id, claimToken: message.claimToken, now, }); instrumentation.record({ type: "outbox", ...options.instrumentationContext, messageId: message.id, messageKind: message.kind, messageName: message.name, status: "delivered", details: outboxInstrumentationDetails(message), }); result.delivered += 1; } catch (error) { try { await options.onError?.(error, message); } catch { // Preserve the delivery failure path so the message is retried or // dead-lettered even if the observer fails. } const shouldRetry = shouldRetryOutboxMessage(options, message, error); const deadLetter = !shouldRetry; const retryDelayMs = deadLetter ? 0 : resolveRetryDelayMs(options, message, error, now); try { await options.outbox.markFailed({ id: message.id, claimToken: message.claimToken, error, deadLetter, now, retryAt: deadLetter ? undefined : new Date(now.getTime() + retryDelayMs), }); } catch (settlementError) { try { await options.onSettlementError?.(settlementError, message, error); } catch { // Preserve the settlement failure when its observer also fails. } instrumentation.custom({ name: "outbox.settlement.failed", label: "Outbox settlement failed", summary: `Could not settle failed ${message.kind} "${message.name}"`, details: outboxInstrumentationDetails(message, { deliveryError: serializeOutboxError(error), settlementError: serializeOutboxError(settlementError), }), }); continue; } if (deadLetter) { try { await options.onDeadLetter?.(error, message); } catch { // Dead-letter observers must not change the settled message state. } instrumentation.record({ type: "outbox", ...options.instrumentationContext, messageId: message.id, messageKind: message.kind, messageName: message.name, status: "deadLettered", details: outboxInstrumentationDetails(message, { error: serializeOutboxError(error), }), }); if (message.kind === "job") { jobInstrumentation.record({ type: "job", ...options.instrumentationContext, jobName: message.name, status: "deadLettered", details: outboxInstrumentationDetails(message, { error: serializeOutboxError(error), }), }); } result.deadLettered += 1; } else { const retryAt = new Date(now.getTime() + retryDelayMs).toISOString(); instrumentation.record({ type: "outbox", ...options.instrumentationContext, messageId: message.id, messageKind: message.kind, messageName: message.name, status: "retryScheduled", details: outboxInstrumentationDetails(message, { retryDelayMs, retryAt, error: serializeOutboxError(error), }), }); if (message.kind === "job") { jobInstrumentation.record({ type: "job", ...options.instrumentationContext, jobName: message.name, status: "retryScheduled", details: outboxInstrumentationDetails(message, { retryDelayMs, retryAt, error: serializeOutboxError(error), }), }); } result.retried += 1; } } } return result; } //# sourceMappingURL=index.js.map