UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

782 lines 26.8 kB
import { runWithResolvedTracingContext } from "../tracing/execution.js"; import { parseTraceCarrier, } from "../tracing/index.js"; /** * Retry helper namespace for job definitions. */ export const retry = { /** * Disable retries. The first failure is terminal. */ none() { return { strategy: "none", attempts: 1, }; }, /** * Retry with the same delay between attempts. */ fixed(options) { return validateJobRetryOptions({ strategy: "fixed", attempts: options.attempts, delay: options.delay, retryIf: options.retryIf, }); }, /** * Retry with exponential backoff. */ exponential(options) { return validateJobRetryOptions({ strategy: "exponential", attempts: options.attempts, initialDelay: options.initialDelay, maxDelay: options.maxDelay, factor: options.factor, jitter: options.jitter, retryIf: options.retryIf, }); }, }; const JOB_TRANSPORT_ENVELOPE_TYPE = "beignet.job"; const JOB_TRANSPORT_ENVELOPE_VERSION = 1; /** * Wrap a job payload with transport metadata when a trace is present. * Payloads without metadata retain their legacy wire shape. */ export function createJobTransportEnvelope(payload, options) { const trace = parseTraceCarrier(options?.trace); if (!trace) return payload; return { __beignet: { type: JOB_TRANSPORT_ENVELOPE_TYPE, version: JOB_TRANSPORT_ENVELOPE_VERSION, trace, }, payload, }; } /** * Decode a Beignet job transport envelope while accepting legacy raw payloads. * Unknown or malformed trace metadata is ignored without dropping the payload. */ export function parseJobTransportEnvelope(value) { if (typeof value !== "object" || value === null || !("payload" in value)) { return { payload: value }; } const metadata = "__beignet" in value ? value.__beignet : undefined; if (typeof metadata !== "object" || metadata === null || !("type" in metadata) || metadata.type !== JOB_TRANSPORT_ENVELOPE_TYPE || !("version" in metadata) || metadata.version !== JOB_TRANSPORT_ENVELOPE_VERSION) { return { payload: value }; } const trace = "trace" in metadata ? parseTraceCarrier(metadata.trace) : undefined; return { payload: value.payload, ...(trace ? { trace } : {}), }; } /** * Well-known symbol under which the inline dispatcher exposes a * single-attempt dispatch. Delivery systems that own execution retries * themselves — the outbox drain — call it instead of `dispatch(...)` so a * job's retry policy runs in exactly one layer. Registered with `Symbol.for` * so multiple core copies in one process agree on the key. */ export const SINGLE_ATTEMPT_DISPATCH = Symbol.for("beignet.jobs.singleAttemptDispatch"); /** * Error thrown when job payload validation fails. */ export class JobValidationError extends Error { /** * Raw Standard Schema validation issues. */ issues; constructor(args) { super(`Job "${args.name}" payload validation failed: ${formatIssues(args.issues)}`); this.name = "JobValidationError"; this.issues = args.issues; } } /** * Error thrown when a job handler exceeds its declared timeout. */ export class JobTimeoutError extends Error { /** * Stable job name that timed out. */ jobName; /** * Timeout in milliseconds. */ timeoutMs; constructor(args) { super(`Job "${args.jobName}" timed out after ${args.timeoutMs}ms.`); this.name = "JobTimeoutError"; this.jobName = args.jobName; this.timeoutMs = args.timeoutMs; } } /** * Error thrown when an execution lease hook is configured to fail on an * unavailable lease. */ export class JobExecutionLeaseUnavailableError extends Error { /** * Stable job name whose execution lease was unavailable. */ jobName; /** * Logical lease key returned by the hook configuration. */ key; /** * Concrete lock key passed to `LocksPort`. */ lockKey; /** * Acquisition failure reason returned by `LocksPort`. */ reason; constructor(args) { super(`Job "${args.jobName}" execution lease "${args.key}" is unavailable (${args.reason}).`); this.name = "JobExecutionLeaseUnavailableError"; this.jobName = args.jobName; this.key = args.key; this.lockKey = args.lockKey; this.reason = args.reason; } } function formatPath(path) { if (!path?.length) return ""; return path .map((segment) => typeof segment === "object" && segment !== null && "key" in segment ? String(segment.key) : String(segment)) .join("."); } function formatIssues(issues) { return issues .map((issue) => { const path = formatPath(issue.path); return path ? `${path}: ${issue.message}` : issue.message; }) .join("; "); } function assertPositiveInteger(name, value) { if (!Number.isInteger(value) || value <= 0) { throw new Error(`${name} must be a positive integer`); } } function assertPositiveNumber(name, value) { if (!Number.isFinite(value) || value <= 0) { throw new Error(`${name} must be a positive number`); } } function assertNonEmptyString(name, value) { if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`${name} must be a non-empty string`); } } function durationToMs(name, value) { if (typeof value === "number") { assertPositiveInteger(name, value); return value; } if (typeof value !== "string") { throw new Error(`${name} must be a positive millisecond value or duration string like "500ms", "30s", "5m", or "1h".`); } const match = /^(\d+)(ms|s|m|h)$/.exec(value); if (!match) { throw new Error(`${name} must be a positive millisecond value or duration string like "500ms", "30s", "5m", or "1h".`); } const amount = Number(match[1]); assertPositiveInteger(name, amount); switch (match[2]) { case "ms": return amount; case "s": return amount * 1000; case "m": return amount * 60_000; case "h": return amount * 3_600_000; default: throw new Error(`${name} has an unsupported duration unit.`); } } function isRecord(value) { return typeof value === "object" && value !== null; } function validateJobUniqueOptions(options) { if (!isRecord(options)) { throw new Error("unique must be an object or resolver function"); } assertNonEmptyString("unique.key", options.key); durationToMs("unique.ttl", options.ttl); return options; } function validateJobUniqueConfig(config) { if (config === undefined) return undefined; if (typeof config === "function") return config; return validateJobUniqueOptions(config); } function validateJobTimeout(timeout) { if (timeout === undefined) return undefined; durationToMs("timeout", timeout); return timeout; } const VALIDATED_RETRY_OPTIONS = Symbol("beignet.jobs.validatedRetryOptions"); function validateJobRetryOptions(options) { // Options returned by this function are branded so repeated validation — // per attempt in the retry helpers — short-circuits instead of re-parsing // duration strings and reallocating. if (options[VALIDATED_RETRY_OPTIONS]) { return options; } const strategy = options.strategy ?? "exponential"; if (!["none", "fixed", "exponential"].includes(strategy)) { throw new Error("retry.strategy must be none, fixed, or exponential"); } const attempts = options.attempts ?? (strategy === "none" ? 1 : undefined); if (attempts === undefined) { throw new Error("retry.attempts is required"); } assertPositiveInteger("retry.attempts", attempts); if (strategy === "none" && attempts !== 1) { throw new Error("retry.none() must use exactly one attempt"); } if (strategy === "fixed") { if (options.delay === undefined) { throw new Error("retry.delay is required for fixed retry policies"); } durationToMs("retry.delay", options.delay); } if (strategy === "exponential") { if (options.initialDelay !== undefined) { durationToMs("retry.initialDelay", options.initialDelay); } if (options.maxDelay !== undefined) { durationToMs("retry.maxDelay", options.maxDelay); } if (options.factor !== undefined) { assertPositiveNumber("retry.factor", options.factor); } } const validated = { ...options, strategy, attempts, }; Object.defineProperty(validated, VALIDATED_RETRY_OPTIONS, { value: true, enumerable: false, }); return validated; } /** * Return the maximum total attempts configured by a retry policy. */ export function getJobRetryMaxAttempts(options) { return options ? validateJobRetryOptions(options).attempts : undefined; } /** * Return whether a failed job attempt should be retried. */ export function shouldRetryJob(options, args) { if (!options) return args.attempt < args.maxAttempts; const retryOptions = validateJobRetryOptions(options); const maxAttempts = Math.min(args.maxAttempts, retryOptions.attempts ?? args.maxAttempts); if (retryOptions.strategy === "none") return false; if (args.attempt >= maxAttempts) return false; return retryOptions.retryIf?.({ ...args, maxAttempts }) ?? true; } /** * Compute the next retry delay in milliseconds for a failed job attempt. */ export function getJobRetryDelayMs(options, args) { const retryOptions = options ? validateJobRetryOptions(options) : retry.exponential({ attempts: 3 }); let delayMs; if (retryOptions.strategy === "fixed") { delayMs = durationToMs("retry.delay", retryOptions.delay ?? "1s"); } else if (retryOptions.strategy === "none") { delayMs = 0; } else { const initialDelayMs = durationToMs("retry.initialDelay", retryOptions.initialDelay ?? "1s"); const maxDelayMs = durationToMs("retry.maxDelay", retryOptions.maxDelay ?? "1m"); const factor = retryOptions.factor ?? 2; delayMs = Math.min(maxDelayMs, initialDelayMs * factor ** Math.max(0, args.attempt - 1)); } if (retryOptions.jitter && delayMs > 0) { delayMs = Math.ceil(delayMs * (0.5 + Math.random())); if (retryOptions.strategy === "exponential") { delayMs = Math.min(delayMs, durationToMs("retry.maxDelay", retryOptions.maxDelay ?? "1m")); } } return delayMs; } /** * Return the execution timeout in milliseconds configured by a job. */ export function getJobTimeoutMs(job) { return job.timeout === undefined ? undefined : durationToMs("timeout", job.timeout); } function jobExecutionLeaseLockKey(jobName, key, keyPrefix = "jobs:lease") { return `${keyPrefix}:${jobName}:${key}`; } function validateExecutionLeaseKey(key) { assertNonEmptyString("executionLease.key", key); return key; } function abortReason(signal) { return signal.reason ?? new Error("Job execution aborted."); } async function resolveExecutionLeaseLocks(locks, args) { return typeof locks === "function" ? await locks(args) : locks; } async function resolveExecutionLeaseMetadata(metadata, args) { return typeof metadata === "function" ? await metadata(args) : metadata; } async function releaseExecutionLease(lease) { try { await lease.release(); } catch { // The lease TTL is the correctness boundary. Release is best effort so a // provider outage after handler side effects does not turn success into a // retry. } } async function handleUnavailableExecutionLease(behavior, args) { if (behavior === undefined || behavior === "skip") return; if (behavior === "throw") { throw new JobExecutionLeaseUnavailableError({ jobName: args.job.name, key: args.key, lockKey: args.lockKey, reason: args.reason, }); } await behavior(args); } /** * Create a job hook that prevents overlapping handler attempts for the same * logical execution key. * * The hook uses one bounded `LocksPort.acquire(...)` call and never starts * renewal loops, so it can run in serverless entrypoints as long as `locks` * points at shared storage. `ttl` is the real safety boundary when a runtime * terminates before best-effort release runs. */ export function createJobExecutionLeaseHook(options) { const ttlMs = durationToMs("executionLease.ttl", options.ttl); const waitMs = options.wait === undefined ? undefined : durationToMs("executionLease.wait", options.wait); const retryDelayMs = options.retryDelay === undefined ? undefined : durationToMs("executionLease.retryDelay", options.retryDelay); const keyPrefix = options.keyPrefix ?? "jobs:lease"; assertNonEmptyString("executionLease.keyPrefix", keyPrefix); if (typeof options.key === "string") { validateExecutionLeaseKey(options.key); } return async (args, next) => { if (args.signal.aborted) throw abortReason(args.signal); const key = validateExecutionLeaseKey(typeof options.key === "function" ? await options.key(args) : options.key); if (args.signal.aborted) throw abortReason(args.signal); const lockKey = jobExecutionLeaseLockKey(args.job.name, key, keyPrefix); const locks = await resolveExecutionLeaseLocks(options.locks, args); if (args.signal.aborted) throw abortReason(args.signal); const metadata = await resolveExecutionLeaseMetadata(options.metadata, args); if (args.signal.aborted) throw abortReason(args.signal); const result = await locks.acquire(lockKey, { ttlMs, ...(waitMs === undefined ? {} : { waitMs }), ...(retryDelayMs === undefined ? {} : { retryDelayMs }), metadata: { ...(metadata ?? {}), capability: "jobs", jobName: args.job.name, leaseKey: key, attempt: args.attempt ?? null, maxAttempts: args.maxAttempts ?? null, }, }); if (!result.acquired) { if (args.signal.aborted) throw abortReason(args.signal); await handleUnavailableExecutionLease(options.onUnavailable, { ...args, key, lockKey, reason: result.reason, }); return; } if (args.signal.aborted) { await releaseExecutionLease(result.lease); throw abortReason(args.signal); } try { await next(); } finally { await releaseExecutionLease(result.lease); } }; } async function parsePayload(schema, input, args) { const result = await schema["~standard"].validate(input); if (result.issues?.length) { throw new JobValidationError({ name: args.name, issues: result.issues, }); } if ("value" in result) { return result.value; } throw new Error("Invalid Standard Schema result: missing value"); } async function resolveCtx(ctx) { if (typeof ctx === "function") { return ctx(); } return ctx; } function defineJobImpl(name, options) { const retryOptions = options.retry ? validateJobRetryOptions(options.retry) : undefined; const uniqueOptions = validateJobUniqueConfig(options.unique); const timeout = validateJobTimeout(options.timeout); return { kind: "job", name, payload: options.payload, description: options.description, retry: retryOptions, unique: uniqueOptions, timeout, hooks: options.hooks, handle: options.handle, }; } /** * Validate and parse a job payload with the job's Standard Schema. */ export async function parseJobPayload(job, payload) { return (await parsePayload(job.payload, payload, { name: job.name, })); } function normalizeJobHooks(job, hooks = []) { return [ ...hooks, ...(job.hooks ?? []), ]; } /** * Run a parsed job handler once, enforcing hooks and the job's declared * timeout. */ export async function runJobHandler(args) { const traceAttributes = { "beignet.job.name": args.job.name, ...(args.attempt === undefined ? {} : { "beignet.job.attempt": args.attempt }), ...(args.maxAttempts === undefined ? {} : { "beignet.job.max_attempts": args.maxAttempts }), }; await runWithResolvedTracingContext({ tracing: args.tracing, ctx: args.ctx, operation: { name: `beignet.job ${args.job.name}`, type: "job", kind: "consumer", parent: parseTraceCarrier(args.trace), attributes: traceAttributes, metricAttributes: traceAttributes, }, run: async (ctx) => { const timeoutMs = getJobTimeoutMs(args.job); const controller = new AbortController(); const hooks = normalizeJobHooks(args.job, args.hooks); const hookArgs = { job: args.job, payload: args.payload, ctx, signal: controller.signal, attempt: args.attempt, maxAttempts: args.maxAttempts, }; const run = Promise.resolve().then(async () => { let index = -1; const dispatch = async (nextIndex) => { if (nextIndex <= index) { throw new Error(`Job "${args.job.name}" hook called next() multiple times.`); } index = nextIndex; const hook = hooks[nextIndex]; if (!hook) { await args.job.handle({ job: args.job, payload: args.payload, ctx, signal: controller.signal, }); return; } await hook(hookArgs, () => dispatch(nextIndex + 1)); }; await dispatch(0); }); if (timeoutMs === undefined) { await run; return; } const timeoutError = new JobTimeoutError({ jobName: args.job.name, timeoutMs, }); let timeout; try { await Promise.race([ run, new Promise((_, reject) => { timeout = setTimeout(() => { controller.abort(timeoutError); reject(timeoutError); }, timeoutMs); }), ]); } finally { if (timeout !== undefined) { clearTimeout(timeout); } } }, }); } function jobUniqueLockKey(jobName, key, keyPrefix = "jobs:unique") { return `${keyPrefix}:${jobName}:${key}`; } /** * Resolve a job's dispatch-time uniqueness metadata for a parsed payload. */ export async function resolveJobUnique(job, payload, options = {}) { const config = job.unique; if (!config) return undefined; const unique = typeof config === "function" ? await config({ jobName: job.name, payload }) : config; if (unique == null) return undefined; const validated = validateJobUniqueOptions(unique); return { key: validated.key, lockKey: jobUniqueLockKey(job.name, validated.key, options.keyPrefix), ttlMs: durationToMs("unique.ttl", validated.ttl), }; } /** * Create a local/test dispatcher that runs job handlers inline. * * Dispatch honors the job's declared retry policy: failed attempts retry with * the policy's delays until the policy is exhausted. Payload validation * failures never retry. Jobs without a retry policy run exactly once. */ export function createInlineJobDispatcher(options = {}) { const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); async function run(job, payload, retryEnabled, dispatchOptions = {}, propagateFailure = false) { const fail = (error) => { try { options.onError?.(error, job); } catch (observerError) { if (!propagateFailure) throw observerError; } if (propagateFailure || !options.onError) throw error; }; let parsed; let ctx; try { parsed = await parseJobPayload(job, payload); // Resolve once per dispatch so a ctx factory does not run (and cannot // produce different contexts) across retry attempts. ctx = await resolveCtx(options.ctx); } catch (error) { fail(error); return; } const policy = retryEnabled ? job.retry : undefined; const maxAttempts = policy ? (getJobRetryMaxAttempts(policy) ?? 1) : 1; for (let attempt = 1;; attempt += 1) { try { await runJobHandler({ job, payload: parsed, ctx, hooks: options.hooks, trace: dispatchOptions.trace, attempt: dispatchOptions.attempt ?? attempt, maxAttempts: dispatchOptions.maxAttempts ?? maxAttempts, }); return; } catch (error) { const willRetry = shouldRetryJob(policy, { error, attempt, maxAttempts, jobName: job.name, }); if (!willRetry) { fail(error); return; } const delayMs = getJobRetryDelayMs(policy, { error, attempt, jobName: job.name, }); if (delayMs > 0) await sleep(delayMs); } } } const dispatcher = { async dispatch(job, payload, dispatchOptions) { await run(job, payload, options.retry !== false, dispatchOptions); }, }; // Non-enumerable so spreads and serialization keep treating the dispatcher // as a plain port; the outbox drain discovers it by symbol. Object.defineProperty(dispatcher, SINGLE_ATTEMPT_DISPATCH, { value: (job, payload, dispatchOptions) => run(job, payload, false, dispatchOptions, true), enumerable: false, }); return dispatcher; } /** * Wrap any job dispatcher with dispatch-time unique job suppression. * * When a job has no `unique` declaration, dispatch passes through unchanged. * When it does, the wrapper validates the payload, resolves the unique key, * acquires the matching lease, and calls the underlying dispatcher only when * the lease is acquired. Successful dispatches intentionally keep the lease * until its TTL expires; failed dispatches release it so callers can retry. */ export function createUniqueJobDispatcher(options) { const dispatcher = { async dispatch(job, payload, dispatchOptions) { if (!job.unique) { await options.jobs.dispatch(job, payload, dispatchOptions); return; } const parsed = await parseJobPayload(job, payload); const unique = await resolveJobUnique(job, parsed, { keyPrefix: options.keyPrefix, }); if (!unique) { await options.jobs.dispatch(job, payload, dispatchOptions); return; } const result = await options.locks.acquire(unique.lockKey, { ttlMs: unique.ttlMs, waitMs: 0, metadata: { capability: "jobs", jobName: job.name, uniqueKey: unique.key, }, }); if (!result.acquired) { await options.onDuplicate?.({ job, payload: parsed, key: unique.key, lockKey: unique.lockKey, ttlMs: unique.ttlMs, reason: result.reason, }); return; } try { await options.jobs.dispatch(job, payload, dispatchOptions); } catch (error) { try { await result.lease.release(); } catch { // Preserve the dispatch failure; lease release is best effort and // the TTL still bounds duplicate suppression if release fails. } throw error; } }, }; const singleAttempt = options.jobs[SINGLE_ATTEMPT_DISPATCH]; if (singleAttempt) { Object.defineProperty(dispatcher, SINGLE_ATTEMPT_DISPATCH, { value: singleAttempt, enumerable: false, }); } return dispatcher; } /** * Create job helper methods bound to an application context type. * * Call it once in `lib/jobs.ts`: * * ```ts * export const { defineJob } = createJobs<AppContext>(); * ``` * * Retry options describe the job's retry policy. Inline dispatchers run the * policy in-process with real delays; durable providers map the policy onto * their own runtime and reject options they cannot honor. */ export function createJobs() { return { defineJob(name, options) { return defineJobImpl(name, options); }, }; } //# sourceMappingURL=index.js.map