Schedules
Recurring work declared in sp00ky.yml. Each cycle creates an ordinary job row, with cron, fan-out and concurrency control.
Overview
A schedule runs a backend route on a clock, server-side, whether or not a client is connected. Use it for periodic syncs, polling an upstream API, cache warming, or scheduled cleanup.
Schedules are declared in sp00ky.yml and deployed like everything else in the manifest. Each cycle, the scheduler creates a new job row in the target backend’s outbox table, so a scheduled run is an ordinary atomic job, with the same retry policy, the same spky jobs visibility, and the same kill and crash-recovery behaviour as a job your app created with db.run().
That’s the whole integration: your backend doesn’t know it was called by a schedule.
Schedules replaced the old client-side recurring API (db.runRecurring() / pokeRecurring / cancelRecurring), which has been removed. Periodic work is declared here instead of registered by a browser.
For multi-step work (several routes in sequence, in parallel, or passing data to each other) see Workflows.
Cadence
A schedule sets exactly one of cron or every.
cron is a standard 5-field expression (a leading seconds field is also accepted) evaluated in timezone, so “daily at 03:00” stays 03:00 local across daylight-saving changes rather than drifting by an hour twice a year.
every is measured from each fire, not from each completion. A 5m schedule fires at a steady 5-minute rhythm regardless of how long a run takes; use concurrency (below) to decide what happens when a run is still going at the next fire.
Missed fires coalesce. If the stack is down for a week, an hourly schedule fires once on the way back up and then resumes its normal rhythm. It does not replay 168 backlogged runs.
Fan-out: one job per row
forEach turns one schedule into one job per row its query returns: a per-user sync, a per-connection poll, a per-tenant cleanup.
The query runs with root privileges on every fire, so it always sees current data: a row added since the last fire is picked up automatically, and a row that disappears simply stops being scheduled.
Concurrency
concurrency decides what a fire does when the previous run for that same key is still in flight. It is tracked per key, so one slow user never holds up anyone else.
| Prop | Type | Default | Description |
|---|---|---|---|
| skip | policy | default | Record the suppressed tick and leave the in-flight run alone. A 5-minute sync that occasionally takes 8 minutes will not pile up behind itself. |
| allow | policy | - | Spawn regardless. Runs for the same key may overlap; correct when the work is idempotent and independent. |
| replace | policy | - | Kill the in-flight run, then start the new one. Use when only the newest result matters. |
A suppressed tick is recorded as a skipped run, not silently dropped, so spky schedules runs shows you a schedule falling behind instead of just looking idle.
This is about overlap, not throughput. A fan-out over 10,000 rows spawns 10,000 jobs with 10,000 distinct keys, so no policy here restrains it — every one of them is a first run for its own key. What bounds how many actually execute at once is the job table’s own concurrency, which holds the excess as pending rows and admits them oldest-first as slots free.
Definitions in their own files
Once you have more than a handful, schedules: can live in separate files, per entry or wholesale. Paths are relative to the manifest that names them.
Operating them
STATE is the one field worth reading closely: active is normal, paused means an operator stopped it, disabled means enabled: false in the manifest, planning means the scheduler has not computed its first fire yet, and error means the cadence or the forEach query is broken, with the reason printed underneath.
pause is the big red button: it stops future fires, survives redeploys, and wins over a queued trigger. trigger fires one extra run without shifting the cadence, a manual run at 14:07 does not move tonight’s 03:00.
spky schedules list and runs cover workflow schedules too; to see a workflow run’s DAG, use spky workflows show / watch.
How it works
Worth knowing, because it explains what is and isn’t guaranteed:
- Definitions are rows.
spky deploy(andspky dev) upserts each definition into an internal_00_scheduletable. Three writers own disjoint fields: your manifest owns the definition, you ownpaused, and the scheduler owns the clock. That is why a redeploy can never un-pause something you paused, and why pausing never fights the scheduler over a field. - One ticker. A sweep runs every 5 seconds, in the SSP for a singlenode deployment, in the scheduler service for a cluster. A fire is claimed with a compare-and-swap on the schedule’s next-fire time, so even two tickers cannot double-fire.
- Runs are recorded, not inferred. Every fire writes a
_00_schedule_runrow (including suppressed ones), which is both your history and the per-key concurrency state. Because it is state in the database rather than memory, restarts don’t lose it. History is not kept forever, though — see Retention below. - The fan-out row is not duplicated. A run row records which key it was for, not a copy of the row itself; the row travels on the spawned job’s
payload.row(and on a workflow run’sinput), which is wherespky jobs getshows it. - Completion is observed, and also healed. The scheduler learns a job finished from the same event stream your app’s writes travel on, and every sweep also reconciles directly against the job rows. So a dropped event delays things by one sweep instead of stalling them.
Retention
Run history is pruned automatically, asymmetrically by outcome: success, skipped, and replaced runs are kept for 24h by default, while failed and killed are kept for 30d. A run that is still running is never pruned, however old.
That default is a project-wide setting (retention: in sp00ky.yml — see Jobs), and any single schedule can override it:
This matters most for fan-out. A minutely schedule over 500 rows writes 500 run rows and 500 job rows every minute; with concurrency: skip, a slow one writes 500 skipped rows per fire on top. Those are the rows nobody reads, so give that schedule a short history.success and keep the long window for failures.
Keeping nothing but failures
If successful runs are of no interest at all, skip the window and say so — per schedule, or project-wide as the default:
A schedule’s own history: always wins over the project default, in either direction — so one project-wide line plus an opt-out is usually all you need. Note that retention.mode is resolved when you deploy, unlike the retention windows which can be retuned with a single UPDATE.
Then a successful run never persists: a suppressed tick writes no row at all, and a run that completes cleanly is deleted as it finalizes rather than waiting out a window. Failures and kills are kept as normal, on the usual long window.
Nothing you read gets worse for it. The counts still land in the rollup (spky schedules get reports lifetime totals), and the schedule still records its last outcome, so spky schedules list is unchanged.
Two guarantees worth knowing. A run that is still running is never discarded, whatever the mode — concurrency: skip counts in-flight runs, so removing them early would let a slow fan-out stack up on itself. And the mode is frozen onto each run when it spawns, like a workflow’s DAG, so a redeploy never changes the fate of work already in flight.
It applies to the whole execution. A workflow that succeeds discards its run, every step row, and every job row its steps spawned — all together, once the run finalizes. A workflow that fails keeps all of it, including the job rows of the steps that succeeded, because those are what you read to work out why it died.
A discarded job row is a job you can no longer inspect: spky jobs get and spky jobs retry on a successful scheduled job report not-found, because there is no row left. That is the trade — and it is why this only ever applies to jobs a schedule spawned. A job created from your app with db.run() is never discarded this way, so a UI can still watch it succeed and read its result.
It also skews spky jobs: throughput ✓/min reads 0 and fail rate reads 100% whenever anything has failed, because both count surviving rows and the successes are gone. On a failures-only schedule the authoritative success volume is spky schedules get <name>, which reads the counters.
Pruned runs are counted before they are deleted, so spky schedules get <name> still reports lifetime totals (pruned : 41230 ok 12 failed) long after the rows are gone. They are a lower bound — see Jobs → Retention.
spky schedules list shows each schedule’s last outcome from the schedule row itself, not by scanning history, so it stays correct after the run rows are gone — and stays fast when one schedule fans out very wide. Within a single fire of a fan-out, a failure is reported in preference to a sibling success, since one status cannot summarise many items.
Validation
spky lint checks schedules as part of the normal config pass, so mistakes surface before a deploy rather than at 03:00:
- every cron expression parses, and every
everyinterval is at least 10s timezoneis a real IANA zone- each
backendis an app with an outbox method, and eachrouteexists in that backend’s OpenAPI spec forEach.queryis a singleSELECT- no name is used by both a schedule and a workflow
Schedules run on VM and cluster deployments. The Cloudflare (free) plan has no job runner, so a schedule there would create rows nothing executes. spky lint warns for the same reason when a schedule targets a devOnly backend. It will run locally under spky dev, but not in the cloud.
Reference
Schedule fields
| Prop | Type | Default | Description |
|---|---|---|---|
| cron | string | - | Cron expression, e.g. "0 3 * * *". Exactly one of cron or every. |
| every | duration | - | Fixed interval from each fire, e.g. 30s, 5m, 1h30m. Minimum 10s. |
| timezone | string | UTC | IANA zone the cron expression is evaluated in, e.g. Europe/Berlin. |
| backend | string | (required) | Name of an apps: entry with an outbox method. |
| route | string | (required) | POST route on that backend, e.g. /syncGames. |
| payload | object | {} | Static payload merged into every spawned job. |
| forEach.query | string | - | SurrealQL SELECT run each fire; one job per row, passed as payload.row. |
| forEach.key | string | id | Row field whose value keys the per-row concurrency check. |
| concurrency | skip | allow | replace | skip | What a fire does when the previous run for that key is still going. |
| retry.max | integer | 3 | Retries per run, before the run counts as failed. |
| retry.strategy | linear | exponential | linear | Backoff shape between retries. |
| timeout | duration | (backend default) | Per-job HTTP timeout. Honoured only when the backend sets deploy.timeoutOverridable. |
| enabled | boolean | true | false deploys the schedule but leaves it inert. Independent of spky schedules pause. |
Run statuses
| Prop | Type | Default | Description |
|---|---|---|---|
| running | status | - | In flight. |
| success | status | - | The job succeeded. |
| failed | status | - | The job exhausted its retries. |
| skipped | status | - | concurrency: skip suppressed this tick. The previous run for that key was still going. |
| replaced | status | - | concurrency: replace killed this run in favour of a newer fire. |
| killed | status | - | An operator stopped it. |