Skip to content

Cron jobs

A _cron_jobs record is the whole job: name, a 5-field cron expression, and a raw SQL statement to run on it — editable from the dashboard’s Cron jobs screen or the generic Records API, no match-arm registry and no rebuild for a new job type. Changes are reactive: creating, updating, or deleting a _cron_jobs row takes effect on the live scheduler immediately, no restart. lastRunAt/lastStatus/ lastMessage are written back after every run, including the real driver error on failure, and a job can be run on demand via POST /api/crons/custom:<id>.

A job registered from JavaScript with cronAdd works the same way, minus the dashboard CRUD (it lives in a pb_hooks/*.pb.js file, not a record).

Every SQL _cron_jobs run and every JS cronAdd run also lands one row in _cronRuns — jobId (the same id GET /api/crons lists: custom:<id> for a SQL job, the raw cronAdd id otherwise), source (sql/js), status (running/success/error), startedAt, tickAt, durationMs, and message. tickAt is the scheduled minute this run claims (see Multi-node safety below) — null on a row written before tickAt existed. This is what gives a JS cronAdd job — which otherwise has no row of its own anywhere — the same run-history visibility _cron_jobs’ own lastRunAt/lastStatus/lastMessage columns already gave SQL jobs; the dashboard’s Cron jobs tab shows both. Query _cronRuns directly (GET /api/collections/_cronRuns/records?filter=jobId%3D%22custom%3Aabc123%22&sort=-startedAt) for a fuller history than the “last run” snapshot alone.

Each server node’s own scheduler decides independently that a job is due — with more than one node sharing a Postgres database, every node can reach the same scheduled minute at once. Every node normalizes that minute the same way (the tick’s timestamp floored to the minute, called tickAt), so two nodes racing the same tick always agree on the exact value. Before running a job’s body, a node claims that tick by inserting the _cronRuns row for it itself — status: "running" — guarded by a UNIQUE (jobId, tickAt) index with INSERT ... ON CONFLICT DO NOTHING (Postgres) / INSERT OR IGNORE (SQLite): only the node whose insert actually lands a row has won the claim, and only that node’s job body ever runs, for both SQL and JS jobs alike. Every other node’s insert affects zero rows and it skips silently, before the job body ever runs. The winner updates that same row in place once the body finishes (status: "success"/"error", durationMs, message).

Because the claim is a real, atomically-enforced database constraint — not a lock that is only held for the moment a check runs — it holds even when one node’s run has already fully finished before a slower sibling node gets around to considering the same tick at all. SQLite (never multi-node) always wins its own claim, so single-node behavior is unchanged: exactly one _cronRuns row per run.