UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

1,721 lines (1,567 loc) 45.8 kB
import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { LeaseHandle, LeaseMetadata, LocksPort } from "../locks/index.js"; import { runWithResolvedTracingContext } from "../tracing/execution.js"; import { parseTraceCarrier, type TraceCarrier, type TracingPort, } from "../tracing/index.js"; /** * Any Standard Schema compatible validator. */ export type StandardSchema = StandardSchemaV1<unknown, unknown>; /** * Value or promise of that value. */ export type MaybePromise<T> = T | Promise<T>; type NoInferJob<T> = [T][T extends unknown ? 0 : never]; /** * Infer the parsed output type from a Standard Schema. */ export type InferSchemaOutput<T extends StandardSchemaV1> = StandardSchemaV1.InferOutput<T>; /** * Duration accepted by job helpers. Numbers are milliseconds. */ export type JobDuration = | number | `${number}ms` | `${number}s` | `${number}m` | `${number}h`; /** * Duration accepted by retry helpers. Numbers are milliseconds. */ export type JobRetryDuration = JobDuration; /** * Duration accepted by job timeout helpers. Numbers are milliseconds. */ export type JobTimeoutDuration = JobDuration; /** * Duration accepted by job execution lease helpers. Numbers are milliseconds. */ export type JobExecutionLeaseDuration = JobDuration; /** * Retry strategy understood by Beignet job adapters. */ export type JobRetryStrategy = "none" | "fixed" | "exponential"; /** * Arguments passed to a retry predicate. */ export interface JobRetryPredicateArgs { /** * Error thrown by the previous attempt. */ error: unknown; /** * One-based attempt number that just failed. */ attempt: number; /** * Maximum attempts allowed for this delivery. */ maxAttempts: number; /** * Job name when the retry decision is for a job. */ jobName?: string; } /** * Return whether a failed attempt should be retried. */ export type JobRetryPredicate = (args: JobRetryPredicateArgs) => boolean; /** * Dispatch-time uniqueness metadata for a job. * * Unique jobs are suppressed while the resolved key's lease is active. * The lease is intentionally left to expire after a successful dispatch. */ export interface JobUniqueOptions { /** * Logical uniqueness key within this job name. */ key: string; /** * How long duplicate dispatches should be suppressed. */ ttl: JobDuration; } /** * Arguments passed to a per-payload unique job resolver. */ export interface JobUniqueResolverArgs<Payload extends StandardSchema> { /** * Stable job name. */ jobName: string; /** * Parsed job payload. */ payload: StandardSchemaV1.InferOutput<Payload>; } /** * Per-payload resolver for dispatch-time uniqueness. */ export type JobUniqueResolver<Payload extends StandardSchema> = { bivarianceHack( args: JobUniqueResolverArgs<Payload>, ): MaybePromise<JobUniqueOptions | null | undefined>; }["bivarianceHack"]; /** * Unique job declaration value. */ export type JobUniqueConfig<Payload extends StandardSchema = StandardSchema> = | JobUniqueOptions | JobUniqueResolver<Payload>; /** * Resolved uniqueness metadata used by dispatchers. */ export interface ResolvedJobUniqueOptions { /** * Logical uniqueness key returned by the job declaration. */ key: string; /** * Concrete lock key used by `LocksPort`. */ lockKey: string; /** * Lease TTL in milliseconds. */ ttlMs: number; } /** * Job definition created by `defineJob(...)`. */ export interface JobDef< Name extends string = string, Payload extends StandardSchema = StandardSchema, Ctx = unknown, > { /** * Discriminator for job definitions. */ readonly kind: "job"; /** * Stable job name used by dispatchers and provider adapters. */ readonly name: Name; /** * Standard Schema payload validator. */ readonly payload: Payload; /** * Optional human-readable description for docs and tooling. */ readonly description?: string; /** * Retry metadata for durable job providers. */ readonly retry?: JobRetryOptions; /** * Optional dispatch-time uniqueness guard. */ readonly unique?: JobUniqueConfig<Payload>; /** * Optional maximum execution time for each job handler attempt. */ readonly timeout?: JobTimeoutDuration; /** * Optional hooks that wrap each handler attempt for this job. */ readonly hooks?: readonly JobHook<JobDef<Name, Payload, Ctx>, Ctx>[]; /** * Handle a parsed job payload. */ handle( args: JobHandleArgs<JobDef<Name, Payload, Ctx>, Ctx>, ): MaybePromise<void>; } /** * Infer the parsed payload type for a job definition. */ export type InferJobPayload<J extends JobDef> = J["payload"] extends StandardSchemaV1<unknown, infer Output> ? Output : never; /** * Arguments passed to a job handler. */ export interface JobHandleArgs<J extends JobDef, Ctx> { /** * Job definition being handled. */ job: J; /** * Parsed job payload. */ payload: InferJobPayload<J>; /** Handler context. */ ctx: Ctx; /** * Abort signal that fires when the job's declared timeout expires. * * Beignet dispatchers and worker helpers provide this signal. It is optional * in the type so direct handler tests can stay lightweight. * * JavaScript cannot forcibly stop arbitrary async work, so handlers that * call cancellable APIs should pass this signal through. */ signal?: AbortSignal; } /** * Arguments passed to job execution hooks. */ export interface JobHookArgs<J extends JobDef, Ctx> extends JobHandleArgs<J, Ctx> { /** * Abort signal provided by the Beignet execution runner. */ signal: AbortSignal; /** * One-based execution attempt when the runner can report it. */ attempt?: number; /** * Maximum execution attempts when the runner can report it. */ maxAttempts?: number; } /** * Hook that wraps one job handler attempt. * * Hooks run only through Beignet dispatchers and worker helpers. Direct calls * to `job.handle(...)` bypass hooks, which keeps lightweight unit tests * possible. */ export type JobHook<J extends JobDef = JobDef, Ctx = unknown> = { bivarianceHack( args: JobHookArgs<J, Ctx>, next: () => Promise<void>, ): MaybePromise<void>; }["bivarianceHack"]; /** * Arguments passed to an execution lease key resolver. */ export type JobExecutionLeaseResolverArgs<J extends JobDef, Ctx> = JobHookArgs< J, Ctx >; /** * Resolver for the logical execution lease key within a job name. */ export type JobExecutionLeaseKeyResolver<J extends JobDef, Ctx> = { bivarianceHack( args: JobExecutionLeaseResolverArgs<J, Ctx>, ): MaybePromise<string>; }["bivarianceHack"]; /** * Resolver for the locks port used by an execution lease hook. */ export type JobExecutionLeaseLocksResolver<J extends JobDef, Ctx> = | LocksPort | { bivarianceHack( args: JobExecutionLeaseResolverArgs<J, Ctx>, ): MaybePromise<LocksPort>; }["bivarianceHack"]; /** * Resolver for optional diagnostics metadata attached to lease acquisition. */ export type JobExecutionLeaseMetadataResolver<J extends JobDef, Ctx> = | LeaseMetadata | { bivarianceHack( args: JobExecutionLeaseResolverArgs<J, Ctx>, ): MaybePromise<LeaseMetadata | undefined>; }["bivarianceHack"]; /** * Arguments passed when an execution lease cannot be acquired. */ export interface JobExecutionLeaseUnavailableArgs<J extends JobDef, Ctx> extends JobHookArgs<J, Ctx> { /** * Logical lease key returned by the hook configuration. */ key: string; /** * Concrete lock key passed to `LocksPort`. */ lockKey: string; /** * Acquisition failure reason returned by `LocksPort`. */ reason: "unavailable" | "timeout"; } /** * Behavior when an execution lease cannot be acquired. * * `"skip"` treats the attempt as successful without running the handler. * `"throw"` fails the attempt with `JobExecutionLeaseUnavailableError` so the * job retry policy can classify it. A function can log or throw custom errors; * returning from it skips the handler. */ export type JobExecutionLeaseUnavailableBehavior<J extends JobDef, Ctx> = | "skip" | "throw" | { bivarianceHack( args: JobExecutionLeaseUnavailableArgs<J, Ctx>, ): MaybePromise<void>; }["bivarianceHack"]; /** * Options for `createJobExecutionLeaseHook(...)`. */ export interface JobExecutionLeaseHookOptions< J extends JobDef = JobDef, Ctx = unknown, > { /** * Locks port or resolver used to acquire the execution lease. */ locks: JobExecutionLeaseLocksResolver<J, Ctx>; /** * Logical lease key within this job name. */ key: string | JobExecutionLeaseKeyResolver<J, Ctx>; /** * Lease time-to-live. This is the real safety boundary in serverless runtimes * where an invocation may terminate before `finally` runs. */ ttl: JobExecutionLeaseDuration; /** * How long to wait for an existing lease before applying `onUnavailable`. * * Defaults to no wait. */ wait?: JobExecutionLeaseDuration; /** * Delay between acquisition attempts while waiting. */ retryDelay?: JobExecutionLeaseDuration; /** * Prefix used to build the concrete lock key. * * Defaults to `"jobs:lease"`. */ keyPrefix?: string; /** * Optional diagnostics metadata attached to the lease acquire call. */ metadata?: JobExecutionLeaseMetadataResolver<J, Ctx>; /** * Behavior when the lease cannot be acquired. * * Defaults to `"skip"` so overlapping executions do not create retry storms. */ onUnavailable?: JobExecutionLeaseUnavailableBehavior<J, Ctx>; } /** * Retry metadata that durable job providers can map to their own retry model. */ export interface JobRetryOptions { /** * Retry strategy. Raw objects without a strategy default to exponential * backoff so existing `{ attempts }` style definitions stay meaningful. */ strategy?: JobRetryStrategy; /** * Maximum total attempts, including the first attempt. */ attempts?: number; /** * Delay between attempts for fixed retry policies. */ delay?: JobRetryDuration; /** * Initial delay for exponential retry policies. */ initialDelay?: JobRetryDuration; /** * Maximum delay for exponential retry policies. */ maxDelay?: JobRetryDuration; /** * Exponential multiplier. Defaults to `2`. */ factor?: number; /** * Whether adapters that compute delays should add jitter. */ jitter?: boolean; /** * Optional app-owned retry classifier. */ retryIf?: JobRetryPredicate; } /** * Options for declaring a typed job. */ export interface DefineJobOptions< Name extends string, Payload extends StandardSchema, Ctx, > { /** * Standard Schema payload validator. */ payload: Payload; /** * Optional human-readable description for docs and tooling. */ description?: string; /** * Retry metadata for durable job providers. */ retry?: JobRetryOptions; /** * Optional dispatch-time uniqueness guard. */ unique?: JobUniqueConfig<Payload>; /** * Optional maximum execution time for each job handler attempt. */ timeout?: JobTimeoutDuration; /** * Optional hooks that wrap each handler attempt for this job. */ hooks?: readonly JobHook< JobDef<NoInferJob<Name>, NoInferJob<Payload>, Ctx>, Ctx >[]; /** * Handle a parsed job payload. */ handle( args: JobHandleArgs<JobDef<Name, Payload, Ctx>, Ctx>, ): MaybePromise<void>; } /** * Options for a fixed job retry policy. */ export interface FixedJobRetryOptions { /** * Maximum total attempts, including the first attempt. */ attempts: number; /** * Delay between attempts. */ delay: JobRetryDuration; /** * Optional app-owned retry classifier. */ retryIf?: JobRetryPredicate; } /** * Options for an exponential job retry policy. */ export interface ExponentialJobRetryOptions { /** * Maximum total attempts, including the first attempt. */ attempts: number; /** * Initial delay. Defaults to `1s`. */ initialDelay?: JobRetryDuration; /** * Maximum delay. Defaults to `1m`. */ maxDelay?: JobRetryDuration; /** * Exponential multiplier. Defaults to `2`. */ factor?: number; /** * Whether computed delays should include jitter. */ jitter?: boolean; /** * Optional app-owned retry classifier. */ retryIf?: JobRetryPredicate; } /** * Retry helper namespace for job definitions. */ export const retry = { /** * Disable retries. The first failure is terminal. */ none(): JobRetryOptions { return { strategy: "none", attempts: 1, }; }, /** * Retry with the same delay between attempts. */ fixed(options: FixedJobRetryOptions): JobRetryOptions { return validateJobRetryOptions({ strategy: "fixed", attempts: options.attempts, delay: options.delay, retryIf: options.retryIf, }); }, /** * Retry with exponential backoff. */ exponential(options: ExponentialJobRetryOptions): JobRetryOptions { return validateJobRetryOptions({ strategy: "exponential", attempts: options.attempts, initialDelay: options.initialDelay, maxDelay: options.maxDelay, factor: options.factor, jitter: options.jitter, retryIf: options.retryIf, }); }, } as const; /** * Options for the inline job dispatcher. */ export interface InlineJobDispatcherOptions<Ctx> { /** * Static job context or factory evaluated for each dispatched job. */ ctx?: Ctx | (() => MaybePromise<Ctx>); /** * Called when a dispatched inline job fails all attempts allowed by its * retry policy. When omitted, the final error is rethrown to the caller. */ onError?: (error: unknown, job: JobDef<string, StandardSchema, Ctx>) => void; /** * Sleep implementation used between retry attempts. Defaults to a real * `setTimeout` delay; inject a fake in tests to keep retries instant. */ sleep?: (ms: number) => Promise<void>; /** * Honor the job's declared retry policy inline. Defaults to `true`. Set * `false` when another layer owns execution retries for every dispatch * through this dispatcher. */ retry?: boolean; /** * Hooks that wrap every job attempt executed by this dispatcher. Runner hooks * wrap job-local hooks. */ hooks?: readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[]; } /** Metadata propagated when a job is dispatched. */ export interface JobDispatchOptions { /** Versioned trace context captured by the job producer. */ trace?: TraceCarrier; } const JOB_TRANSPORT_ENVELOPE_TYPE = "beignet.job"; const JOB_TRANSPORT_ENVELOPE_VERSION = 1; /** Parsed payload and propagation metadata from a job transport envelope. */ export interface ParsedJobTransportEnvelope { payload: unknown; trace?: TraceCarrier; } /** * Wrap a job payload with transport metadata when a trace is present. * Payloads without metadata retain their legacy wire shape. */ export function createJobTransportEnvelope( payload: unknown, options?: JobDispatchOptions, ): unknown { 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: unknown, ): ParsedJobTransportEnvelope { 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: unique symbol = Symbol.for( "beignet.jobs.singleAttemptDispatch", ); /** * Metadata passed to a single-attempt dispatch when another delivery layer * owns retry scheduling. */ export interface SingleAttemptJobDispatchOptions extends JobDispatchOptions { /** * One-based delivery attempt. */ attempt?: number; /** * Maximum delivery attempts. */ maxAttempts?: number; } /** * Shape of the single-attempt dispatch exposed under * `SINGLE_ATTEMPT_DISPATCH`. */ export type SingleAttemptJobDispatch = <J extends JobDef>( job: J, payload: InferJobPayload<J>, options?: SingleAttemptJobDispatchOptions, ) => Promise<void>; /** * Job dispatcher shape accepted by job wrappers. */ export interface JobDispatcher { dispatch<J extends JobDef>( job: J, payload: InferJobPayload<J>, options?: JobDispatchOptions, ): MaybePromise<void>; } /** * Arguments passed when a unique job dispatch is suppressed. */ export interface UniqueJobDuplicateArgs<J extends JobDef = JobDef> { /** * Job definition whose dispatch was suppressed. */ job: J; /** * Parsed job payload. */ payload: InferJobPayload<J>; /** * Logical uniqueness key returned by the job declaration. */ key: string; /** * Concrete lock key used by `LocksPort`. */ lockKey: string; /** * Lease TTL in milliseconds. */ ttlMs: number; /** * Lock acquisition reason returned by `LocksPort`. */ reason: "unavailable" | "timeout"; } /** * Options for `createUniqueJobDispatcher(...)`. */ export interface UniqueJobDispatcherOptions { /** * Dispatcher to call when a job is not unique or the unique lease is acquired. */ jobs: JobDispatcher; /** * Lease-backed lock port used as the uniqueness guard. */ locks: LocksPort; /** * Prefix for concrete lock keys. * * Defaults to `"jobs:unique"`. */ keyPrefix?: string; /** * Called when a duplicate dispatch is suppressed. */ onDuplicate?: (args: UniqueJobDuplicateArgs) => MaybePromise<void>; } /** * Local/test job dispatcher that executes job handlers inline. */ export interface InlineJobDispatcher<Ctx = unknown> { /** * Validate a payload and run the job handler inline, honoring the job's * declared retry policy before failing. */ dispatch<J extends JobDef<string, StandardSchema, Ctx>>( job: J, payload: InferJobPayload<J>, options?: JobDispatchOptions, ): Promise<void>; } /** * Context-bound job helper factory. */ export interface Jobs<Ctx> { /** * Define a job with the bound context type. */ defineJob<Name extends string, Payload extends StandardSchema>( name: Name, options: DefineJobOptions<Name, Payload, Ctx>, ): JobDef<Name, Payload, Ctx>; } /** * Error thrown when job payload validation fails. */ export class JobValidationError extends Error { /** * Raw Standard Schema validation issues. */ readonly issues: readonly StandardSchemaV1.Issue[]; constructor(args: { name: string; issues: readonly StandardSchemaV1.Issue[]; }) { 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. */ readonly jobName: string; /** * Timeout in milliseconds. */ readonly timeoutMs: number; constructor(args: { jobName: string; timeoutMs: number }) { 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. */ readonly jobName: string; /** * Logical lease key returned by the hook configuration. */ readonly key: string; /** * Concrete lock key passed to `LocksPort`. */ readonly lockKey: string; /** * Acquisition failure reason returned by `LocksPort`. */ readonly reason: "unavailable" | "timeout"; constructor(args: { jobName: string; key: string; lockKey: string; reason: "unavailable" | "timeout"; }) { 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: StandardSchemaV1.Issue["path"]): string { 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: readonly StandardSchemaV1.Issue[]): string { return issues .map((issue) => { const path = formatPath(issue.path); return path ? `${path}: ${issue.message}` : issue.message; }) .join("; "); } function assertPositiveInteger(name: string, value: number): void { if (!Number.isInteger(value) || value <= 0) { throw new Error(`${name} must be a positive integer`); } } function assertPositiveNumber(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`${name} must be a positive number`); } } function assertNonEmptyString(name: string, value: string): void { if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`${name} must be a non-empty string`); } } function durationToMs(name: string, value: JobDuration | unknown): number { 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: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; } function validateJobUniqueOptions(options: JobUniqueOptions): JobUniqueOptions { 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<Payload extends StandardSchema>( config: JobUniqueConfig<Payload> | undefined, ): JobUniqueConfig<Payload> | undefined { if (config === undefined) return undefined; if (typeof config === "function") return config; return validateJobUniqueOptions(config); } function validateJobTimeout( timeout: JobTimeoutDuration | undefined, ): JobTimeoutDuration | undefined { if (timeout === undefined) return undefined; durationToMs("timeout", timeout); return timeout; } const VALIDATED_RETRY_OPTIONS = Symbol("beignet.jobs.validatedRetryOptions"); function validateJobRetryOptions(options: JobRetryOptions): JobRetryOptions { // 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 as { [VALIDATED_RETRY_OPTIONS]?: boolean })[ 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: JobRetryOptions | undefined, ): number | undefined { return options ? validateJobRetryOptions(options).attempts : undefined; } /** * Return whether a failed job attempt should be retried. */ export function shouldRetryJob( options: JobRetryOptions | undefined, args: JobRetryPredicateArgs, ): boolean { 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: JobRetryOptions | undefined, args: Pick<JobRetryPredicateArgs, "attempt" | "error" | "jobName">, ): number { const retryOptions = options ? validateJobRetryOptions(options) : retry.exponential({ attempts: 3 }); let delayMs: number; 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: Pick<JobDef, "timeout">, ): number | undefined { return job.timeout === undefined ? undefined : durationToMs("timeout", job.timeout); } function jobExecutionLeaseLockKey( jobName: string, key: string, keyPrefix = "jobs:lease", ): string { return `${keyPrefix}:${jobName}:${key}`; } function validateExecutionLeaseKey(key: string): string { assertNonEmptyString("executionLease.key", key); return key; } function abortReason(signal: AbortSignal): unknown { return signal.reason ?? new Error("Job execution aborted."); } async function resolveExecutionLeaseLocks<J extends JobDef, Ctx>( locks: JobExecutionLeaseLocksResolver<J, Ctx>, args: JobExecutionLeaseResolverArgs<J, Ctx>, ): Promise<LocksPort> { return typeof locks === "function" ? await locks(args) : locks; } async function resolveExecutionLeaseMetadata<J extends JobDef, Ctx>( metadata: JobExecutionLeaseMetadataResolver<J, Ctx> | undefined, args: JobExecutionLeaseResolverArgs<J, Ctx>, ): Promise<LeaseMetadata | undefined> { return typeof metadata === "function" ? await metadata(args) : metadata; } async function releaseExecutionLease(lease: LeaseHandle): Promise<void> { 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<J extends JobDef, Ctx>( behavior: JobExecutionLeaseUnavailableBehavior<J, Ctx> | undefined, args: JobExecutionLeaseUnavailableArgs<J, Ctx>, ): Promise<void> { 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< J extends JobDef = JobDef, Ctx = unknown, >(options: JobExecutionLeaseHookOptions<J, Ctx>): JobHook<J, Ctx> { 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 extends StandardSchemaV1>( schema: Schema, input: unknown, args: { name: string }, ): Promise<InferSchemaOutput<Schema>> { 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 as InferSchemaOutput<Schema>; } throw new Error("Invalid Standard Schema result: missing value"); } async function resolveCtx<Ctx>( ctx: Ctx | (() => MaybePromise<Ctx>) | undefined, ): Promise<Ctx> { if (typeof ctx === "function") { return (ctx as () => MaybePromise<Ctx>)(); } return ctx as Ctx; } function defineJobImpl< Name extends string, Payload extends StandardSchema, Ctx = unknown, >( name: Name, options: DefineJobOptions<Name, Payload, Ctx>, ): JobDef<Name, Payload, Ctx> { 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 as JobDef<Name, Payload, Ctx>["handle"], }; } /** * Validate and parse a job payload with the job's Standard Schema. */ export async function parseJobPayload<J extends JobDef>( job: J, payload: unknown, ): Promise<InferJobPayload<J>> { return (await parsePayload(job.payload, payload, { name: job.name, })) as InferJobPayload<J>; } /** * Options for running one parsed job handler attempt. */ export interface RunJobHandlerOptions< J extends JobDef<string, StandardSchema, Ctx>, Ctx, > { /** * Job definition to execute. */ job: J; /** * Parsed job payload. */ payload: InferJobPayload<J>; /** Handler context or factory resolved inside the job span. */ ctx: Ctx | (() => MaybePromise<Ctx>); /** Runtime tracing port used before a lazy context factory runs. */ tracing?: TracingPort; /** Trace context captured by the dispatching process. */ trace?: TraceCarrier; /** * Runner-level hooks. These wrap job-local hooks. */ hooks?: readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[]; /** * One-based execution attempt when the runner can report it. */ attempt?: number; /** * Maximum execution attempts when the runner can report it. */ maxAttempts?: number; } function normalizeJobHooks<Ctx>( job: JobDef<string, StandardSchema, Ctx>, hooks: readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[] = [], ): readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[] { return [ ...hooks, ...((job.hooks ?? []) as readonly JobHook< JobDef<string, StandardSchema, Ctx>, Ctx >[]), ]; } /** * Run a parsed job handler once, enforcing hooks and the job's declared * timeout. */ export async function runJobHandler< J extends JobDef<string, StandardSchema, Ctx>, Ctx, >(args: RunJobHandlerOptions<J, Ctx>): Promise<void> { 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 }), } as const; 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, } satisfies JobHookArgs<J, Ctx>; const run = Promise.resolve().then(async () => { let index = -1; const dispatch = async (nextIndex: number): Promise<void> => { 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: ReturnType<typeof setTimeout> | undefined; try { await Promise.race([ run, new Promise<void>((_, reject) => { timeout = setTimeout(() => { controller.abort(timeoutError); reject(timeoutError); }, timeoutMs); }), ]); } finally { if (timeout !== undefined) { clearTimeout(timeout); } } }, }); } function jobUniqueLockKey( jobName: string, key: string, keyPrefix = "jobs:unique", ): string { return `${keyPrefix}:${jobName}:${key}`; } /** * Resolve a job's dispatch-time uniqueness metadata for a parsed payload. */ export async function resolveJobUnique<J extends JobDef>( job: J, payload: InferJobPayload<J>, options: { keyPrefix?: string } = {}, ): Promise<ResolvedJobUniqueOptions | undefined> { 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<Ctx>( options: InlineJobDispatcherOptions<Ctx> = {}, ): InlineJobDispatcher<Ctx> { const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))); async function run<J extends JobDef<string, StandardSchema, Ctx>>( job: J, payload: InferJobPayload<J>, retryEnabled: boolean, dispatchOptions: SingleAttemptJobDispatchOptions = {}, propagateFailure = false, ): Promise<void> { const fail = (error: unknown): void => { try { options.onError?.(error, job); } catch (observerError) { if (!propagateFailure) throw observerError; } if (propagateFailure || !options.onError) throw error; }; let parsed: InferJobPayload<J>; let ctx: 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: InlineJobDispatcher<Ctx> = { async dispatch<J extends JobDef<string, StandardSchema, Ctx>>( job: J, payload: InferJobPayload<J>, dispatchOptions?: JobDispatchOptions, ) { 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: <J extends JobDef<string, StandardSchema, Ctx>>( job: J, payload: InferJobPayload<J>, dispatchOptions?: SingleAttemptJobDispatchOptions, ) => 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: UniqueJobDispatcherOptions, ): JobDispatcher { const dispatcher: JobDispatcher = { async dispatch<J extends JobDef>( job: J, payload: InferJobPayload<J>, dispatchOptions?: JobDispatchOptions, ): Promise<void> { 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 as { [SINGLE_ATTEMPT_DISPATCH]?: SingleAttemptJobDispatch; } )[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<Ctx>(): Jobs<Ctx> { return { defineJob<Name extends string, Payload extends StandardSchema>( name: Name, options: DefineJobOptions<Name, Payload, Ctx>, ): JobDef<Name, Payload, Ctx> { return defineJobImpl(name, options); }, }; }