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
| Definition | What it is |
|---|
migrate(c) | Creates or upgrades the package's tables; safe on every start |
State | Pending, Running, Completed, Dead, Cancelled |
Job, AttemptError | A stored job, with the history of its failed attempts (the last 25) |
Enqueueing
| Definition | What 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
| Definition | What it is |
|---|
Lease | A 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, snooze | Record 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
| Definition | What it is |
|---|
Backoff | Exponential{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
| Definition | What it is |
|---|
Worker, worker(queue) | Queue, lease length (5 minutes) and backoff |
Ran | Idle 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
| Definition | What 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
| Definition | What 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_fires | Fire times in UTC |
next_fire_in, prev_fire_in, next_fires_in | Fire times on a zone's wall clock |
Schedules
| Definition | What it is |
|---|
Schedule, schedule(name, cron, queue, kind, payload) | A recurring job, in UTC by default |
Missed | CatchUp{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
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).
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.
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.
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.
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 *).
fn prev_fire(c: Cron, t: Time) -> Opt[Time]
The last fire time of c at or before t, in UTC.
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).
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.
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.
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.
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.
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.
pub type AttemptError = {attempt: Int, at: Time, error: Str} derive Eq, Show
One failed attempt, as recorded in Job.errors.
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
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
fn enqueue_now(c: Db, j: NewJob) -> Enqueued ! db, fail[DbErr], time
enqueue at the current time.
fn enqueued_id(e: Enqueued) -> Int
The id of an enqueue result, new or existing.
fn get(c: Db, id: Int) -> Opt[Job] ! db, fail[DbErr]
fn filter() -> Filter
Any queue, state and kind, from the start, 100 at a time.
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.
fn stats(c: Db, now: Time) -> List[QueueStats] ! db, fail[DbErr]
Counts for every queue that has jobs, in queue order, at time now.
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.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
fn retry_dead(c: Db, queue: Str, now: Time) -> Int ! db, fail[DbErr]
retry for every dead job of queue; gives how many.
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.
fn delete(c: Db, id: Int) -> Bool ! db, fail[DbErr]
Deletes a job in any state.
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.
fn default_backoff() -> Backoff
Exponential backoff from 1 second up to 1 hour, with a quarter of jitter.
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.
fn worker(queue: Str where _.len > 0) -> Worker
A worker for queue with a 5 minute lease and the default backoff.
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.
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.
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).
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.
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.
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.
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.
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.
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.
fn remove_schedule(c: Db, name: Str) -> Bool ! db, fail[DbErr]
Removes a schedule. Jobs it already enqueued stay.
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.
fn schedules(c: Db) -> List[Schedule] ! db, fail[DbErr]
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.
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.