alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
1,432 lines • 56 kB
JavaScript
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