Package

jobs

Durable background jobs on SQLite: priorities, delays, unique keys, leases, retries with backoff, dead letters, workers and cron schedules

0.1.0 MIT db

Install

sspur add jobs@^0.1.0

or in sspur.toml:

jobs = { version = "^0.1.0" }

README

jobs

A durable background job queue for SSPUR programs, stored in SQLite: the database you already have, or a file of its own. It covers what BullMQ, Sidekiq, Oban and river do for a single database: priorities, delays and run-at times, unique jobs, bulk enqueue, leases with a visibility timeout so a crashed worker's jobs come back, heartbeats, retries with exponential, linear, fixed or listed backoff (with repeatable jitter), a dead letter state, cancel, retry, snooze, purge, stats, and cron schedules with time zones and a policy for missed runs.

Every operation takes the time as a now: Time argument, so tests are deterministic and a crash can be simulated by moving the clock; enqueue_now, work, drain and run_worker read the clock for production use.

Install

$ sspur add jobs

Example

use jobs.{Job, Outcome, Done, Retry}

type Email = {to: Str, subject: Str}

// The handler: a top-level function, so worker tasks can call it by name.
fn send_email(j: Job) -> Outcome
= match json.decode[Email](j.payload)
  | ok(e) => if e.to.contains("@") then Done else Retry{error: "no @ in {e.to}"}
  | err(e) => jobs.Discard{error: e}

fn main() -> Unit ! db, fail[DbErr], log, time, conc
= do
  c = db.open(":memory:")
  applied = jobs.migrate(c)
  t = now()
  welcome = jobs.job("mail", "welcome", json.encode(Email{to: "ada@example.com", subject: "Hi"}))
  a = jobs.enqueue(c, welcome with unique_key := some("welcome:ada"), t)
  again = jobs.enqueue(c, welcome with unique_key := some("welcome:ada"), t)
  b = jobs.enqueue(c, jobs.job("mail", "welcome", json.encode(Email{to: "nobody", subject: "Hi"})) with max_attempts := 2, t)
  bad = jobs.enqueue(c, jobs.job("mail", "welcome", "not json"), t)
  log("{applied} migrations; enqueued {a}, then {again}")
  w = jobs.worker("mail") with backoff := jobs.Fixed{delay: millis(0)}
  for i in par(0..2)
    n = jobs.drain(c, w, send_email, 100)
  for s in jobs.stats(c, now())
    log("{s.queue}: {s.completed} completed, {s.dead} dead, {s.available} waiting")
  for j in jobs.list(c, jobs.filter() with state := some(jobs.Dead))
    log("dead job {j.id} after {j.attempts} attempts: {j.last_error.or("")}")
  from = datetime(2026, 10, 16, 12, 0, 0).or(t)
  match jobs.parse_cron("0 9 * * MON-FRI")
  | ok(cron) => log("next weekday 09:00 UTC runs: {jobs.next_fires(cron, from, 3)}")
  | err(e) => log(e)
  log("bad expression: {jobs.parse_cron("0 25 * * *")}")
  p = jobs.put_schedule(c, jobs.schedule("digest", "*/5 * * * *", "mail", "digest", "\{}") with missed := jobs.CatchUp{limit: 10}, from)
  fired = jobs.tick(c, from.add(mins(12)))
  log("tick enqueued {fired.map(f => f.at)}")

Run as a dependent (jobs = { path = "..." }), natively and with --interp, the output is the same:

$ sspur run main.ssp
2 migrations; enqueued Inserted{id: 1}, then Duplicate{id: 1}
mail: 1 completed, 2 dead, 0 waiting
dead job 2 after 2 attempts: no @ in nobody
dead job 3 after 1 attempts: invalid JSON
next weekday 09:00 UTC runs: [2026-10-19T09:00:00Z, 2026-10-20T09:00:00Z, 2026-10-21T09:00:00Z]
bad expression: err("hour: 25 is out of range 0-23")
tick enqueued [2026-10-16T12:05:00Z, 2026-10-16T12:10:00Z]

The second job fails twice and goes to the dead letter state with its error; the third has a payload that doesn't decode, so the handler discards it at once. The schedule was registered at 12:00 and ticked at 12:12, so with CatchUp it enqueues both runs it missed.

