UNPKG

alepha

Version:

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

1,432 lines 56 kB
import { $atom, $hook, $inject, $module, $state, Alepha, AlephaError, KIND, PipelinePrimitive, createPrimitive, z } from "alepha"; import { AlephaBackground, BackgroundTaskProvider } from "alepha/background"; import { AlephaLock, LockProvider } from "alepha/lock"; import { $queue, AlephaQueue } from "alepha/queue"; import { AlephaScheduler, CronProvider } from "alepha/scheduler"; import { $secure } from "alepha/security"; import { $action, NotFoundError, okSchema } from "alepha/server"; import { $logger, logEntrySchema } from "alepha/logger"; import { $entity, $repository, DbConflictError, DbEntityNotFoundError, db, sql } from "alepha/orm"; import { CryptoProvider } from "alepha/crypto"; import { DateTimeProvider } from "alepha/datetime"; //#region ../../src/api/jobs/schemas/jobExecutionQuerySchema.ts const jobExecutionQuerySchema = z.object({ status: z.enum([ "pending", "running", "scheduled", "ok", "error", "cancelled" ]).optional(), limit: z.integer().min(1).max(200).default(20).optional() }); //#endregion //#region ../../src/api/jobs/entities/jobExecutionEntity.ts /** * Job execution record. * * Stores durable state for queue-mode jobs (outbox pattern) and error records * for cron-mode jobs. Successful executions are trimmed by the sweep to keep * the last N rows per job (configurable via `jobConfig.keepLastSuccess`). * * Status transitions: * - queue push → pending (or `scheduled` if `delay`/`scheduledAt` was given) * - worker claim → running * - success → ok (or row deleted, depending on `record` and `keepLastSuccess`) * - terminal failure → error * - retryable failure → scheduled (with scheduledAt = now; sweep picks it up) * - delay → scheduled (with scheduledAt = now + delay) * - sweep picks due ones → pending * - cancel → cancelled */ const jobExecutionEntity = $entity({ name: "job_executions", schema: z.object({ id: db.primaryKey(z.uuid()), createdAt: db.createdAt(), updatedAt: db.updatedAt(), jobName: z.text(), key: z.text().nullable().optional(), /** * Owning tenant for this execution, when it was pushed in (or for) a tenant * context. Used to org-scope tenant-facing views — notably the notification * admin list, which is backed by this outbox. Nullable: cron / global / non- * tenant pushes carry none. Deliberately NOT `db.organization()`: the job * worker + sweep must see every org's rows, so this stays a plain, * non-auto-scoping column rather than an auto-filtered one. */ organizationId: z.uuid().nullable().optional(), status: db.default(z.enum([ "pending", "running", "scheduled", "ok", "error", "cancelled" ]), "pending"), priority: db.default(z.integer().min(0).max(3), 2), attempt: db.default(z.integer(), 0), maxAttempts: db.default(z.integer(), 1), payload: z.record(z.text(), z.any()).optional(), scheduledAt: z.datetime().optional(), startedAt: z.datetime().optional(), completedAt: z.datetime().optional(), error: z.text().optional(), logs: z.array(logEntrySchema).optional(), triggeredBy: z.text().optional(), triggeredByName: z.text().optional(), cancelledBy: z.text().optional(), cancelledByName: z.text().optional() }), indexes: [ { columns: [ "jobName", "status", "scheduledAt" ] }, { columns: [ "jobName", "status", "createdAt" ] }, { columns: ["jobName", "startedAt"] }, { columns: ["jobName", "key"], unique: true } ] }); //#endregion //#region ../../src/api/jobs/schemas/jobExecutionResourceSchema.ts /** * Public-facing schema for a job execution row. * * Diverges from the raw entity in two places, both for API ergonomics: * * - `priority` is exposed as the **string enum** (`critical`/`high`/...) * instead of the numeric value used internally for SQL ordering. The * `JobService` is responsible for the int → string transform. * - `can` derives the available admin actions from the row's status. */ const jobExecutionResourceSchema = jobExecutionEntity.schema.omit({ priority: true }).extend({ priority: z.enum([ "critical", "high", "normal", "low" ]), can: z.object({ retry: z.boolean(), cancel: z.boolean() }) }).meta({ title: "JobExecutionResource", description: "A job execution row with derived actions." }); //#endregion //#region ../../src/api/jobs/schemas/jobRegistrationSchema.ts const jobRegistrationSchema = z.object({ name: z.text(), description: z.text().optional(), type: z.enum([ "cron", "queue", "direct" ]).describe("Effective runtime mode. 'cron' = scheduled. 'queue' = push-driven, dispatched via AlephaApiJobsQueue. 'direct' = push-driven, processed in-process (no queue infrastructure loaded), with the sweep as the safety net."), priority: z.enum([ "critical", "high", "normal", "low" ]), cron: z.text().optional(), timeout: z.text().optional(), retry: z.object({ retries: z.integer() }).optional(), recent: z.object({ ok: z.integer(), error: z.integer(), lastRun: z.datetime().optional() }) }); //#endregion //#region ../../src/api/jobs/schemas/triggerJobSchema.ts const triggerJobSchema = z.object({ payload: z.record(z.text(), z.any()).optional() }); //#endregion //#region ../../src/api/jobs/schemas/jobConfigAtom.ts const jobConfig = $atom({ name: "alepha.jobs", description: "Configuration for the $job primitive.", schema: z.object({ sweepCron: z.text({ description: "Cron expression for the sweep tick. Must be minute-granular at minimum (cron resolution). On Cloudflare Workers this expression is emitted into wrangler.jsonc by the build." }), trimCron: z.text({ description: "Cron expression for the ring-buffer trim tick (per-job keepLastSuccess/keepLastError enforcement). Decoupled from `sweepCron` because trim is bounded by job execution rate, not retry latency — running it every sweep is wasted work for most apps." }), staleThreshold: z.integer().describe("Pending age (ms) before the sweep re-dispatches it."), runTimeout: z.integer().describe("Running age (ms) before assumed crash (fallback when no per-job timeout)."), keepLastSuccess: z.integer().describe("Max successful rows to keep per job. Set 0 to disable and delete on success."), keepLastError: z.integer().describe("Max error rows to keep per job."), logMaxEntries: z.integer().describe("Max log entries captured per execution."), drainTimeout: z.integer().describe("Max time (ms) to wait for in-flight jobs during shutdown.") }), default: { sweepCron: "*/15 * * * *", trimCron: "0 * * * *", staleThreshold: 3e5, runTimeout: 18e5, keepLastSuccess: 10, keepLastError: 10, logMaxEntries: 100, drainTimeout: 3e4 } }); //#endregion //#region ../../src/api/jobs/providers/JobDispatcher.ts /** * Abstract dispatcher for queued/direct job executions. * * The default implementation, {@link DirectJobDispatcher}, runs the handler * in-process after the caller's `push()` returns — fast and dependency-free. * * `AlephaApiJobsQueue` substitutes this with `JobQueueProvider`, which * publishes the executionId to `AlephaQueue` so a worker pool can consume * the work asynchronously. * * Substitute via DI: * ```ts * Alepha.create() * .with({ provide: JobDispatcher, use: MyCustomDispatcher }) * .with(AlephaApiJobs); * ``` * * The `kind` getter is read by the `JobProvider.effectiveMode` accessor * and by the admin UI so users can see which dispatcher is currently active. */ var JobDispatcher = class { /** * Optional batch dispatch. The default implementation loops, but * dispatchers backed by a real queue should override this to use the * provider's batch send (e.g. Cloudflare Queues `sendBatch`). */ async dispatchMany(items) { for (const item of items) await this.dispatch(item.jobName, item.executionId); } }; //#endregion //#region ../../src/api/jobs/providers/DirectJobDispatcher.ts /** * Default `JobDispatcher` for environments without `AlephaApiJobsQueue`. * * Runs `JobProvider.processExecution` in the background — the caller's * `push()` returns immediately while the handler continues to completion * in the same process. The DB outbox row is the durability guarantee: * if the process dies before the handler finishes, the next sweep tick * picks the row up and re-dispatches. * * Keeping the isolate alive past the HTTP response (Cloudflare Workers) vs. * relying on the event loop (Node/Vercel) is delegated to * {@link BackgroundTaskProvider.defer} — this dispatcher stays * platform-agnostic. The DB outbox row remains the durability guarantee: if * the process dies mid-handler, the next sweep re-dispatches. */ var DirectJobDispatcher = class extends JobDispatcher { kind = "direct"; alepha = $inject(Alepha); background = $inject(BackgroundTaskProvider); log = $logger(); jobProviderRef; getJobProvider() { if (!this.jobProviderRef) this.jobProviderRef = this.alepha.inject(JobProvider); return this.jobProviderRef; } async dispatch(jobName, executionId) { this.background.defer(() => this.getJobProvider().processExecution(jobName, executionId).catch((err) => { this.log.warn(`Direct execution failed for '${jobName}' (sweep will retry)`, err); })); } }; //#endregion //#region ../../src/api/jobs/providers/JobQueueProvider.ts /** * Queue-backed `JobDispatcher` registered by `AlephaApiJobsQueue`. * * Extends {@link JobDispatcher} and substitutes the default * `DirectJobDispatcher` so that `$job.push()` is delivered through * `AlephaQueue` (e.g. Cloudflare Queues, Redis, in-memory) instead of * being processed in-process. * * The class is also kept as a `JobQueueProvider` export name for backwards * compatibility — it has always been the queue path's entry point. */ var JobQueueProvider = class extends JobDispatcher { kind = "queue"; alepha = $inject(Alepha); jobProviderRef; getJobProvider() { if (!this.jobProviderRef) this.jobProviderRef = this.alepha.inject(JobProvider); return this.jobProviderRef; } queue = $queue({ name: "api:jobs:dispatch", schema: z.object({ jobName: z.text(), executionId: z.text() }), handler: async (msg) => { await this.getJobProvider().processExecution(msg.payload.jobName, msg.payload.executionId); } }); async dispatch(jobName, executionId) { await this.queue.push({ jobName, executionId }); } /** * Fan-out to a single variadic `queue.push(...payloads)` call so the * underlying queue provider can batch the network round-trips when it * supports it (Cloudflare Queues, Redis pipelines). */ async dispatchMany(items) { if (items.length === 0) return; await this.queue.push(...items); } /** * Backwards-compatible alias for {@link dispatch}. Older code paths called * `JobQueueProvider.push(jobName, executionId)` directly; new code should * go through the `JobDispatcher.dispatch` API. */ async push(jobName, executionId) { return this.dispatch(jobName, executionId); } }; //#endregion //#region ../../src/api/jobs/providers/JobProvider.ts const PRIORITY_MAP = { critical: 0, high: 1, normal: 2, low: 3 }; const PRIORITY_REVERSE = { 0: "critical", 1: "high", 2: "normal", 3: "low" }; /** * 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. */ var JobProvider = class { alepha = $inject(Alepha); dt = $inject(DateTimeProvider); cronProvider = $inject(CronProvider); lockProvider = $inject(LockProvider); crypto = $inject(CryptoProvider); 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. */ dispatcherRef; get dispatcher() { if (!this.dispatcherRef) this.dispatcherRef = this.alepha.has(JobQueueProvider) ? this.alepha.inject(JobQueueProvider) : this.alepha.inject(DirectJobDispatcher); return this.dispatcherRef; } log = $logger(); executions = $repository(jobExecutionEntity); jobs = /* @__PURE__ */ new Map(); inFlight = /* @__PURE__ */ new Set(); abortControllers = /* @__PURE__ */ new Map(); perExecutionLogs = /* @__PURE__ */ new Map(); stopping = false; constructor() { 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); } registerJob(name, options) { 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 = options.cron ? "cron" : "queue"; if (kind === "cron" && options.record === void 0) 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); } }); } getRegisteredJobs() { 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. */ effectiveMode(name) { if (this.getRegistration(name).kind === "cron") return "cron"; return this.dispatcher.kind; } async runCron(name) { 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). */ async runCronLocked(registration, ctx) { if (this.stopping) return; const useLock = registration.options.lock !== false; if (useLock) { if (!await this.acquireCronLock(registration)) { 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: void 0, 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`". */ async enqueueCronExecution(registration, ctx) { const opts = registration.options; const maxAttempts = (opts.retry?.retries ?? 0) + 1; const execution = await this.executions.create({ jobName: registration.name, payload: void 0, 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. */ async acquireCronLock(registration) { const lockKey = this.cronLockKey(registration.name); const ttlMs = registration.options.timeout ? this.dt.duration(registration.options.timeout).as("milliseconds") * 2 : 300 * 1e3; const value = `${this.lockHolderId},${this.dt.nowISOString()}`; try { const [holderId] = (await this.lockProvider.set(lockKey, value, true, ttlMs)).split(","); return holderId === this.lockHolderId; } catch (e) { this.log.warn(`Cron lock acquire failed for '${registration.name}'`, e); return true; } } /** * 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. */ async guardedUpdate(executionId, expectedStatuses, patch, label) { 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; } } async releaseCronLock(registration) { 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); } } cronLockKey(jobName) { 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. */ lockHolderIdValue; get lockHolderId() { 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. */ async executeInline(registration, executionId, ctx) { 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; 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: void 0, 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); } } async writeTerminalRow(executionId, jobName, status, fields) { try { const logs = status === "error" ? this.snapshotLogs(fields.context) : void 0; await this.executions.create({ id: executionId, jobName, status, payload: fields.payload, 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); } } async push(name, payload, options) { 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 status = options?.delay || options?.scheduledAt ? "scheduled" : "pending"; let scheduledAt; 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) { 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, status, priority, maxAttempts, scheduledAt, triggeredBy: options.triggeredBy, triggeredByName: options.triggeredByName, organizationId: options.organizationId }); if (!created) 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, 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. */ crashThresholdMs(registration) { 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). */ startLeaseHeartbeat(executionId, registration) { const intervalMs = Math.max(250, Math.floor(this.crashThresholdMs(registration) / 3)); const timer = setInterval(() => { (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. */ async createKeyedExecution(fields) { try { return { id: (await this.executions.create(fields)).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. */ maxOptimisticDelayMs = 1440 * 60 * 1e3; /** * 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). */ scheduleOptimisticDispatch(jobName, executionId, scheduledAt) { const delayMs = Math.max(0, new Date(scheduledAt).getTime() - this.dt.nowMillis()); if (delayMs > this.maxOptimisticDelayMs) return; this.dt.createTimeout(() => { this.dispatchScheduled(jobName, executionId); }, delayMs); } async pushMany(name, items) { 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 = []; const bulk = []; for (const item of items) { const validated = this.alepha.codec.validate(opts.schema, item.payload); if (item.key) { keyed.push({ ...item, payload: validated }); continue; } const status = item.delay || item.scheduledAt ? "scheduled" : "pending"; let scheduledAt; 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, status, priority: PRIORITY_MAP[item.priority ?? opts.priority ?? "normal"], maxAttempts, scheduledAt }); } const ids = []; 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); const toDispatch = []; 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}). */ async dispatch(jobName, executionId) { 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`). */ async dispatchMany(items) { if (this.stopping || items.length === 0) return; await this.dispatcher.dispatchMany(items); } async trigger(name, context) { const registration = this.getRegistration(name); if (registration.kind === "cron") { await this.runCronLocked(registration, { triggeredBy: context?.triggeredBy, triggeredByName: context?.triggeredByName }); return; } 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 }); } async cancel(executionId, context) { 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 }); } async processExecution(jobName, executionId) { const registration = this.jobs.get(jobName); if (!registration) { this.log.warn(`Unknown job '${jobName}' — skipping execution`, { executionId }); return; } 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); } } async processQueueExecution(registration, executionId) { 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; 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 }); if (record === "all" && this.config.keepLastSuccess > 0) 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) { if ((await this.executions.findById(executionId))?.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. */ async claim(executionId) { 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; } } async handleFailure(executionId, registration, currentAttempt, error, contextId) { const jobName = registration.name; const retry = registration.options.retry; const maxAttempts = (retry?.retries ?? 0) + 1; if (retry && currentAttempt < maxAttempts && (retry.when ? retry.when(error) : true)) { const nextScheduledAt = this.dt.nowISOString(); this.log.info(`Job '${jobName}' failed, scheduling retry ${currentAttempt + 1}/${maxAttempts} (sweep will pick up)`, { executionId, error: error.message }); 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 }); } snapshotLogs(contextId) { const entries = this.perExecutionLogs.get(contextId); if (!entries || entries.length === 0) return void 0; const max = this.config.logMaxEntries; if (max === 0) return void 0; 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" }); return truncated; } async sweep() { if (this.stopping) return; this.log.trace("Starting job sweep"); const now = this.dt.now(); const nowIso = now.toISOString(); try { 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); } 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); } 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; const crashThresholdMs = this.crashThresholdMs(reg); const startedAtMs = exec.startedAt ? new Date(exec.startedAt).getTime() : 0; 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 = /* @__PURE__ */ 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 }); } } async dispatchSafe(jobName, executionId) { 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. */ async dispatchScheduled(jobName, executionId) { 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 {} } async trimRingBuffers() { 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); } } async trimByStatus(jobName, status, keep) { 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); } } onStart = $hook({ on: "start", handler: async () => { const modes = { cron: 0, queue: 0, direct: 0 }; const perJob = {}; 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 }); 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(); } }); 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(); } } }); getRegistration(name) { const registration = this.jobs.get(name); if (!registration) throw new AlephaError(`Job not registered: ${name}`); return registration; } }; //#endregion //#region ../../src/api/jobs/primitives/$job.ts /** * Job primitive for defining scheduled (cron) or queued (push) tasks. * * A job must be either **cron-only** (pass `cron`) or **queue-only** * (pass `schema`), never both. To run scheduled work that processes * payloads, compose two jobs: a cron that pushes payloads, and a * queue job that handles them. */ const $job = (options) => { return createPrimitive(JobPrimitive, options); }; var JobPrimitive = class extends PipelinePrimitive { jobProvider = $inject(JobProvider); get name() { return this.options.name ?? `${this.config.service.name}.${this.config.propertyKey}`; } onInit() { const handler = this.handler.run.bind(this.handler); this.jobProvider.registerJob(this.name, { ...this.options, handler }); } /** * Push a single payload to the queue (queue-mode only). */ async push(payload, options) { return this.jobProvider.push(this.name, payload, options); } /** * Push multiple payloads at once (queue-mode only). * Batched INSERT + batched queue send when supported. */ async pushMany(items) { return this.jobProvider.pushMany(this.name, items); } /** * Cancel a pending or running execution. */ async cancel(executionId) { return this.jobProvider.cancel(executionId); } /** * Manually fire a cron-mode job, or trigger a queue-mode job with an explicit payload. */ async trigger(context) { return this.jobProvider.trigger(this.name, context); } }; $job[KIND] = JobPrimitive; //#endregion //#region ../../src/api/jobs/services/JobService.ts /** * Admin surface for the job system. * * Six methods: list jobs, list executions, get execution, * trigger, retry, cancel. Everything else lives in events — any * analytics/observability is an external concern that subscribes * to `job:begin` / `job:success` / `job:error`. */ var JobService = class { alepha = $inject(Alepha); log = $logger(); jobProvider = $inject(JobProvider); executions = $repository(jobExecutionEntity); computeCan(status) { return { retry: status === "error" || status === "cancelled", cancel: status === "pending" || status === "running" || status === "scheduled" }; } /** * Convert the int-priority storage column into the public enum string. * The cast through `unknown` skips TypeScript's structural check between * the entity-level row (`priority: number`) and the resource schema * (`priority: enum`); the runtime values are correct. */ toResource(row) { return { ...row, priority: PRIORITY_REVERSE[row.priority] ?? "normal", can: this.computeCan(row.status) }; } /** * List every registered job with recent ok/error counts and lastRun. * One aggregate query covers all jobs. */ async listJobs() { const registry = this.jobProvider.getRegisteredJobs(); const aggRows = await this.executions.query((e) => sql` SELECT ${e.jobName} AS job_name, ${e.status} AS status, COUNT(*) AS count, MAX(${e.completedAt}) AS last_run FROM ${e} WHERE ${e.status} IN ('ok', 'error') GROUP BY ${e.jobName}, ${e.status} `, z.object({ job_name: z.string(), status: z.string(), count: z.string(), last_run: z.union([z.string(), z.number()]).nullable().optional() })); const toIso = (v) => { if (v === null || v === void 0) return void 0; if (typeof v === "number") return new Date(v).toISOString(); return v; }; const byJob = /* @__PURE__ */ new Map(); for (const row of aggRows) { const entry = byJob.get(row.job_name) ?? { ok: 0, error: 0 }; if (row.status === "ok") entry.ok = Number(row.count); if (row.status === "error") entry.error = Number(row.count); const iso = toIso(row.last_run); if (iso && (!entry.lastRun || iso > entry.lastRun)) entry.lastRun = iso; byJob.set(row.job_name, entry); } const result = []; for (const [name, reg] of registry) { const opts = reg.options; const counts = byJob.get(name) ?? { ok: 0, error: 0 }; result.push({ name, description: opts.description, type: this.jobProvider.effectiveMode(name), cron: opts.cron, priority: opts.priority ?? "normal", timeout: opts.timeout ? String(opts.timeout) : void 0, retry: opts.retry ? { retries: opts.retry.retries } : void 0, recent: counts }); } return result; } /** * Recent executions for a single job, ORDER BY startedAt DESC. */ async getExecutions(jobName, query = {}) { if (!this.jobProvider.getRegisteredJobs().has(jobName)) throw new NotFoundError(`Job not found: ${jobName}`); const where = this.executions.createQueryWhere(); where.jobName = { eq: jobName }; if (query.status) where.status = { eq: query.status }; return (await this.executions.findMany({ where, orderBy: { column: "startedAt", direction: "desc" }, limit: query.limit ?? 20 })).map((row) => this.toResource(row)); } /** * Full execution detail (includes captured logs). */ async getExecution(id) { const execution = await this.executions.findById(id); if (!execution) throw new NotFoundError(`Execution not found: ${id}`); return this.toResource(execution); } /** * Manual trigger (cron jobs) or push-with-payload (queue jobs). */ async triggerJob(name, context) { const job = this.alepha.primitives($job).find((j) => j.name === name); if (!job) throw new NotFoundError(`Job not found: ${name}`); this.log.info(`Triggering job '${name}'`, { triggeredBy: context?.triggeredByName ?? context?.triggeredBy }); await job.trigger(context); return { ok: true }; } /** * Retry a dead or cancelled execution by re-pushing with the original payload. */ async retryExecution(id, context) { const execution = await this.executions.findById(id); if (!execution) throw new NotFoundError(`Execution not found: ${id}`); if (execution.status !== "error" && execution.status !== "cancelled") throw new AlephaError(`Cannot retry execution in '${execution.status}' status`); const job = this.alepha.primitives($job).find((j) => j.name === execution.jobName); if (!job) throw new NotFoundError(`Job not found: ${execution.jobName}`); this.log.info(`Retrying execution ${id}`, { jobName: execution.jobName, previousStatus: execution.status, triggeredBy: context?.triggeredByName ?? context?.triggeredBy }); if (execution.payload) await job.push(execution.payload); else await job.trigger({ triggeredBy: context?.triggeredBy, triggeredByName: context?.triggeredByName }); return { ok: true }; } async cancelExecution(id, context) { this.log.info(`Cancelling execution ${id}`, { cancelledBy: context?.cancelledByName ?? context?.cancelledBy }); await this.job