Jobs
A job is one backend call, stored as a row. Watch it, retry it, kill it, from your app or the CLI.
Overview
A job is a row in an outbox table that says “call this backend route with this payload”. Your app writes the row and moves on; the SSP delivers it, retries it on failure, and records the outcome back on the row.
Because the row is ordinary synced data, the whole thing is reactive: your UI can watch a job’s status the same way it watches anything else, offline writes queue like any other mutation, and nothing depends on the client staying connected while the work runs.
spky add api scaffolds the outbox table and wires the backend into sp00ky.yml. See Backend Setup for that walkthrough.
Calling a backend route
db.run(backend, path, payload, options?) takes the backend name from sp00ky.yml, a route path from that backend’s OpenAPI spec, and the payload for it. The payload is validated against the spec, so a missing required argument throws immediately rather than failing later in the runner.
| Prop | Type | Default | Description |
|---|---|---|---|
| assignedTo | string | - | Record id to link the job to. Makes it reachable with .related() from that record, which is the usual way a UI finds "the job for this thread". |
| max_retries | number | 3 | Attempts before the job is marked failed. Use 0 for anything not safe to repeat. |
| retry_strategy | 'linear' | 'exponential' | 'linear' | Backoff shape between attempts. |
| timeout | number | (backend default) | HTTP timeout in seconds. Only applies when the backend sets deploy.timeoutOverridable. |
| delay | number | 0 | Minimum milliseconds the job stays pending before it becomes eligible to run. |
Reading job status
status moves pending → processing → success | failed. On success the backend’s response body is stored on the job’s result field, so spky jobs get shows what a job actually returned. On failure each attempt appends { code, reason } to errors, and retries counts what has been tried.
Delayed jobs
A delay keeps a job pending for at least that long. It is a minimum, not a precise schedule: the job becomes eligible when the delay elapses, then runs as soon as the runner picks it up.
A delayed job is just a pending job, so it stays visible and cancellable while it waits. That makes it the natural way to build an undo window:
spky jobs kill and fn::job::kill are operator actions: record-access users can’t call them, so one user can never kill another’s job. For a user-facing “Undo”, use the flag pattern above (the user owns the record, so a normal update works) rather than trying to kill the job from the client.
Delayed jobs survive a restart: the recovery sweep re-dispatches a job whose delay elapsed while its SSP was down, so the window can’t be lost.
Scheduled and multi-step work
Everything above is about jobs your app creates. Work that should happen on a clock is declared in sp00ky.yml instead, and runs whether or not anyone is connected:
Each cycle creates a new job row in this same table, so everything on this page still applies to it.
- Schedules: cron and interval cadences, fan-out (one job per row of a query), concurrency policies,
spky schedules - Workflows: several jobs as a DAG, passing each step’s output to its dependents, and the live
spky workflows watchview
These replaced the client-side db.runRecurring() API, which has been removed.
Inspecting jobs from the CLI
spky jobs reads the outbox tables straight from SurrealDB, so you can watch and manage the queue without building any UI:
kill and retry go through the fn::job::kill / fn::job::retry SurrealQL functions rather than a plain UPDATE, because job pickup is gated inside the SSP. Those functions are installed by the schema deploy, so both need a deployed backend. clear only touches terminal rows, so it is a plain DELETE.
How execution works
The job runner is built into the SSP. There is nothing to install or implement:
- Pickup is create-only. Writing a
pendingrow fires an event that hands the job to the runner, which is whydb.run()creates a row rather than updating one. - The runner is the only writer of
status. Everything else (kill, retry, recovery) goes through guarded statements, so two writers can never disagree about a job’s state. - Retries happen in-process, with linear or exponential backoff. A job that exhausts them is marked
failedwith its error history intact. - A recovery sweep catches what events miss: rows created while the SSP was down, jobs whose delay elapsed during a restart, and
processingrows orphaned by a crash. In cluster mode the scheduler owns that sweep and usesassigneeto tell whether the owning SSP is still alive.
A SCHEMAFULL job table must define the platform’s own fields, or those writes are rejected at the statement level and quietly lost. spky deploy injects assignee, result, errors[*], and timeout with IF NOT EXISTS (plus the retention index below), so re-deploying an older project fixes it.
One thing a deploy cannot fix for you: assigned_to must be optional. A scheduled job belongs to no domain record, so the scheduler creates rows without it — and if your outbox table declares assigned_to TYPE record<...> rather than option<record<...>>, every scheduled fire fails to spawn with Expected record but found NONE. Deploys never relax a field you defined yourself, so change it in your own .surql.
Never mark an outbox table -- @nosync. It looks like a way to stop job rows syncing to clients, but job dispatch is driven by the table’s create event — @nosync silently disables the job runner.
Concurrency
By default a job table runs one job at a time. Raise it per table:
Above the limit, nothing is queued in memory: the extra rows simply stay pending in the outbox and are admitted in created_at order as slots free. The outbox is the queue. That is what makes a spike safe — a fan-out can create 10,000 rows in one fire, and the table will still only ever have concurrency of them in flight. Because the backlog is rows in the database, it survives a restart, needs no new status, and is visible with the same spky jobs list --status pending you already use.
This is the knob to reach for when a fan-out is more than the backend behind it can take. Everything funnels through the outbox — ad-hoc db.run() calls, scheduled fires, and workflow steps alike — so one limit per table governs all three.
Not to be confused with a schedule’s concurrency: skip | allow | replace, which decides what happens when a schedule fires again while a previous run of the same fan-out key is still going. That one is about overlap; this one is about how many run at once. See schedules.
The limit is exact within one SSP process. Across a multi-replica deployment it is a best-effort ceiling: each node also counts the processing rows the others hold and takes the tighter of the two views, so two nodes admitting in the same instant can briefly overshoot before converging. A single-node deployment never runs that count and is exact.
Like retention, the policy lives in the database (_00_job_policy:<table>), written by spky deploy. A throttle is the setting you least want to need a redeploy for:
Raising this means your handler will be called concurrently. If it mutates shared state, writes to the same rows, or was written assuming one-at-a-time delivery, make it safe for that first. The default stays at 1 precisely so upgrading changes nothing until you opt in.
Retry backoff does not hold a slot — a job sleeping between attempts lets the next one through.
Retention
Finished jobs are deleted automatically. Retention is asymmetric by outcome, because the two are read for different reasons: a successful job is inspected once if at all, while a failure is the thing you went looking for.
| default | applies to | |
|---|---|---|
success | 6h | job rows in success |
failed | 14d | job rows in failed |
runSuccess | 24h | run history in success, skipped, replaced |
runFailed | 30d | run history in failed, killed |
maxRows | off | hard ceiling on successful rows per table, see below |
pending and processing rows are never pruned, at any age. skipped and replaced follow the success window on purpose: with the default concurrency: skip, a slow wide fan-out writes one skipped row per item per fire, and those are the highest-volume, lowest-information rows in the system.
What survives the delete
Before each batch is deleted it is folded into permanent hourly counters, so “how much ran, and how much of it failed” outlives the rows themselves. That is what makes a 6-hour window acceptable rather than lossy. spky schedules get <name> shows the totals:
The counters are bounded by construction — 24 rows a day per schedule or table — and are written as a by-product of a delete that was happening anyway, so they cost no extra pass over the data.
These are a lower bound, not an audit log. Only automatic retention folds into them; rows removed by spky jobs clear, by your own DELETE, or before this feature existed were never counted.
The row cap
An age window bounds volume at rate x window, which is normally enough. For a fan-out so wide that even a short window holds more than you want to carry, maxRows is a hard ceiling:
It is off by default, and deliberately narrow: it only ever trims successful rows (never failed, killed, pending, or processing), it trims the oldest first, and it still folds what it removes into the counters. A table can legitimately sit above the cap if failures alone exceed it. Because it has to count rows, it runs on a slow multiple of the prune cadence rather than every pass.
A single schedule can override the run-history windows, which is what a noisy fan-out needs:
Job rows age by updated_at — a terminal job’s last write is its terminalization. Run history ages by finished_at. Neither uses created_at, which is only the queue time.
Pruning is deliberately paced (batched, once a minute) rather than done in one sweep. An outbox table is a synced user table, so every deleted row fires the table’s delete event and posts the record to the SSP — the cost per row is a network round-trip, not a local write. A large backlog therefore drains over minutes instead of stalling the database. The same is true of spky jobs clear, which batches for the same reason.
Retention is stored in the database (_00_retention:default), written by spky deploy. You can retune it with a single UPDATE and no redeploy, the same way spky schedules pause works. Note that the Cloudflare Workers host does not run the prune pass.
Job table schema
spky add api generates this. The permission rule is the important part: scope jobs to their owner so one user can neither read nor queue work on another’s records.
status, errors, result, and assignee are platform-written: clients may read them but never set them. assignee is the owning SSP node id, used for cluster recovery.