Skip to content

Scheduler

The scheduler turns a cron expression into a guaranteed-once-per-occurrence event. Everything time-based in Subspace, date reminders, recurring agent runs, trigger schedules, and the app’s own backups, rides this one engine.

Each row in ops.schedules (cron, target, a single_flight_key, last_run) drives a croner job. When the cron boundary hits, fire runs one Postgres transaction that:

  1. Takes a single-flight lock

    pg_try_advisory_xact_lock on the schedule’s key. If another fire for the same schedule is already mid-transaction, this one backs off rather than racing it.

  2. Checks the watermark

    If last_run is already at or past the occurrence being fired, the fire is a no-op, this is what makes a redelivered or duplicate fire harmless.

  3. Commits watermark + event + run row

    last_run advances, a schedule-fired event lands on the events spine, and a row lands in the task queue’s Scheduled section, all in the same transaction.

After that transaction commits, the fire dispatches its target: an agentPageId starts an agent run, a systemJob name runs a built-in maintenance job, or a triggerPageId fires a schedule-kind trigger page. That dispatch happens outside the transaction, but is itself claim-keyed on the occurrence timestamp, so a redelivered kick can’t double-run it.

If the server was down across one or more scheduled occurrences, boot catches up exactly one fire per schedule: it walks forward from the schedule’s last_run watermark computing each missed occurrence, and fires using the latest one, not the first. Firing the latest occurrence (rather than the earliest) means the watermark lands current in a single catch-up fire even when two or more periods were slept through, so the next boot doesn’t see a stale watermark and fire again.

/settings/schedules lists every schedule: name, cron expression, last run (with its status: ran / failed / running), and next occurrence, computed live from the cron. New schedule creates one from a name and a cron string, validated before it saves. The same upcoming and past runs also render in the task queue’s Scheduled section, so you don’t need to leave the queue to see what’s coming.

Subspace’s own maintenance work is seeded as ordinary schedules targeting a systemJob name rather than an agent page:

Job What it does
blob-gc Sweeps orphaned file blobs no page references anymore.
Nightly backup Runs the WAL-G/restic backup pass, see Backup & restore.
Retention Applies retention policy and rebuilds any consumer (search index, OKF export, blob replica) that policy touched.
Restore drill Periodically restores the latest backup into a scratch environment to prove it actually works.

Because these are ordinary ops.schedules rows, they show up in /settings/schedules and the task queue exactly like an agent schedule does.

A trigger page can fire on a schedule instead of (or alongside) an event filter, “9am daily, run the research heartbeat” is a schedule-kind trigger under the hood, using the same fire path and the same missed-run catch-up described above.