How it works

Jobs live in jobs_jobs and schedules in jobs_schedules; migrate(c) creates them and records its own versions in jobs_schema, so it never interferes with db.migrate. Times are stored as Unix milliseconds.

A job is Pending (waiting for its run_at; a retry is a pending job with attempts made), Running (leased to a worker) or final: Completed, Dead or Cancelled.

claim runs one BEGIN IMMEDIATE transaction that first rescues expired leases of the queue, then takes the best available job (smallest priority, then earliest run_at, then oldest) and leases it until now + lease. Each claim increments the attempt count and the lease token. Every finishing call (complete, fail_attempt, retry_in, discard, snooze, extend) checks the token, so a worker whose lease expired and whose job another worker took over can't record a result: it gets false. A completion after the lease expired is still accepted as long as nobody claimed the job again. A rescued job counts the lost attempt, and goes to Dead if that was its last.

Because SQLite serializes writers, claims from any number of threads and processes never hand out one job twice; the tests run 6 concurrent workers over 300 jobs on a file database and check that every job ran exactly once.

Workers and concurrency

A handler is a function Job -> Outcome. It decides what happens: Done, Retry{error} (by the worker's backoff), RetryIn{error, after}, Discard{error} (dead now) or Snooze{after} (later, without counting the attempt). A handler that traps or hangs leaves its job leased; the lease expiring brings it back.

work processes one job, drain until the queue is empty or a maximum, and run_worker until an Atomic[Int] stop flag is set, sleeping between polls. SSPUR tasks can't share function values, so to run several workers at once start them in par and name a top-level handler (or write the lambda inside the task):

for i in par(0..4)
  n = jobs.drain(c, w, send_email, 1000)

The step API (claim, claim_many, extend, finish and the single outcomes) is there for callers that drive the loop themselves, for example to batch work.

Unique jobs

With unique_key, enqueueing again into the same queue returns Duplicate{id} of the existing job while it is pending or running, and also, when unique_for is set, until that long after the first enqueue (whatever its state). The check and the insert are one transaction.

Cron and schedules

parse_cron reads 5-field expressions (minute, hour, day of month, month, day of week), 6-field ones with seconds first, and the macros @yearly, @annually, @monthly, @weekly, @daily, @midnight and @hourly. Fields take *, numbers, ranges, steps (*/15, 1-30/5, and 5/15 meaning 5-59/15), lists and names (JAN-DEC, SUN-SAT, any case); 0 and 7 are Sunday. Errors name the field: hour: 25 is out of range 0-23, minute: step '0' must be a positive number, day of month: 'L' is not supported (it is a Quartz extension).

The two day fields follow Vixie cron exactly: when both are restricted, a day matches if either does (0 0 1 * MON is the 1st and every Monday); when either starts with *, both must match. That includes the well-known surprise that */2 in the day of month counts as * for this rule, so 0 0 */2 * MON means odd-numbered Mondays; write 1-31/2 for "odd days or Mondays".

next_fire, prev_fire and next_fires work in UTC; the _in variants take a Zone (time_zone("Europe/Berlin")). On the wall clock, a time the clock skips at the start of daylight saving time is shifted forward by the gap (02:30 becomes 03:30), and a time that happens twice when it ends fires once, at its first occurrence. Searches look up to nine years ahead, so an expression that can never fire (0 0 30 2 *) gives none rather than looping.

put_schedule stores a schedule (validating its expression and zone) and tick(c, now) enqueues every due run once: each run's unique key is schedule:NAME:MILLIS and the schedule's progress moves in the same transaction, so ticking twice or from several processes is safe. Runs missed while nothing ticked follow the schedule's missed: CatchUp{limit} (each, up to the most recent limit), Latest (only the most recent) or Skip{grace} (the most recent if it is at most grace old).

API

Queue setup

DefinitionWhat it is
migrate(c)Creates or upgrades the package's tables; safe on every start
StatePending, Running, Completed, Dead, Cancelled
Job, AttemptErrorA stored job, with the history of its failed attempts (the last 25)

Enqueueing

DefinitionWhat it is
NewJob, job(queue, kind, payload)A job to add, with defaults: priority 0, now, 20 attempts, not unique
enqueue(c, j, now), enqueue_now(c, j)Adds a job, or finds the one with its unique key
enqueue_all(c, js, now)Adds many in one transaction
Enqueued, enqueued_id(e)Inserted{id} or Duplicate{id}

Claiming and finishing

DefinitionWhat it is
LeaseA claimed job with its token and expiry
claim(c, queue, lease, now), claim_many(c, queue, n, lease, now)Lease the next available jobs
extend(c, l, by, now)Heartbeat: push the lease out
complete, fail_attempt, retry_in, discard, snoozeRecord one outcome; false when the lease was lost
Outcome, finish(c, l, o, backoff, now)A handler's decision, and recording it
rescue(c, now)Bring back jobs whose lease expired, in every queue

Backoff

DefinitionWhat it is
BackoffExponential{base, cap, jitter}, Fixed{delay}, Linear{step, cap}, Steps{delays}
backoff_delay(b, attempt, seed)The wait after attempt attempts; repeatable for a seed
default_backoff()Exponential from 1 s to 1 h with 25% jitter

Workers

DefinitionWhat it is
Worker, worker(queue)Queue, lease length (5 minutes) and backoff
RanIdle or Processed{job, outcome, recorded}
work_at(c, w, handler, now), work(c, w, handler)Process one job
drain(c, w, handler, max)Process until empty or max
run_worker(c, w, handler, stop, idle_ms)Serve the queue until stop is set

Managing and inspecting

DefinitionWhat it is
get(c, id)One job
Filter, filter(), list(c, f)Jobs by queue, state and kind, with keyset paging
QueueStats, stats(c, now)Counts per queue and state, and the oldest waiting job
cancel(c, id, now)Cancel a pending or running job
retry(c, id, now), retry_dead(c, queue, now)Bring dead or cancelled jobs back
reschedule(c, id, at)Move a pending job
delete(c, id), purge(c, state, before)Remove jobs

Cron

DefinitionWhat it is
Cron, parse_cron(expr)A parsed expression, or an error naming the field
cron_matches(c, t), cron_matches_in(c, z, t)Whether a time is a fire time
next_fire, prev_fire, next_firesFire times in UTC
next_fire_in, prev_fire_in, next_fires_inFire times on a zone's wall clock

Schedules

DefinitionWhat it is
Schedule, schedule(name, cron, queue, kind, payload)A recurring job, in UTC by default
MissedCatchUp{limit}, Latest, Skip{grace}
put_schedule(c, s, now), remove_schedule(c, name), set_enabled(c, name, on, now), schedules(c)Manage them
due_fires(cron, zone, last, now, missed)The runs a tick would enqueue (pure)
Fired, tick(c, now)Enqueue every due run once

Out of scope

  • Other stores (Redis, Postgres): the queue relies on SQLite's single writer for its guarantees.
  • Job dependencies, flows and batches with callbacks; rate limiting per queue (use a rate limiter in the handler and Snooze).
  • Pushing results to waiting callers: store results yourself, keyed by job id.
  • Killing a running handler: SSPUR has no way to stop a task from outside, so timeouts are leases.
  • Quartz cron extensions (L, W, #, ?) and @reboot.

License

MIT, see LICENSE.

API

Cron type

pub type Cron = {source: Str, seconds: List[Int], minutes: List[Int], hours: List[Int], days: List[Int], months: List[Int], weekdays: List[Int], any_day: Bool, any_weekday: Bool, has_seconds: Bool} derive Eq, Show

A parsed cron expression. Each list holds the allowed values of one field in ascending order: seconds 0-59 (only 0 for a 5-field expression), minutes 0-59, hours 0-23, days of the month 1-31, months 1-12 and days of the week 0-6 (0 is Sunday; 7 is read as 0). any_day and any_weekday record whether those fields started with *, which decides how the two day fields combine (see cron_matches).

parse_cron fn

fn parse_cron(expr: Str) -> Res[Cron, Str]

Parses a cron expression: five fields (minute, hour, day of month, month, day of week), six with a seconds field first, or a macro (@yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly). Fields take *, numbers, a-b ranges, /step on any of them (*/15, 1-30/5, and 5/15 for 5-59/15), comma lists, month names JAN-DEC and day names SUN-SAT (any case); 0 and 7 are both Sunday. Errors name the field and the offending text.

cron_matches fn

fn cron_matches(c: Cron, t: Time) -> Bool

Whether the instant t (to the second, in UTC) is a fire time of c. When both day fields are restricted (neither starts with *), a day matches if either matches, as in Vixie cron: 0 0 1 * MON fires on the 1st and on every Monday. Otherwise both must match.

cron_matches_in fn

fn cron_matches_in(c: Cron, z: Zone, t: Time) -> Bool

Whether t is a fire time of c on the wall clock of the zone z.

next_fire fn

fn next_fire(c: Cron, t: Time) -> Opt[Time]

The first fire time of c strictly after t, in UTC, or none when there is none within nine years (such as 0 0 30 2 *).

prev_fire fn

fn prev_fire(c: Cron, t: Time) -> Opt[Time]

The last fire time of c at or before t, in UTC.

next_fires fn

fn next_fires(c: Cron, t: Time, n: Int where _ >= 0) -> List[Time]

The next n fire times of c after t, in UTC (fewer if the expression runs out).

next_fire_in fn

fn next_fire_in(c: Cron, z: Zone, t: Time) -> Opt[Time]

The first fire time of c strictly after t on the wall clock of z, as an instant. Skipped and repeated wall times follow the rules of daylight saving time described in the README.

prev_fire_in fn

fn prev_fire_in(c: Cron, z: Zone, t: Time) -> Opt[Time]

The last fire time of c at or before t on the wall clock of z, as an instant.

next_fires_in fn

fn next_fires_in(c: Cron, z: Zone, t: Time, n: Int where _ >= 0) -> List[Time]

The next n fire times of c after t on the wall clock of z.

State type

pub type State = Pending | Running | Completed | Dead | Cancelled derive Eq, Ord, Show

Where a job is in its life. Pending jobs wait for their run_at (a retry is a pending job with attempts already made), Running jobs are leased to a worker, and the other three are final: Completed, Dead (out of attempts or discarded: the dead letter set) and Cancelled.

NewJob type

pub type NewJob = {queue: Str where _.len > 0 and _.len <= 200, kind: Str where _.len > 0 and _.len <= 200, payload: Str, priority: Int where _ >= -1000 and _ <= 1000, run_at: Opt[Time], delay: Duration, max_attempts: Int where _ >= 1 and _ <= 1000, unique_key: Opt[Str], unique_for: Duration} derive Eq, Show

A job to enqueue. Build one with job and change fields with with. Smaller priority runs first. run_at (when set) or delay (from the enqueue time) says when it becomes available. With a unique_key, enqueueing again in the same queue while a job with that key is pending or running, or within unique_for of the first one, returns the existing job instead.

AttemptError type

pub type AttemptError = {attempt: Int, at: Time, error: Str} derive Eq, Show

One failed attempt, as recorded in Job.errors.

Job type

pub type Job = {id: Int, queue: Str, kind: Str, payload: Str, priority: Int, state: State, run_at: Time, attempts: Int, max_attempts: Int, unique_key: Opt[Str], last_error: Opt[Str], errors: List[AttemptError], created_at: Time, attempted_at: Opt[Time], finished_at: Opt[Time]} derive Eq, Show

A stored job.

Enqueued type

pub type Enqueued = Inserted{id: Int} | Duplicate{id: Int} derive Eq, Show

The result of an enqueue: a new job, or the existing job with the same unique key.

Lease type

pub type Lease = {job: Job, token: Int, until: Time} derive Eq, Show

A claimed job. token identifies this claim: finishing calls with an older token (after the lease expired and another worker claimed the job) change nothing and return false.

Outcome type

pub type Outcome = Done | Retry{error: Str} | RetryIn{error: Str, after: Duration} | Discard{error: Str} | Snooze{after: Duration} derive Eq, Show

What a handler decided about a job. Retry waits by the worker's backoff and RetryIn by the given time; both count the attempt, and a job out of attempts goes to Dead. Discard sends it to Dead at once. Snooze runs it again later without counting the attempt.

Backoff type

pub type Backoff = Exponential{base: Duration, cap: Duration, jitter: F64} | Fixed{delay: Duration} | Linear{step: Duration, cap: Duration} | Steps{delays: List[Duration]} derive Eq, Show

How long to wait before the next attempt, by the number of attempts made (1 after the first). Exponential doubles base up to cap and then takes off up to jitter (0 to 1) of it at random but repeatably, seeded by the job id; Linear waits step times the attempt, up to cap; Steps takes the list in order and repeats its last element.

Worker type

pub type Worker = {queue: Str where _.len > 0, lease: Duration, backoff: Backoff} derive Eq, Show

What a worker needs: the queue it serves, how long a claim lasts before another worker may take the job over, and the backoff for Retry.

Ran type

pub type Ran = Idle | Processed{job: Job, outcome: Outcome, recorded: Bool} derive Eq, Show

What work did: nothing was available, or it ran a job, with whether the outcome was recorded (false when the lease had been lost).

Filter type

pub type Filter = {queue: Opt[Str], state: Opt[State], kind: Opt[Str], after_id: Int, limit: Int where _ >= 1 and _ <= 1000} derive Eq, Show

Filters for list: by queue, state and kind (none means any), job ids above after_id (for keyset paging), at most limit jobs in id order.

QueueStats type

pub type QueueStats = {queue: Str, available: Int, scheduled: Int, running: Int, completed: Int, dead: Int, cancelled: Int, oldest_due: Opt[Time]} derive Eq, Show

Counts per queue. available are pending and due, scheduled pending and due later; oldest_due is the earliest run_at of the available ones, a measure of how far behind the workers are.

migrate fn db

fn migrate(c: Db) -> Int ! db, fail[DbErr]

Creates or upgrades the tables this package uses (jobs_jobs, jobs_schedules and the version table jobs_schema), each step in a transaction, and returns how many steps it applied. It is separate from db.migrate, so an application's own migrations are not affected. Safe to call on every start, from several processes.

job fn

fn job(queue: Str where _.len > 0 and _.len <= 200, kind: Str where _.len > 0 and _.len <= 200, payload: Str) -> NewJob

A job for queue of kind kind with payload (usually JSON from json.encode): priority 0, available at once, 20 attempts, not unique.

enqueue fn db

fn enqueue(c: Db, j: NewJob, now: Time) -> Enqueued ! db, fail[DbErr]

Adds a job at time now, or finds the pending, running or recent job with its unique key.

enqueue_all fn db

fn enqueue_all(c: Db, js: List[NewJob], now: Time) -> List[Enqueued] ! db, fail[DbErr]

Adds every job in one transaction: all of them or, if one fails, none. The results are in order.

enqueue_now fn db

fn enqueue_now(c: Db, j: NewJob) -> Enqueued ! db, fail[DbErr], time

enqueue at the current time.

enqueued_id fn

fn enqueued_id(e: Enqueued) -> Int

The id of an enqueue result, new or existing.

get fn db

fn get(c: Db, id: Int) -> Opt[Job] ! db, fail[DbErr]

The job with id id.

filter fn

fn filter() -> Filter

Any queue, state and kind, from the start, 100 at a time.

list fn db

fn list(c: Db, f: Filter) -> List[Job] ! db, fail[DbErr]

Jobs that pass f, in id order. For the next page, set after_id to the last id returned.

stats fn db

fn stats(c: Db, now: Time) -> List[QueueStats] ! db, fail[DbErr]

Counts for every queue that has jobs, in queue order, at time now.

rescue fn db

fn rescue(c: Db, now: Time) -> Int ! db, fail[DbErr]

Returns jobs whose lease expired at now to their queues (or to dead when they were on their last attempt) and gives how many. claim does this for its own queue, so call it only to see crashed work sooner, for example from a periodic task.

claim_many fn db

fn claim_many(c: Db, queue: Str, n: Int where _ >= 1 and _ <= 1000, lease: Duration, now: Time) -> List[Lease] ! db, fail[DbErr]

Claims up to n available jobs of queue for lease from now, highest priority (smallest number) first, then earliest run_at, then oldest. Each claim counts an attempt. The claim is one BEGIN IMMEDIATE transaction, so concurrent workers in any number of threads or processes never get the same job; expired leases of the queue are rescued first.

claim fn db

fn claim(c: Db, queue: Str, lease: Duration, now: Time) -> Opt[Lease] ! db, fail[DbErr]

Claims the next available job of queue, as claim_many with n = 1.

extend fn db

fn extend(c: Db, l: Lease, by: Duration, now: Time) -> Opt[Lease] ! db, fail[DbErr]

Extends a lease to by after now (a heartbeat for long jobs). None when the lease was lost.

complete fn db

fn complete(c: Db, l: Lease, now: Time) -> Bool ! db, fail[DbErr]

Marks a leased job completed. False when the lease was lost (the job was rescued and claimed again, or cancelled).

fail_attempt fn db

fn fail_attempt(c: Db, l: Lease, error: Str, backoff: Backoff, now: Time) -> Bool ! db, fail[DbErr]

Records a failed attempt: the job runs again after backoff, or goes to dead when it has used its attempts. False when the lease was lost.

retry_in fn db

fn retry_in(c: Db, l: Lease, error: Str, after: Duration, now: Time) -> Bool ! db, fail[DbErr]

Records a failed attempt that should run again after after (or go to dead when it has used its attempts).

discard fn db

fn discard(c: Db, l: Lease, error: Str, now: Time) -> Bool ! db, fail[DbErr]

Sends a leased job to dead at once, whatever attempts it has left.

snooze fn db

fn snooze(c: Db, l: Lease, after: Duration, now: Time) -> Bool ! db, fail[DbErr]

Puts a leased job back to run after after without counting this attempt.

finish fn db

fn finish(c: Db, l: Lease, o: Outcome, backoff: Backoff, now: Time) -> Bool ! db, fail[DbErr]

Records a handler's outcome for a leased job, using backoff for Retry.

cancel fn db

fn cancel(c: Db, id: Int, now: Time) -> Bool ! db, fail[DbErr]

Cancels a pending or running job. A worker that still holds it can no longer record a result.

retry fn db

fn retry(c: Db, id: Int, now: Time) -> Bool ! db, fail[DbErr]

Makes a dead or cancelled job pending again, with its attempts reset and available at now.

retry_dead fn db

fn retry_dead(c: Db, queue: Str, now: Time) -> Int ! db, fail[DbErr]

retry for every dead job of queue; gives how many.

reschedule fn db

fn reschedule(c: Db, id: Int, at: Time) -> Bool ! db, fail[DbErr]

Moves a pending job to run at at (sooner or later). False for jobs in other states.

delete fn db

fn delete(c: Db, id: Int) -> Bool ! db, fail[DbErr]

Deletes a job in any state.

purge fn db

fn purge(c: Db, s: State, before: Time) -> Int ! db, fail[DbErr]

Deletes jobs in the final state s (Completed, Dead or Cancelled) that finished before before, and gives how many. Pending and running jobs are never purged.

default_backoff fn

fn default_backoff() -> Backoff

Exponential backoff from 1 second up to 1 hour, with a quarter of jitter.

backoff_delay fn

fn backoff_delay(b: Backoff, attempt: Int where _ >= 0, seed: Int) -> Duration

The wait before the attempt after attempt attempts, for a job seeded by seed (its id): the same inputs always give the same wait, so retries are spread out but repeatable in tests.

worker fn

fn worker(queue: Str where _.len > 0) -> Worker

A worker for queue with a 5 minute lease and the default backoff.

work_at fn db

fn work_at[e](c: Db, w: Worker, handler: Job -> Outcome ! e, now: Time) -> Ran ! db, fail[DbErr], e

Claims one job of the worker's queue at now, runs handler on it and records the outcome at now. Handlers report failure through their Outcome; a handler that traps or never returns leaves the job leased, and it is retried when the lease expires.

work fn db

fn work[e](c: Db, w: Worker, handler: Job -> Outcome ! e) -> Ran ! db, fail[DbErr], time, e

work_at with the clock: claims now and records the outcome when the handler returns.

drain fn db

fn drain[e](c: Db, w: Worker, handler: Job -> Outcome ! e, max: Int where _ >= 0) -> Int ! db, fail[DbErr], time, e

Runs work until the queue has nothing available or max jobs ran, and gives how many ran. For concurrency, start several in par and pass a top-level function (or a lambda written inside the task), since tasks can't share function values: for i in par(0..4) then jobs.drain(c, w, send_email, 1000).

run_worker fn db

fn run_worker[e](c: Db, w: Worker, handler: Job -> Outcome ! e, stop: Atomic[Int], idle_ms: Int where _ >= 1) -> Int ! db, fail[DbErr], time, conc, div, e

Serves the worker's queue until stop holds a non-zero value: runs jobs while there are any and sleeps idle_ms when there are none. Gives how many jobs ran. Set stop from another task, or from a signal-handling path of the program, to finish after the current job.

Missed type

pub type Missed = CatchUp{limit: Int} | Latest | Skip{grace: Duration} derive Eq, Show

What tick does about fire times that passed while nothing ticked (the process was down): CatchUp enqueues each of them, at most the limit most recent; Latest enqueues only the most recent; Skip enqueues the most recent only if it is at most grace old, and drops the rest.

Schedule type

pub type Schedule = {name: Str where _.len > 0 and _.len <= 200, cron: Str, zone: Str, queue: Str where _.len > 0 and _.len <= 200, kind: Str where _.len > 0 and _.len <= 200, payload: Str, priority: Int where _ >= -1000 and _ <= 1000, max_attempts: Int where _ >= 1 and _ <= 1000, missed: Missed, enabled: Bool} derive Eq, Show

A recurring job: cron (see parse_cron) on the wall clock of the IANA zone zone ("UTC", "Europe/Berlin", or a fixed offset such as "+05:30"), enqueueing kind with payload into queue. name identifies it.

Fired type

pub type Fired = {schedule: Str, at: Time, result: Enqueued} derive Eq, Show

One run that tick enqueued: the schedule, its fire time and the enqueue result.

schedule fn

fn schedule(name: Str where _.len > 0 and _.len <= 200, cron: Str, queue: Str where _.len > 0 and _.len <= 200, kind: Str where _.len > 0 and _.len <= 200, payload: Str) -> Schedule

A schedule named name in UTC, enabled, with priority 0, 20 attempts and Latest for missed runs.

put_schedule fn db

fn put_schedule(c: Db, s: Schedule, now: Time) -> Res[Unit, Str] ! db, fail[DbErr], time

Adds or replaces the schedule s.name, checking its cron expression and zone. Its first run is the first fire time after now; replacing a schedule with the same expression and zone keeps its progress, so nothing fires twice. An error names what is wrong.

remove_schedule fn db

fn remove_schedule(c: Db, name: Str) -> Bool ! db, fail[DbErr]

Removes a schedule. Jobs it already enqueued stay.

set_enabled fn db

fn set_enabled(c: Db, name: Str, enabled: Bool, now: Time) -> Bool ! db, fail[DbErr]

Turns a schedule on or off. Turning one on counts from now, so the time it was off is not caught up.

schedules fn db

fn schedules(c: Db) -> List[Schedule] ! db, fail[DbErr]

Every schedule, by name.

due_fires fn

fn due_fires(c: Cron, z: Zone, last: Time, now: Time, missed: Missed) -> List[Time]

The fire times of c in z after last and at or before now that missed lets through, oldest first. tick enqueues these.

tick fn db

fn tick(c: Db, now: Time) -> List[Fired] ! db, fail[DbErr], time

Enqueues every run of every enabled schedule that is due at now, once: each run gets the unique key schedule:NAME:MILLIS and the schedule's progress moves to now in the same transaction, so ticking from several processes, or twice, enqueues nothing twice. Call it every few seconds or every minute; missed runs follow each schedule's missed.

Versions

VersionPublishedCommitSourceEffects
0.1.0 2026-10-16ded2d3b48esnapshotdb