Github|...

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().

# sp00ky.yml
schedules:
  nightly-cleanup:
    cron: "0 3 * * *"          # 03:00, every day
    backend: api               # an app with an outbox method
    route: /cleanupExpired     # a POST route on that backend
    payload:
      olderThanDays: 30
// Your backend, unchanged, a scheduled run is an ordinary job POST.
app.post('/cleanupExpired', async (req, res) => {
  const { olderThanDays } = req.body;
  const deleted = await purgeExpired(olderThanDays);
  res.json({ deleted });          // the response body is stored on the job row
});

That’s the whole integration: your backend doesn’t know it was called by a schedule.

Note

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.

schedules:
  # Cron: 5 fields (minute hour day month weekday), evaluated in `timezone`.
  nightly:
    cron: "0 3 * * *"
    timezone: Europe/Berlin      # optional; defaults to UTC
    backend: api
    route: /nightly

  # Interval: measured from each fire, not from completion.
  poll-upstream:
    every: 5m                    # 30s | 5m | 1h30m, minimum 10s
    backend: api
    route: /pollUpstream

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.

Note

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.

schedules:
  game-sync:
    every: 5m
    backend: api
    route: /syncGames
    forEach:
      query: SELECT id FROM connection WHERE active = true
      key: id                    # per-row concurrency key (default: id)
    concurrency: skip            # skip | allow | replace
    retry: { max: 3, strategy: linear }
    timeout: 120s
// Each row arrives under `payload.row`.
app.post('/syncGames', async (req, res) => {
  const { row } = req.body;      // { id: 'connection:abc' }
  const games = await syncGamesFor(row.id);
  res.json({ synced: games.length });
});

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.
Note

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.

Note

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.

# The whole section from one file:
schedules: ./schedules.yml

# Or one file per definition, mixed with inline ones:
schedules:
  game-sync: ./schedules/game-sync.yml
  quick-ping:
    every: 1h
    backend: api
    route: /ping

Operating them

$ spky schedules list
NAME                     KIND      CADENCE        STATE     NEXT FIRE            LAST RUN
game-sync                job       every 5m       active    2026-07-25 09:35:00 success
monthly-report           workflow  0 6 1 * *      active    2026-08-01 06:00:00 running
nightly-cleanup          job       0 3 * * *      paused    2026-07-26 01:00:00 success
stale-poller             job       every 1h       error     -                    ·  -
  last error: forEach query failed: query: Parse error at 'FROM'

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.

spky schedules get game-sync        # definition, clock, recent runs
spky schedules pause game-sync     # stop future fires
spky schedules resume game-sync    # plan the next fire from now
spky schedules trigger game-sync   # fire once now, cadence untouched
spky schedules runs game-sync      # run history, newest first
spky schedules runs --status failed
spky schedules sync                # re-push sp00ky.yml without a deploy

# Every read supports --json for scripting:
spky schedules list --json | jq '.[] | select(.paused) | .name'

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 runs game-sync
SCHEDULE               STATUS     FIRED                TRIGGER   KEY
game-sync success  2026-07-25 09:30:00  cron      connection:alice
game-sync skipped  2026-07-25 09:30:00  cron      connection:bob
game-sync success  2026-07-25 09:25:00  cron      connection:alice
game-sync success  2026-07-25 09:25:00  cron      connection:bob

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 (and spky dev) upserts each definition into an internal _00_schedule table. Three writers own disjoint fields: your manifest owns the definition, you own paused, 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_run row (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’s input), which is where spky jobs get shows 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:

schedules:
  sync-every-tenant:
    every: 1m
    backend: api
    route: /sync
    forEach:
      query: SELECT id FROM tenant
      key: id
    history:
      success: 15m     # this schedule keeps far less than the project default
      failed: 30d

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:

schedules:
  sync-every-tenant:
    every: 1m
    backend: api
    route: /sync
    forEach:
      query: SELECT id FROM tenant
      key: id
    history: failures-only    # successful runs never persist

  # Or combine the mode with a window for the failures you do keep:
  other-job:
    every: 5m
    backend: api
    route: /other
    history:
      mode: failures-only
      failed: 7d
# sp00ky.yml — the default for every schedule
retention:
  mode: failures-only
  failed: 14d          # failures still keep a real window

schedules:
  audit-trail:
    cron: "0 3 * * *"
    backend: api
    route: /audit
    history: all       # ...except this one, which keeps everything

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.

Note

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.

Warning

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.

Note

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 every interval is at least 10s
  • timezone is a real IANA zone
  • each backend is an app with an outbox method, and each route exists in that backend’s OpenAPI spec
  • forEach.query is a single SELECT
  • no name is used by both a schedule and a workflow
Warning

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.

See also