js/jobs
js/jobs.ts
fino:jobs — durable background jobs and cron schedules over sqlite.
Work is described by fino:task tasks and delivered by name: a job row
stores {task, input}, so anything pushed survives a restart and runs
wherever that task name is registered. Task instances are worker
definitions, registered separately from pushes — inline (process()) to
run on this realm's loop, or as an exclusive worker pool (workers())
whose entry module default-exports a Task.
Delivery is at-least-once: a crash after side effects or an expired
lease reruns the job, so handlers must be idempotent. fino:task/durable
tasks are the built-in idempotency tool — a retried durable job resumes
its workflow run from the last checkpoint instead of starting over, and
its ctx.sleep() / ctx.waitForSignal() parks are woken by this module's
scheduler.
Two homes, one behavior
Under fino run, the orchestrator hosts the jobs service and this module
becomes a thin client over the injected fino:jobs/control facade — the
scheduler and its single database connection outlive the app realm. In
any other realm (tests, embedded library use), Jobs.open() hosts the
service locally. The sqlite file must have exactly one service per
process, and one process per file.
Cron expressions evaluate in UTC (see internal:jobs/cron); @daily
means midnight UTC.
import { task } from 'fino:task';
import { Jobs } from 'fino:jobs';
const greet = task({
name: 'greet',
run: async (input: { name: string }) => `hello ${input.name}`,
});
await using jobs = await Jobs.open({ path: './.fino/jobs.db', tasks: [greet] });
const job = await jobs.push('greet', { name: 'Ada' });
const done = await jobs.wait(job.id);
console.log(done.result);
Types
type JobStatus = 'pending' | 'claimed' | 'running' | 'waiting' | 'done' | 'error' | 'dead' | 'cancelled'
Job lifecycle states stored in JobRecord.status.
Terminal states are done, error, dead, and cancelled; wait()
resolves when a job reaches one of those states.
Interfaces
interface JobRetryPolicy {
Retry/backoff policy copied onto each job.
Attempts are delayed by baseMs * factor ** (attempt - 1), capped at
maxMs. When jitter is true, the computed delay is randomized between
zero and the capped delay to avoid retry bursts.
Every field is required here; JobsPushOptions.retry and
JobsScheduleOptions.retry accept a Partial<JobRetryPolicy> and merge it
over the service defaults.
import type { JobRetryPolicy } from 'fino:jobs';
const policy: JobRetryPolicy = {
maxAttempts: 5,
baseMs: 1000,
factor: 2,
maxMs: 60_000,
jitter: true,
};
Properties
maxAttempts: number
Total attempts before the job is dead-lettered, including the first run.
baseMs: number
Delay of the first retry, and the base of the exponential backoff.
factor: number
Multiplier applied per attempt: attempt n waits baseMs * factor ** (n - 1).
maxMs: number
Ceiling on the computed delay so backoff does not grow without bound.
jitter: boolean
When true, randomize each delay in [0, computed] to spread out retry bursts.
interface JobRecord {
Persisted job row returned by push(), get(), list(), job(), and
wait().
Timestamps are epoch milliseconds. input, result, waitingOn, and
error are JSON-compatible values stored in the jobs database. Active jobs
may have claimedBy / claimedUntil set while a worker owns their lease;
terminal jobs set finishedAt.
import { Jobs } from 'fino:jobs';
const jobs = await Jobs.open({ path: './.fino/jobs.db' });
const record = await jobs.get('job-id');
if (record !== null && record.status === 'error') {
console.error(`${record.task} failed after ${record.attempts} attempts:`, record.error?.message);
}
Properties
id: string
Unique job id assigned at push time.
queue: string
Queue the job belongs to; scopes dedupe and stats.
task: string
Registered task name that will run this job.
input: unknown
JSON-compatible input passed to the task handler.
status: JobStatus
Current lifecycle state.
priority: number
Sort priority among jobs due at the same time; higher runs first.
runAt: number
Earliest epoch-ms time the job may be claimed.
attempts: number
Number of attempts made so far.
maxAttempts: number
Maximum attempts before dead-lettering, copied from the retry policy.
backoff: JobRetryPolicy | null
Retry/backoff policy for this job, or null to use service defaults.
timeoutMs: number | null
Per-attempt timeout in milliseconds, or null for no timeout.
dedupeKey: string | null
Dedupe key that reserves the (queue, key) slot while active, or null.
claimedBy: string | null
Worker id holding the current lease, or null when unclaimed.
claimedUntil: number | null
Epoch-ms expiry of the current lease, or null when unclaimed.
workflowRunId: string | null
Durable workflow run id backing this job, or null for a plain job.
waitingOn: unknown
Signal or condition a parked durable job is waiting on, if any.
scheduleId: string | null
Id of the schedule that enqueued this job, or null if pushed directly.
result: unknown
Return value of a done job; otherwise null/undefined.
error: {
message: string;
stack?: string;
} | null
Failure details for an error or dead job, or null.
createdAt: number
Epoch-ms creation time.
updatedAt: number
Epoch-ms time of the last state change.
finishedAt: number | null
Epoch-ms time the job reached a terminal state, or null while active.
interface QueueStats {
Aggregate counts for one queue or all queues.
Returned by the live Jobs.stats() signal. oldestPendingAt is the oldest
due pending job timestamp, or null when there is no pending work.
import { Jobs } from 'fino:jobs';
const jobs = await Jobs.open({ path: './.fino/jobs.db' });
const stats = jobs.stats('emails');
stats.subscribe((s) => {
if (s.oldestPendingAt !== null && Date.now() - s.oldestPendingAt > 60_000) {
console.warn(`emails queue is backing up: ${s.pending} pending`);
}
});
Properties
pending: number
Jobs that are due and awaiting a claim.
running: number
Jobs currently executing on a worker.
waiting: number
Durable jobs parked on a signal or sleep.
done: number
Jobs that completed successfully.
error: number
Jobs that failed their last attempt and may still retry.
dead: number
Jobs that exhausted retries or were dead-lettered.
cancelled: number
Jobs cancelled before completion.
oldestPendingAt: number | null
Epoch-ms timestamp of the oldest due pending job, or null when idle.
interface ScheduleRecord {
Persisted schedule row returned by schedule() and schedules().
spec is the normalized cron or interval expression. nextRunAt and
lastRunAt are epoch milliseconds; lastJobId links to the most recently
enqueued job when one exists.
import { Jobs } from 'fino:jobs';
const jobs = await Jobs.open({ path: './.fino/jobs.db' });
for (const s of await jobs.schedules()) {
console.log(`${s.id} (${s.spec}) next runs at ${new Date(s.nextRunAt).toISOString()}`);
}
Properties
id: string
Schedule name, unique per service; passed to schedule()/unschedule().
task: string
Task name enqueued on each occurrence.
input: unknown
JSON-compatible input passed to every enqueued job.
queue: string
Queue that jobs from this schedule are pushed to.
spec: string
Normalized cron or interval expression driving the cadence.
enabled: boolean
Whether the schedule is currently active.
overlap: 'skip' | 'allow'
Overlap policy when a prior scheduled job is still active.
catchup: 'skip' | 'one'
Catch-up policy for occurrences missed during downtime.
retry: JobRetryPolicy | null
Retry policy copied onto each enqueued job, or null for defaults.
nextRunAt: number
Epoch-ms time of the next due occurrence.
lastRunAt: number | null
Epoch-ms time the schedule last fired, or null if it never has.
lastJobId: string | null
Id of the most recently enqueued job, or null if none exists.
createdAt: number
Epoch-ms creation time.
updatedAt: number
Epoch-ms time of the last modification.
interface JobsOptions {
Options for Jobs.open().
path is the only required field. Supplying tasks registers inline
workers in the same call, equivalent to a follow-up process(); the
remaining fields tune the locally-hosted service and are ignored when a
runtime orchestrator already hosts the jobs service.
import { task } from 'fino:task';
import { Jobs } from 'fino:jobs';
const resize = task({ name: 'resize', run: async () => null });
await using jobs = await Jobs.open({
path: './.fino/jobs.db',
tasks: [resize],
concurrency: 4,
leaseMs: 30_000,
});
Properties
path: string
Path to the sqlite database file backing jobs, schedules, and durable runs.
tasks?: Task[]
Inline worker definitions: task trees this realm executes directly.
concurrency?: number
Concurrency for the inline processor (default 1).
leaseMs?: number
Worker lease duration; expired leases are requeued or dead-lettered.
pollIntervalMs?: number
Fallback poll interval for work created outside this process.
closeTimeout?: number
How long stop() waits for in-flight jobs before letting leases recover them.
interface JobsPushOptions {
Options for Jobs.push().
All fields are optional; an empty object enqueues an immediately-due job on
the default queue. Combine delay with key to schedule debounced work,
or priority with retry to control ordering and failure handling.
import { Jobs } from 'fino:jobs';
const jobs = await Jobs.open({ path: './.fino/jobs.db' });
await jobs.push('send-digest', { userId: 42 }, {
queue: 'emails',
delay: '1h',
key: 'digest:42',
priority: 10,
retry: { maxAttempts: 3 },
timeoutMs: 15_000,
});
Properties
queue?: string
Queue name used for ordering, stats, and worker selection.
Defaults to 'default'. Jobs in different queues are independent for
dedupe and queue-level stats, but all queues share the same backing store.
delay?: number | string | Date
Delay before the job becomes claimable.
A number is milliseconds from now, a string accepts <n><ms|s|m|h|d>, and
a Date is treated as an absolute run time. Omit it to make the job due
immediately.
priority?: number
Sort priority among jobs that are due at the same time.
Higher numbers are claimed first. Defaults to 0.
key?: string
Dedupe key for active work in this queue.
When set, at most one non-terminal job may exist for (queue, key).
Finished, dead, cancelled, or errored jobs release the key.
retry?: Partial<JobRetryPolicy>
Retry policy overrides for this job.
Values are merged with the service defaults. Throw
NonRetryableJobError from a handler to bypass retries and dead-letter
the job immediately.
timeoutMs?: number
Per-attempt timeout in milliseconds.
When set, a running attempt that exceeds this duration is treated as a failed attempt and follows the retry policy.
interface JobsScheduleOptions {
Options for Jobs.schedule().
Exactly one of cron or every sets the cadence; the rest tune overlap,
catch-up after downtime, the target queue, and the retry policy copied onto
each enqueued job. Cron cadences evaluate in UTC.
import { Jobs } from 'fino:jobs';
const jobs = await Jobs.open({ path: './.fino/jobs.db' });
await jobs.schedule('nightly-report', 'report', {}, {
cron: '0 3 * * *',
queue: 'reports',
overlap: 'skip',
catchup: 'one',
retry: { maxAttempts: 2 },
});
Properties
cron?: string
Five-field UTC cron expression or supported alias such as @daily.
Use either cron or every, not both. Cron schedules are evaluated in
UTC.
every?: string
Fixed interval schedule using <n><ms|s|m|h|d> syntax.
Use either every or cron, not both.
queue?: string
Queue used for jobs created by this schedule.
Defaults to 'default'.
overlap?: 'skip' | 'allow'
Overlap behavior when a previous scheduled job is still active.
'skip' avoids enqueueing another job while one from this schedule is
pending, running, waiting, claimed, or errored. 'allow' always enqueues
the due occurrence.
catchup?: 'skip' | 'one'
Catch-up behavior after downtime or delayed scheduler ticks.
'skip' advances to the next future occurrence without backfilling.
'one' enqueues at most one missed occurrence.
retry?: Partial<JobRetryPolicy>
Retry policy applied to jobs created by the schedule.
Values are copied onto each enqueued job when it is created.
Classes
class NonRetryableJobError extends Error {
A job that could not complete and should not be retried.
Throw from a task handler to send the job straight to the dead-letter state regardless of remaining attempts. Use it for failures that cannot succeed on a retry — malformed input, a permanent 4xx from an upstream service, or a business-rule rejection.
import { task } from 'fino:task';
import { NonRetryableJobError } from 'fino:jobs';
const charge = task({
name: 'charge',
run: async (input: { amount: number }) => {
if (input.amount <= 0) {
throw new NonRetryableJobError(`invalid amount: ${input.amount}`);
}
return input.amount;
},
});
Constructors
constructor(message: string)
Construct the error with a human-readable failure reason.
class Jobs {
Handle to the jobs system: pushes, schedules, worker registration, and
job lifecycle operations. Create with Jobs.open().
Static Methods
static async open(opts: JobsOptions): Promise<Jobs>
Open the jobs system.
Detects the orchestrator's fino:jobs/control facade and becomes a
client of the runtime-hosted service when present; otherwise hosts the
service in this realm.
import { Jobs } from 'fino:jobs';
await using jobs = await Jobs.open({ path: './.fino/jobs.db' });
Methods
push(task: string, input: unknown, opts: JobsPushOptions = {}): Promise<JobRecord>
Enqueue a job by task name.
Task instances are never pushed — a job row must be restorable from the database alone, so work requests carry only the name and input.
import { Jobs } from 'fino:jobs';
const jobs = await Jobs.open({ path: './.fino/jobs.db' });
await jobs.push('ingest', { url: 'https://example.com' }, { delay: '5s' });
schedule(
name: string,
task: string,
input: unknown,
opts: JobsScheduleOptions
): Promise<ScheduleRecord>
Create or replace a named schedule that enqueues task on a cron or
interval cadence (UTC).
import { Jobs } from 'fino:jobs';
const jobs = await Jobs.open({ path: './.fino/jobs.db' });
await jobs.schedule('nightly-compact', 'compact', {}, { cron: '@daily' });
unschedule(name: string): Promise<boolean>
Remove a named schedule. Returns whether it existed.
get(id: string): Promise<JobRecord | null>
Load one job by id.
list(filter: {
queue?: string;
status?: JobStatus;
task?: string;
scheduleId?: string;
limit?: number;
offset?: number;
} = {}): Promise<JobRecord[]>
List jobs, newest first.
job(id: string): ReadonlySignal<JobRecord | null>
Watch one job by id.
The signal is seeded from get(id) while subscribed and refreshes when
local jobs runtime events mention the same job. A periodic reconcile also
runs while hot so missed external writes eventually converge.
stats(queue?: string): ReadonlySignal<QueueStats>
Watch aggregate queue statistics.
The signal starts when subscribed, refreshes from the jobs store on runtime job events, and reconciles every five seconds while hot.
schedules(): Promise<ScheduleRecord[]>
List schedules.
cancel(id: string): Promise<boolean>
Cancel a job. Pending and parked jobs cancel immediately; a running job is marked cancelled but its in-flight execution is not interrupted.
retry(id: string): Promise<boolean>
Requeue a dead, errored, or cancelled job from attempt zero. Durable jobs keep their workflow run and resume from the last checkpoint.
signal(id: string, name: string, payload?: unknown): Promise<void>
Deliver an external signal to a parked durable job and make it claimable.
import { Jobs } from 'fino:jobs';
const jobs = await Jobs.open({ path: './.fino/jobs.db' });
await jobs.signal('job-id', 'approved', { by: 'ada' });
wait(id: string, opts: {
timeoutMs?: number;
} = {}): Promise<JobRecord>
Wait for a job to reach a terminal state.
async process(opts: {
tasks: Task[];
concurrency?: number;
}): Promise<void>
Register this realm as an inline processor for tasks: claimed jobs for
those task names execute on this realm's event loop.
import { task } from 'fino:task';
import { Jobs } from 'fino:jobs';
const jobs = await Jobs.open({ path: './.fino/jobs.db' });
await jobs.process({ tasks: [task({ name: 'noop', run: async () => null })] });
async workers(opts: {
entry: string;
size?: number;
realm?: Omit<RealmOptions, 'entry' | 'thread'>;
}): Promise<void>
Register a pool processor: an exclusive worker pool whose entry module
default-exports a Task (children included). Each job runs in a fresh
worker realm.
import { Jobs } from 'fino:jobs';
const jobs = await Jobs.open({ path: './.fino/jobs.db' });
await jobs.workers({ entry: './handlers/ingest.ts', size: 4 });
async stop(): Promise<void>
Stop the locally-hosted service (drain, then close). In client mode the runtime owns the service lifecycle and this is a no-op.