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