@beignet/core
Version:
Core framework primitives for Beignet
814 lines • 30.7 kB
JavaScript
/**
* @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