@beignet/core
Version:
Core framework primitives for Beignet
1,721 lines (1,567 loc) • 45.8 kB
text/typescript
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);
},
};
}