UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

1,453 lines (1,338 loc) 48.8 kB
import { $hook, $inject, $state, Alepha, AlephaError, type Static, type TSchema, } from "alepha"; import { CryptoProvider } from "alepha/crypto"; import { DateTimeProvider, type DurationLike } from "alepha/datetime"; import { LockProvider } from "alepha/lock"; import type { LogEntry } from "alepha/logger"; import { $logger } from "alepha/logger"; import { $repository, DbConflictError, DbEntityNotFoundError, } from "alepha/orm"; import { CronProvider } from "alepha/scheduler"; import { type JobStatus, jobExecutionEntity, } from "../entities/jobExecutionEntity.ts"; import type { JobPrimitiveOptions, JobPriority } from "../primitives/$job.ts"; import { jobConfig } from "../schemas/jobConfigAtom.ts"; import { DirectJobDispatcher } from "./DirectJobDispatcher.ts"; import type { JobDispatcher } from "./JobDispatcher.ts"; import { JobQueueProvider } from "./JobQueueProvider.ts"; // ----------------------------------------------------------------------------------------------------------------- const PRIORITY_MAP: Record<JobPriority, number> = { critical: 0, high: 1, normal: 2, low: 3, }; const PRIORITY_REVERSE: Record<number, JobPriority> = { 0: "critical", 1: "high", 2: "normal", 3: "low", }; // ----------------------------------------------------------------------------------------------------------------- export interface PushOptions { delay?: DurationLike; key?: string; priority?: JobPriority; scheduledAt?: Date; triggeredBy?: string; triggeredByName?: string; /** * Owning tenant for this execution. Persisted on the row so tenant-facing * views (e.g. the notification admin list) can org-scope it. Plumbed through * verbatim — callers in a tenant context pass it explicitly (the resolved * tenant), cron/global pushes leave it undefined. */ organizationId?: string; } export interface PushManyItem<T extends TSchema = TSchema> { payload: Static<T>; key?: string; delay?: DurationLike; priority?: JobPriority; scheduledAt?: Date; } export interface JobTriggerContext<T extends TSchema = TSchema> { payload?: Static<T>; triggeredBy?: string; triggeredByName?: string; } export interface CancelContext { cancelledBy?: string; cancelledByName?: string; } /** * The declared shape of the job (set at registration time). * * **Important** — this `kind` is the *declared* form. The *effective* * runtime mode (cron / queue / direct) is exposed by * `JobProvider.effectiveMode(name)` and the `JobRegistration.type` field on * the admin schema. Don't conflate the two: a `queue` kind can run as * `direct` at runtime when no queue dispatcher is loaded. */ interface JobRuntimeRegistration { name: string; options: JobPrimitiveOptions; kind: "cron" | "queue"; } export type JobEffectiveMode = "cron" | "queue" | "direct"; // ----------------------------------------------------------------------------------------------------------------- /** * Coordinates cron and push jobs with a durable outbox table and a single * reconciliation sweep. The actual delivery channel (queue / direct) is * abstracted behind {@link JobDispatcher}, substituted by DI: * * - **DirectJobDispatcher** (default, registered by `AlephaApiJobs`) — * runs the handler in-process right after `push()` returns. * - **QueueJobDispatcher** (registered by `AlephaApiJobsQueue`) — sends * the executionId through `AlephaQueue` so a pool of workers can pick * it up. * * Push flow: * push() → INSERT row (pending) → dispatcher.dispatch(jobName, id) * worker → claim → UPDATE running → handler → DELETE/UPDATE on success * → UPDATE error / scheduled (retry) on failure * * Cron flow: * scheduler tick → acquire lock → executeInline (no retry) * → enqueue + dispatch (retry declared) * * Sweep responsibilities (every `sweepCron`): * - re-enqueue pending rows older than `staleThreshold` * - mark crashed running rows as failed and apply retry policy * - move `scheduled` rows with `scheduledAt <= now` to pending + dispatch * * Trim runs on its own cron (`trimCron`, default hourly): * - per-job history trimmed beyond `keepLastSuccess` / `keepLastError` * - decoupled from sweep because trim cost scales with job count, not * retry latency — running it every sweep is wasted work for most apps. */ export class JobProvider { protected readonly alepha = $inject(Alepha); protected readonly dt = $inject(DateTimeProvider); protected readonly cronProvider = $inject(CronProvider); protected readonly lockProvider = $inject(LockProvider); protected readonly crypto = $inject(CryptoProvider); protected readonly config = $state(jobConfig); /** * Resolved at first use (after the container is fully wired) — picks * the queue dispatcher when `AlephaApiJobsQueue` was loaded, otherwise * the direct dispatcher. Lazy because both dispatchers inject * `JobProvider` themselves; resolving them at field-init time would * create a circular construction. */ protected dispatcherRef?: JobDispatcher; public get dispatcher(): JobDispatcher { if (!this.dispatcherRef) { this.dispatcherRef = this.alepha.has(JobQueueProvider) ? this.alepha.inject(JobQueueProvider) : this.alepha.inject(DirectJobDispatcher); } return this.dispatcherRef; } protected readonly log = $logger(); protected readonly executions = $repository(jobExecutionEntity); protected readonly jobs = new Map<string, JobRuntimeRegistration>(); protected readonly inFlight = new Set<Promise<void>>(); protected readonly abortControllers = new Map<string, AbortController>(); protected readonly perExecutionLogs = new Map<string, LogEntry[]>(); protected stopping = false; constructor() { // Register the sweep + trim crons eagerly so the wrangler build // step (which reads `CronProvider.getCronJobs()` without running // any lifecycle hooks) sees them and emits the matching cron // triggers into wrangler.jsonc. CronProvider's `start` hook will // boot whatever is in `cronJobs` at runtime — registering here // (constructor, runs at inject time) is equivalent to registering // in `onStart` from CronProvider's POV but visible to build-time // introspection. this.cronProvider.createCronJob( "api:jobs:sweep", this.config.sweepCron, async () => { await this.sweep(); }, true, ); this.cronProvider.createCronJob( "api:jobs:trim", this.config.trimCron, async () => { if (this.stopping) return; try { await this.trimRingBuffers(); } catch (e) { this.log.error("Trim failed", { error: e }); } }, true, ); } // --- Registration ----------------------------------------------------------------------------------------------- public registerJob(name: string, options: JobPrimitiveOptions): void { if (this.jobs.has(name)) { throw new AlephaError(`Job already registered: ${name}`); } if (options.cron && options.schema) { throw new AlephaError( `Job '${name}' declares both 'cron' and 'schema'. A job must be either cron-only (recurring) or queue-only (push-based). Split into two jobs.`, ); } if (!options.cron && !options.schema) { throw new AlephaError( `Job '${name}' must declare either 'cron' (for recurring tasks) or 'schema' (for queue-mode tasks).`, ); } const kind: "cron" | "queue" = options.cron ? "cron" : "queue"; // Cron jobs keep their last successful run by default, so the admin // "Last run" reflects reality — a routine cron success is otherwise // unrecorded (only failures are), making every healthy cron look like it // "never" ran. Bounded to the latest row (keep.ok=1) to avoid unbounded // growth from recurring ticks. Opt out of recording successes entirely // with `record: "error"` (recommended for very high-frequency crons). if (kind === "cron" && options.record === undefined) { options = { ...options, record: "all", keep: { ...options.keep, ok: options.keep?.ok ?? 1 }, }; } this.jobs.set(name, { name, options, kind }); this.log.debug(`Registered ${kind} job '${name}'`, { cron: options.cron, priority: options.priority ?? "normal", retries: options.retry?.retries ?? 0, }); if (options.cron) { this.cronProvider.createCronJob(name, options.cron, async () => { try { await this.runCron(name); } catch (error) { this.log.error(`Cron tick failed for job '${name}'`, error); } }); } } public getRegisteredJobs(): Map<string, JobRuntimeRegistration> { return this.jobs; } /** * Resolves what *actually* runs at dispatch time. Cron jobs are always * "cron"; non-cron jobs delegate to the active `JobDispatcher` (queue * vs. direct), which is determined by which modules the app loaded. */ public effectiveMode(name: string): JobEffectiveMode { const reg = this.getRegistration(name); if (reg.kind === "cron") return "cron"; return this.dispatcher.kind; } // --- Cron execution (inline, no queue) -------------------------------------------------------------------------- protected async runCron(name: string): Promise<void> { const registration = this.getRegistration(name); if (registration.kind !== "cron") { throw new AlephaError(`Job '${name}' is not cron-mode`); } await this.runCronLocked(registration, { triggeredBy: "system", triggeredByName: "system (cron)", }); } /** * Cron-mode runner that respects the per-job distributed lock. * Used by both the scheduled tick and manual `trigger()` calls so that an * admin-triggered run on one instance can't race a scheduled run on another. * * **Two paths depending on `retry`:** * * - **No `retry`** — runs the handler inline. No DB row on success; * error row only on failure. The "next tick" is the implicit retry. * - **`retry` declared** — enqueues a synthetic execution row and hands * it to the dispatcher. The handler then runs through the same path * as a queue/direct push (claim, retry-on-fail, sweep recovery). Use * this when a single failed tick must not block work for the whole * `cron` interval (e.g. once-daily jobs). */ protected async runCronLocked( registration: JobRuntimeRegistration, ctx: { triggeredBy?: string; triggeredByName?: string }, ): Promise<void> { if (this.stopping) return; const useLock = registration.options.lock !== false; if (useLock) { const acquired = await this.acquireCronLock(registration); if (!acquired) { this.log.debug( `Cron '${registration.name}' skipped — another instance holds the lock`, ); return; } } try { if (registration.options.retry) { await this.enqueueCronExecution(registration, ctx); return; } const executionId = this.crypto.randomUUID(); const promise = this.executeInline(registration, executionId, { payload: undefined, attempt: 1, triggeredBy: ctx.triggeredBy, triggeredByName: ctx.triggeredByName, }); this.inFlight.add(promise); try { await promise; } finally { this.inFlight.delete(promise); } } finally { if (useLock) { await this.releaseCronLock(registration); } } } /** * Materialize a cron tick into the outbox so it goes through the normal * retry/sweep path. Used when the user opts into `retry` on a cron job — * a transient failure no longer means "wait for the next cron tick", it * means "the sweep will retry within `sweepCron`". */ protected async enqueueCronExecution( registration: JobRuntimeRegistration, ctx: { triggeredBy?: string; triggeredByName?: string }, ): Promise<void> { const opts = registration.options; const maxAttempts = (opts.retry?.retries ?? 0) + 1; const execution = await this.executions.create({ jobName: registration.name, payload: undefined, status: "pending", priority: PRIORITY_MAP[opts.priority ?? "normal"], maxAttempts, triggeredBy: ctx.triggeredBy, triggeredByName: ctx.triggeredByName, }); await this.dispatch(registration.name, execution.id); } /** * Acquire a per-job NX lock keyed by `cron-job:<name>` so that a single * tick across all replicas runs exactly one execution. Auto-expires after * `2 * timeout` (or 5 minutes if no per-job timeout) so a crashed worker * cannot permanently block the cron from firing. * * **Caveat — same-process double-fire is not prevented.** The lock value * is a per-process holder id, so two concurrent ticks on the same process * (e.g. a scheduled tick overlapping an admin `trigger()` call) will both * see "we own it". This is acceptable for the multi-replica use case the * lock targets; a process that overlaps its own cron handler should set a * smaller `timeout` or use idempotent handler logic. A future fix can add * a per-process Set guard before reaching the LockProvider. */ protected async acquireCronLock( registration: JobRuntimeRegistration, ): Promise<boolean> { const lockKey = this.cronLockKey(registration.name); const ttlMs = registration.options.timeout ? this.dt.duration(registration.options.timeout).as("milliseconds") * 2 : 5 * 60 * 1000; const value = `${this.lockHolderId},${this.dt.nowISOString()}`; try { const stored = await this.lockProvider.set(lockKey, value, true, ttlMs); const [holderId] = stored.split(","); return holderId === this.lockHolderId; } catch (e) { this.log.warn(`Cron lock acquire failed for '${registration.name}'`, e); return true; // Fail-open: better to risk a duplicate than to silently miss a tick. } } /** * Update only when the row is still in one of the expected statuses. * Logs and returns silently when the guard rejects — this happens when a * concurrent operation (most often `cancel()`) has already moved the row * into a terminal state. We must not overwrite that. */ protected async guardedUpdate( executionId: string, expectedStatuses: JobStatus[], patch: Parameters<typeof this.executions.updateById>[1], label: string, ): Promise<void> { try { await this.executions.updateOne( { id: { eq: executionId }, status: { inArray: expectedStatuses } }, patch, ); } catch (e) { if (e instanceof DbEntityNotFoundError) { this.log.debug( `${label}: row ${executionId} not in expected status — skipping write`, ); return; } throw e; } } protected async releaseCronLock( registration: JobRuntimeRegistration, ): Promise<void> { try { await this.lockProvider.del(this.cronLockKey(registration.name)); } catch (e) { this.log.debug( `Cron lock release failed for '${registration.name}' (will expire by TTL)`, e, ); } } protected cronLockKey(jobName: string): string { return `alepha.api.jobs.cron:${jobName}`; } /** * Stable per-process id used as the lock value — survives multiple ticks. * Lazy so that Cloudflare Workers (which forbid random in global scope) * stay happy. */ protected lockHolderIdValue?: string; protected get lockHolderId(): string { if (!this.lockHolderIdValue) { this.lockHolderIdValue = this.crypto.randomUUID(); } return this.lockHolderIdValue; } /** * Execute a cron handler inline. Records a row only on error (or always, * when `record: 'all'`). No DB writes on the happy path by default. */ protected async executeInline( registration: JobRuntimeRegistration, executionId: string, ctx: { payload: unknown; attempt: number; triggeredBy?: string; triggeredByName?: string; }, ): Promise<void> { const opts = registration.options; const name = registration.name; const record = opts.record ?? "error"; const contextId = this.alepha.context.createContextId(); this.perExecutionLogs.set(contextId, []); const abortController = new AbortController(); this.abortControllers.set(executionId, abortController); let timeoutId: ReturnType<typeof setTimeout> | undefined; if (opts.timeout) { const ms = this.dt.duration(opts.timeout).as("milliseconds"); timeoutId = setTimeout(() => abortController.abort(), ms); } const startedAt = this.dt.now(); try { await this.alepha.context.run( async () => { await this.alepha.events.emit("job:begin", { name, now: startedAt, executionId, }); try { await opts.handler({ payload: ctx.payload, attempt: ctx.attempt, now: startedAt, signal: abortController.signal, executionId, }); if (record === "all") { await this.writeTerminalRow(executionId, name, "ok", { payload: ctx.payload, attempt: ctx.attempt, startedAt, error: undefined, context: contextId, triggeredBy: ctx.triggeredBy, triggeredByName: ctx.triggeredByName, }); } await this.alepha.events.emit( "job:success", { name, executionId }, { catch: true }, ); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); if (record !== "none") { await this.writeTerminalRow(executionId, name, "error", { payload: ctx.payload, attempt: ctx.attempt, startedAt, error: err, context: contextId, triggeredBy: ctx.triggeredBy, triggeredByName: ctx.triggeredByName, }); } await this.alepha.events.emit( "job:error", { name, error: err, executionId }, { catch: true }, ); } finally { if (timeoutId) clearTimeout(timeoutId); this.abortControllers.delete(executionId); await this.alepha.events.emit( "job:end", { name, executionId }, { catch: true }, ); } }, { context: contextId }, ); } finally { this.perExecutionLogs.delete(contextId); } } protected async writeTerminalRow( executionId: string, jobName: string, status: "ok" | "error", fields: { payload: unknown; attempt: number; startedAt: ReturnType<DateTimeProvider["now"]>; error?: Error; context: string; triggeredBy?: string; triggeredByName?: string; }, ): Promise<void> { try { const logs = status === "error" ? this.snapshotLogs(fields.context) : undefined; await this.executions.create({ id: executionId, jobName, status, payload: fields.payload as Record<string, unknown> | undefined, attempt: fields.attempt, maxAttempts: fields.attempt, startedAt: fields.startedAt.toISOString(), completedAt: this.dt.nowISOString(), error: fields.error?.message, logs, triggeredBy: fields.triggeredBy, triggeredByName: fields.triggeredByName, }); } catch (e) { this.log.warn(`Failed to write terminal row for ${executionId}`, e); } } // --- Queue push ------------------------------------------------------------------------------------------------- public async push( name: string, payload: unknown, options?: PushOptions, ): Promise<string> { const registration = this.getRegistration(name); if (registration.kind !== "queue") { throw new AlephaError( `Job '${name}' is not queue-mode (no schema declared). Use trigger() instead.`, ); } const opts = registration.options; const validated = this.alepha.codec.validate(opts.schema!, payload); const priority = PRIORITY_MAP[options?.priority ?? opts.priority ?? "normal"]; const maxAttempts = (opts.retry?.retries ?? 0) + 1; const isDelayed = options?.delay || options?.scheduledAt; const status: JobStatus = isDelayed ? "scheduled" : "pending"; let scheduledAt: string | undefined; if (options?.scheduledAt) { scheduledAt = options.scheduledAt.toISOString(); } else if (options?.delay) { scheduledAt = this.dt .now() .add(this.dt.duration(options.delay)) .toISOString(); } if (options?.key) { // Key-based dedup: check for existing row first, then insert. // Two queries in the no-conflict path, but deterministic across dialects. const existing = await this.executions.findMany({ where: { jobName: { eq: name }, key: { eq: options.key } }, limit: 1, }); if (existing.length > 0) { return existing[0].id; } const { id: executionId, created } = await this.createKeyedExecution({ jobName: name, key: options.key, payload: validated as Record<string, unknown>, status, priority, maxAttempts, scheduledAt, triggeredBy: options.triggeredBy, triggeredByName: options.triggeredByName, organizationId: options.organizationId, }); if (!created) { // Lost the race to a concurrent same-key push — the winner dispatches. return executionId; } if (status === "pending") { await this.dispatch(name, executionId); } else if (status === "scheduled" && scheduledAt) { this.scheduleOptimisticDispatch(name, executionId, scheduledAt); } return executionId; } const execution = await this.executions.create({ jobName: name, payload: validated as Record<string, unknown>, status, priority, maxAttempts, scheduledAt, triggeredBy: options?.triggeredBy, triggeredByName: options?.triggeredByName, organizationId: options?.organizationId, }); if (status === "pending") { await this.dispatch(name, execution.id); } else if (status === "scheduled" && scheduledAt) { this.scheduleOptimisticDispatch(name, execution.id, scheduledAt); } return execution.id; } /** * How long a `running` row may go without a lease renewal before the * sweep assumes the instance running it crashed. Shared by the sweep's * crash detection and the heartbeat cadence so the two can't drift. */ protected crashThresholdMs(registration: JobRuntimeRegistration): number { return registration.options.timeout ? this.dt.duration(registration.options.timeout).as("milliseconds") * 2 : this.config.runTimeout; } /** * While a handler runs, keep the row's `updatedAt` fresh so another * instance's sweep can tell a long-running job from a crashed one — the * sweep treats `max(startedAt, updatedAt)` as the lease. Self-stops when * the row leaves `running` (finished, cancelled, swept elsewhere). */ protected startLeaseHeartbeat( executionId: string, registration: JobRuntimeRegistration, ): ReturnType<typeof setInterval> { const intervalMs = Math.max( 250, Math.floor(this.crashThresholdMs(registration) / 3), ); const timer = setInterval(() => { void (async () => { try { await this.executions.updateOne( { id: { eq: executionId }, status: { eq: "running" } }, { status: "running" }, ); } catch { clearInterval(timer); } })(); }, intervalMs); return timer; } /** * Insert a keyed execution row, resolving the dedup pre-check/insert * race: a concurrent same-key push can land between the read and this * insert, so a unique violation on (jobName, key) is settled by * returning the winner's row instead of throwing. */ protected async createKeyedExecution(fields: { jobName: string; key: string; payload?: Record<string, unknown>; status: JobStatus; priority: number; maxAttempts: number; scheduledAt?: string; triggeredBy?: string; triggeredByName?: string; organizationId?: string; }): Promise<{ id: string; created: boolean }> { try { const execution = await this.executions.create(fields); return { id: execution.id, created: true }; } catch (e) { if (e instanceof DbConflictError) { const winner = await this.executions.findMany({ where: { jobName: { eq: fields.jobName }, key: { eq: fields.key } }, limit: 1, }); if (winner.length > 0) { return { id: winner[0].id, created: false }; } } throw e; } } /** * Ceiling for the optimistic local timer. Past one day the sweep is the * delivery mechanism anyway, and a 32-bit `setTimeout` overflows at * ~24.85 days — an unclamped timer would fire immediately and run the * job weeks early. */ protected readonly maxOptimisticDelayMs = 24 * 60 * 60 * 1000; /** * Fire a local setTimeout so delayed/retrying rows dispatch as close to * `scheduledAt` as possible, rather than waiting for the next sweep tick. * No-op on stateless runtimes where timers won't survive, and for delays * beyond `maxOptimisticDelayMs` (the sweep handles both). */ protected scheduleOptimisticDispatch( jobName: string, executionId: string, scheduledAt: string, ): void { const delayMs = Math.max( 0, new Date(scheduledAt).getTime() - this.dt.nowMillis(), ); if (delayMs > this.maxOptimisticDelayMs) { return; } this.dt.createTimeout(() => { void this.dispatchScheduled(jobName, executionId); }, delayMs); } public async pushMany( name: string, items: Array<PushManyItem>, ): Promise<string[]> { if (items.length === 0) return []; const registration = this.getRegistration(name); if (registration.kind !== "queue") { throw new AlephaError( `Job '${name}' is not queue-mode (no schema declared).`, ); } const opts = registration.options; const maxAttempts = (opts.retry?.retries ?? 0) + 1; const keyed: PushManyItem[] = []; const bulk: Array<{ jobName: string; payload: Record<string, unknown>; status: JobStatus; priority: number; maxAttempts: number; scheduledAt?: string; }> = []; for (const item of items) { const validated = this.alepha.codec.validate(opts.schema!, item.payload); if (item.key) { keyed.push({ ...item, payload: validated as Static<TSchema> }); continue; } const isDelayed = item.delay || item.scheduledAt; const status: JobStatus = isDelayed ? "scheduled" : "pending"; let scheduledAt: string | undefined; if (item.scheduledAt) { scheduledAt = item.scheduledAt.toISOString(); } else if (item.delay) { scheduledAt = this.dt .now() .add(this.dt.duration(item.delay)) .toISOString(); } bulk.push({ jobName: name, payload: validated as Record<string, unknown>, status, priority: PRIORITY_MAP[item.priority ?? opts.priority ?? "normal"], maxAttempts, scheduledAt, }); } const ids: string[] = []; for (const item of keyed) { const id = await this.push(name, item.payload, { key: item.key, delay: item.delay, priority: item.priority, scheduledAt: item.scheduledAt, }); ids.push(id); } if (bulk.length > 0) { const created = await this.executions.createMany(bulk); // Collect pending rows so we hand them to the dispatcher in a single // batch — the queue dispatcher can fan them out in one network call. const toDispatch: Array<{ jobName: string; executionId: string }> = []; for (const exec of created) { ids.push(exec.id); if (exec.status === "pending" && !this.stopping) { toDispatch.push({ jobName: name, executionId: exec.id }); } else if ( exec.status === "scheduled" && exec.scheduledAt && !this.stopping ) { this.scheduleOptimisticDispatch(name, exec.id, exec.scheduledAt); } } if (toDispatch.length > 0) { await this.dispatchMany(toDispatch); } } this.log.debug(`pushMany '${name}': ${ids.length} jobs created`, { bulk: bulk.length, keyed: keyed.length, }); return ids; } /** * Hand a single execution to the active `JobDispatcher`. Whether that * results in a queue send or in-process execution depends on which * dispatcher is wired (see {@link JobDispatcher}). */ protected async dispatch( jobName: string, executionId: string, ): Promise<void> { if (this.stopping) return; await this.dispatcher.dispatch(jobName, executionId); } /** * Batched variant. Used by `pushMany` so a backing queue can do a single * batch network call (e.g. Cloudflare Queues `sendBatch`). */ protected async dispatchMany( items: Array<{ jobName: string; executionId: string }>, ): Promise<void> { if (this.stopping || items.length === 0) return; await this.dispatcher.dispatchMany(items); } // --- Manual trigger (admin / CLI) ------------------------------------------------------------------------------ public async trigger( name: string, context?: JobTriggerContext, ): Promise<void> { const registration = this.getRegistration(name); if (registration.kind === "cron") { await this.runCronLocked(registration, { triggeredBy: context?.triggeredBy, triggeredByName: context?.triggeredByName, }); return; } // queue-mode: treat as a normal push with the given payload if (!context?.payload) { throw new AlephaError( `Queue-mode job '${name}' requires a payload for manual trigger.`, ); } await this.push(name, context.payload, { triggeredBy: context.triggeredBy, triggeredByName: context.triggeredByName, }); } // --- Cancel ---------------------------------------------------------------------------------------------------- public async cancel( executionId: string, context?: CancelContext, ): Promise<void> { const execution = await this.executions.findById(executionId); if (!execution) { throw new AlephaError(`Execution not found: ${executionId}`); } if ( execution.status === "ok" || execution.status === "error" || execution.status === "cancelled" ) { throw new AlephaError( `Cannot cancel execution in '${execution.status}' status`, ); } const controller = this.abortControllers.get(executionId); if (controller) controller.abort(); await this.executions.updateById(executionId, { status: "cancelled", key: null, cancelledBy: context?.cancelledBy, cancelledByName: context?.cancelledByName, completedAt: this.dt.nowISOString(), }); this.log.info(`Cancelled execution ${executionId}`, { jobName: execution.jobName, cancelledBy: context?.cancelledByName ?? context?.cancelledBy, }); } // --- Queue consumer (called by JobQueueProvider) -------------------------------------------------------------- public async processExecution( jobName: string, executionId: string, ): Promise<void> { const registration = this.jobs.get(jobName); if (!registration) { this.log.warn(`Unknown job '${jobName}' — skipping execution`, { executionId, }); return; } // Both `queue` and cron-with-retry execute through the outbox path — // the DB-level `claim()` is the actual concurrency guard. if (registration.kind !== "queue" && !registration.options.retry) { this.log.warn( `Job '${jobName}' has no outbox path (no schema and no retry) — skipping`, { executionId }, ); return; } const promise = this.processQueueExecution(registration, executionId); this.inFlight.add(promise); try { await promise; } finally { this.inFlight.delete(promise); } } protected async processQueueExecution( registration: JobRuntimeRegistration, executionId: string, ): Promise<void> { const jobName = registration.name; const opts = registration.options; const record = opts.record ?? "error"; const execution = await this.claim(executionId); if (!execution) { this.log.debug(`Execution ${executionId} already claimed, skipping`); return; } const contextId = this.alepha.context.createContextId(); this.perExecutionLogs.set(contextId, []); const abortController = new AbortController(); this.abortControllers.set(executionId, abortController); let timeoutId: ReturnType<typeof setTimeout> | undefined; if (opts.timeout) { const ms = this.dt.duration(opts.timeout).as("milliseconds"); timeoutId = setTimeout(() => abortController.abort(), ms); } const leaseTimer = this.startLeaseHeartbeat(executionId, registration); const now = this.dt.now(); try { await this.alepha.context.run( async () => { await this.alepha.events.emit("job:begin", { name: jobName, now, executionId, }); try { await opts.handler({ payload: execution.payload, attempt: execution.attempt, now, signal: abortController.signal, executionId, }); // Success: either DELETE (keepLastSuccess=0 or record=error) // or UPDATE to 'ok' (record=all and keepLastSuccess>0). const keepSuccess = record === "all" && this.config.keepLastSuccess > 0; if (keepSuccess) { await this.executions.updateById(executionId, { status: "ok", completedAt: this.dt.nowISOString(), key: null, }); } else { await this.executions.deleteById(executionId); } await this.alepha.events.emit( "job:success", { name: jobName, executionId }, { catch: true }, ); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); if (abortController.signal.aborted) { const current = await this.executions.findById(executionId); if (current?.status === "cancelled") { await this.alepha.events.emit( "job:cancel", { name: jobName, executionId }, { catch: true }, ); return; } } await this.handleFailure( executionId, registration, execution.attempt, err, contextId, ); } finally { if (timeoutId) clearTimeout(timeoutId); this.abortControllers.delete(executionId); await this.alepha.events.emit( "job:end", { name: jobName, executionId }, { catch: true }, ); } }, { context: contextId }, ); } finally { clearInterval(leaseTimer); this.perExecutionLogs.delete(contextId); } } /** * Transition pending → running and return the post-update row. * Two round-trips: read current attempt, then guarded UPDATE … RETURNING. * Returns null when the row is gone or already claimed by another worker. * The returned row replaces a separate post-claim findById, so the dispatch * path is 2 queries instead of 3. */ protected async claim(executionId: string) { const current = await this.executions.findById(executionId); if (!current) return null; try { return await this.executions.updateOne( { id: { eq: executionId }, status: { eq: "pending" } }, { status: "running", attempt: current.attempt + 1, startedAt: this.dt.nowISOString(), }, ); } catch (e) { if (e instanceof DbEntityNotFoundError) return null; throw e; } } protected async handleFailure( executionId: string, registration: JobRuntimeRegistration, currentAttempt: number, error: Error, contextId: string, ): Promise<void> { const jobName = registration.name; const opts = registration.options; const retry = opts.retry; const maxAttempts = (retry?.retries ?? 0) + 1; // `retries: 2` means "1 initial + 2 retries = 3 total attempts". Retry // while we have not yet *executed* the maxAttempts'th attempt — i.e. // currentAttempt (the one that just failed) is strictly less than // maxAttempts. The off-by-one fix: previously this was // `currentAttempt + 1 < maxAttempts`, which only ran 2 attempts for // `retries: 2`. const canRetry = retry && currentAttempt < maxAttempts && (retry.when ? retry.when(error) : true); if (canRetry) { // Retries are sweep-driven: write the row as `scheduled` with // `scheduledAt = now`. The next sweep tick (every `sweepCron`, // default 15 minutes) re-dispatches it. This means the first retry // can land anywhere from a few seconds to ~15 minutes later — the // exact moment depends on when the next sweep tick fires. // // We deliberately do NOT use exponential backoff or a local timer. // - Exponential backoff was platform-dependent (precise on Node // via setTimeout, but degraded to "next sweep" on Cloudflare // Workers where timers don't survive invocations). The behavior // is now identical everywhere. // - Sweep-only retry keeps the retry path observable from a single // place (the DB) and removes a class of races between the timer // and the sweep. const nextScheduledAt = this.dt.nowISOString(); this.log.info( `Job '${jobName}' failed, scheduling retry ${currentAttempt + 1}/${maxAttempts} (sweep will pick up)`, { executionId, error: error.message }, ); // Guard with `status: running` so a concurrent cancel that has already // flipped the row to 'cancelled' is not overwritten by the retry write. await this.guardedUpdate( executionId, ["running"], { status: "scheduled", error: error.message, scheduledAt: nextScheduledAt, logs: this.snapshotLogs(contextId), }, "retry-after-failure", ); } else { this.log.info( `Job '${jobName}' dead after ${currentAttempt} attempt(s)`, { executionId, error: error.message }, ); await this.guardedUpdate( executionId, ["running"], { status: "error", error: error.message, completedAt: this.dt.nowISOString(), key: null, logs: this.snapshotLogs(contextId), }, "terminal-failure", ); } await this.alepha.events.emit( "job:error", { name: jobName, error, executionId }, { catch: true }, ); } protected snapshotLogs(contextId: string): LogEntry[] | undefined { const entries = this.perExecutionLogs.get(contextId); if (!entries || entries.length === 0) return undefined; const max = this.config.logMaxEntries; if (max === 0) return undefined; if (entries.length <= max) return [...entries]; const truncated = entries.slice(0, max); truncated.push({ level: "WARN", message: `Log entries truncated at ${max}`, timestamp: this.dt.nowMillis(), service: "alepha.jobs", module: "JobProvider", } as LogEntry); return truncated; } // --- Sweep ---------------------------------------------------------------------------------------------------- protected async sweep(): Promise<void> { if (this.stopping) return; this.log.trace("Starting job sweep"); const now = this.dt.now(); const nowIso = now.toISOString(); try { // 1. Due scheduled rows → pending + dispatch const dueWhere = this.executions.createQueryWhere(); dueWhere.status = { eq: "scheduled" }; dueWhere.scheduledAt = { lte: nowIso }; const due = await this.executions.findMany({ where: dueWhere, orderBy: { column: "priority", direction: "asc" }, }); for (const exec of due) { if (!this.jobs.has(exec.jobName)) continue; await this.executions.updateById(exec.id, { status: "pending" }); await this.dispatchSafe(exec.jobName, exec.id); } // 2. Stale pending rows → re-dispatch const staleIso = now .subtract(this.config.staleThreshold, "millisecond") .toISOString(); const staleWhere = this.executions.createQueryWhere(); staleWhere.status = { eq: "pending" }; staleWhere.createdAt = { lte: staleIso }; const stale = await this.executions.findMany({ where: staleWhere, orderBy: { column: "priority", direction: "asc" }, }); for (const exec of stale) { if (!this.jobs.has(exec.jobName)) continue; await this.dispatchSafe(exec.jobName, exec.id); } // 3. Crashed running rows → mark as failed + apply retry const runningWhere = this.executions.createQueryWhere(); runningWhere.status = { eq: "running" }; const running = await this.executions.findMany({ where: runningWhere }); const nowMs = now.valueOf(); for (const exec of running) { const reg = this.jobs.get(exec.jobName); if (!reg) continue; if (this.abortControllers.has(exec.id)) continue; // still alive locally const crashThresholdMs = this.crashThresholdMs(reg); const startedAtMs = exec.startedAt ? new Date(exec.startedAt).getTime() : 0; // The lease is whichever is fresher: the claim (startedAt) or the // last heartbeat (updatedAt). A legitimately long-running job on // another instance keeps renewing; only a stale lease is a crash. const heartbeatMs = exec.updatedAt ? new Date(exec.updatedAt).getTime() : 0; const lastAliveMs = Math.max(startedAtMs, heartbeatMs); if (lastAliveMs > 0 && nowMs - lastAliveMs > crashThresholdMs) { this.log.warn( `Sweep: marking crashed ${exec.jobName} (${exec.id}) as failed`, ); const err = new Error( "Execution assumed crashed (recovered by sweep)", ); await this.handleFailure(exec.id, reg, exec.attempt, err, ""); } } } catch (e) { this.log.error("Sweep failed", { error: e }); } } protected async dispatchSafe( jobName: string, executionId: string, ): Promise<void> { try { await this.dispatch(jobName, executionId); } catch (e) { this.log.warn(`Sweep failed to dispatch ${jobName} (${executionId})`, e); } } /** * Move a row from `scheduled` → `pending` and dispatch it. * Used by the optimistic retry/delay timer. If the sweep has already moved * the row, or another worker has claimed it, the UPDATE guard fails silently. * The `scheduledAt <= now` condition keeps a stray early timer (clock skew, * timer overflow) from promoting a row ahead of schedule. */ protected async dispatchScheduled( jobName: string, executionId: string, ): Promise<void> { if (this.stopping) return; try { await this.executions.updateOne( { id: { eq: executionId }, status: { eq: "scheduled" }, scheduledAt: { lte: this.dt.nowISOString() }, }, { status: "pending" }, ); await this.dispatchSafe(jobName, executionId); } catch { // Row already transitioned (sweep ran, another worker claimed, etc.) } } protected async trimRingBuffers(): Promise<void> { for (const [jobName, reg] of this.jobs) { const okLimit = reg.options.keep?.ok ?? this.config.keepLastSuccess; const errLimit = reg.options.keep?.error ?? this.config.keepLastError; if (okLimit > 0) { await this.trimByStatus(jobName, "ok", okLimit); } if (errLimit > 0) { await this.trimByStatus(jobName, "error", errLimit); } } } protected async trimByStatus( jobName: string, status: "ok" | "error", keep: number, ): Promise<void> { try { const rows = await this.executions.findMany({ where: { jobName: { eq: jobName }, status: { eq: status } }, orderBy: { column: "createdAt", direction: "desc" }, limit: keep + 50, }); if (rows.length <= keep) return; const toDelete = rows.slice(keep).map((r) => r.id); if (toDelete.length > 0) { await this.executions.deleteMany({ id: { inArray: toDelete } }); this.log.debug( `Trimmed ${toDelete.length} ${status} rows for '${jobName}'`, ); } } catch (e) { this.log.warn(`Failed to trim ${status} rows for '${jobName}'`, e); } } // --- Lifecycle ----------------------------------------------------------------------------------------------- protected readonly onStart = $hook({ on: "start", handler: async () => { // Summarize effective modes once at start so operators can see at a // glance what each job will do. No validation is needed here: queue // jobs gracefully fall back to direct mode when AlephaApiJobsQueue // isn't loaded. const modes: Record<JobEffectiveMode, number> = { cron: 0, queue: 0, direct: 0, }; const perJob: Record<string, JobEffectiveMode> = {}; for (const [name] of this.jobs) { const m = this.effectiveMode(name); modes[m]++; perJob[name] = m; } this.log.info(`Job system OK`, { modes, jobs: this.jobs.size, perJob, }); // Capture logs per execution context. this.alepha.events.on("log", ({ entry }) => { const ctx = entry.context; if (!ctx) return; const entries = this.perExecutionLogs.get(ctx); if (!entries) return; entries.push(entry); }); if (!this.alepha.isServerless()) { await this.sweep(); } // Sweep + trim cron registrations live in the constructor — see // the note there. CronProvider's `start` hook (which runs before // ours via DI order) has already booted them by the time we get // here. }, }); protected readonly onStop = $hook({ on: "stop", handler: async () => { this.stopping = true; if (this.inFlight.size > 0) { this.log.info(`Draining ${this.inFlight.size} in-flight job(s)...`); await Promise.race([ Promise.allSettled([...this.inFlight]), this.dt.wait([this.config.drainTimeout, "millisecond"]), ]); } if (this.abortControllers.size > 0) { this.log.warn( `Aborting ${this.abortControllers.size} remaining job(s) after drain timeout`, ); for (const controller of this.abortControllers.values()) { controller.abort(); } } }, }); // --- Helpers ------------------------------------------------------------------------------------------------- protected getRegistration(name: string): JobRuntimeRegistration { const registration = this.jobs.get(name); if (!registration) { throw new AlephaError(`Job not registered: ${name}`); } return registration; } } export { PRIORITY_MAP, PRIORITY_REVERSE };