SapixDBSapixDB/Docs
Home
Community · Automation

Scheduled Crons

✓ Shipped

Run a SaQL query on a recurring schedule and write the results to an output agent automatically. Crons support both a simple interval mode and a full 5-field crontab expression (UTC). The engine persists last-run state across restarts, so missed ticks are never fired spuriously.

How it works

At startup, the cron engine reads all cron definitions from the internal _crons system agent. It then polls once per minute (for schedule-based crons) or on the configured interval. When a cron fires, the engine:

  1. Executes the stored SaQL query_config against the source agent.
  2. Writes each result record to the configured output_agent_id.
  3. Appends a run record to the _cron_runs system agent with the status and record count.
  4. Persists the last_run_ms timestamp to durable storage so a restart does not re-fire the same minute.

Schedule modes

Interval mode

Set interval_secs to a positive integer. The cron fires every N seconds starting from the moment it was last run. Simple and predictable for polling workloads.

Interval — every 5 minutes
POST /v1/crons
Authorization: Bearer spx_root_YOUR_ROOT_KEY
Content-Type: application/json

{
  "name":            "refresh_leaderboard",
  "interval_secs":   300,
  "query_config":    { "type": "current", "key": "player_id", "limit": 100 },
  "output_agent_id": "leaderboard_snapshot",
  "enabled":         true
}

Crontab schedule mode

Set schedule to a standard 5-field cron expression (UTC). The engine evaluates the schedule once per minute against the current UTC time. All fields use UTC — there is no timezone offset support.

5-field cron syntax
┌─────── minute      (0–59)
│ ┌───── hour        (0–23)
│ │ ┌─── day of month (1–31)
│ │ │ ┌─ month        (1–12)
│ │ │ │ ┌ day of week  (0–6, 0 = Sunday)
│ │ │ │ │
* * * * *
SyntaxMeaningExample
*every unit* in minute = every minute
Nexact value15 in minute = :15 past each hour
N-Minclusive range9-17 in hour = 9am–5pm
*/stepevery step units*/15 in minute = :00 :15 :30 :45
N-M/steprange with step0-30/10 in minute = :00 :10 :20 :30
N,M,...comma list1,15 in day = 1st and 15th of month
Crontab — every day at 03:00 UTC
POST /v1/crons
Authorization: Bearer spx_root_YOUR_ROOT_KEY
Content-Type: application/json

{
  "name":            "nightly_rollup",
  "schedule":        "0 3 * * *",
  "query_config":    { "type": "time_range", "start_ms": 0, "limit": 5000 },
  "output_agent_id": "daily_rollup",
  "enabled":         true
}
Crontab — every weekday at 09:00 UTC
{
  "name":     "morning_digest",
  "schedule": "0 9 * * 1-5",
  ...
}
interval_secs and schedule are mutually exclusive. Provide one or the other — providing both is valid but schedule takes precedence if set. Providing neither returns 400 Bad Request.

Persistent last-run tracking

The engine persists each cron's last-run timestamp to durable storage after every successful execution. On restart, the engine reads this value and uses it as the baseline for schedule evaluation. A cron whose scheduled minute already passed before restart will not fire again for that minute — it was already counted.

For interval-based crons the last-run timestamp determines the next fire time. If the server was down for multiple intervals, the cron fires once on resume — not once per missed interval.

HTTP API Reference

MethodPathDescription
GET/v1/cronsList all crons with last-run info.
POST/v1/cronsCreate a cron. Returns 201.
GET/v1/crons/:nameGet one cron by name.
DELETE/v1/crons/:nameTombstone a cron. Returns 204.
POST/v1/crons/:name/enableSet enabled=true, return updated CronView.
POST/v1/crons/:name/disableSet enabled=false, return updated CronView.
GET/v1/crons/:name/runsList up to 50 run records, newest first.
POST/v1/crons/:name/triggerFire the cron immediately regardless of schedule.

Create request body

FieldTypeRequiredDescription
namestringyesUnique cron name. Must not be empty.
interval_secsnumberone of theseFire every N seconds. Omit if using schedule.
schedulestringone of these5-field cron expression (UTC). Omit if using interval_secs.
query_configobjectyesSaQL query to execute. Same shape as a SaQL request body.
output_agent_idstringyesAgent that receives the query results.
descriptionstringnoHuman-readable description. Defaults to empty string.
use_interval_windowbooleannoIf true, the query window is pinned to the last interval. Defaults to false.
enabledbooleannoWhether the cron runs automatically. Defaults to false.
created_bystringnoAttribution label. Defaults to "api".

CronView response shape

CronView
{
  "name":               "nightly_rollup",
  "description":        "",
  "interval_secs":      0,
  "schedule":           "0 3 * * *",
  "query_config":       { ... },
  "output_agent_id":    "daily_rollup",
  "use_interval_window": false,
  "enabled":            true,
  "created_by":         "api",
  "created_at_ms":      1756224000000,
  "last_run_ms":        1756310400000,
  "last_run_status":    "ok"
}

Run record shape

CronRunRecord
{
  "name":          "nightly_rollup",
  "started_at_ms": 1756310400000,
  "duration_ms":   142,
  "records_out":   83,
  "status":        "ok",
  "error":         null
}

Manual trigger

POST /v1/crons/:name/trigger fires the cron immediately and returns the run record. Useful for testing or for backfilling output agents on demand. The trigger does not update last_run_ms used by the scheduler — the next scheduled fire happens at its normal time.

Trigger immediately
POST /v1/crons/nightly_rollup/trigger
Authorization: Bearer spx_root_YOUR_ROOT_KEY

Authentication and Scopes

Creating, deleting, enabling, and disabling crons requires write:crons. Listing and reading crons requires read:crons. A root key always has access.

Holding write:crons alone is not enough to create a cron: SapixDB also checks that the caller holds read:agents/<source>for the query's source agent and write:agents/<output> for output_agent_id — the same way an ordinary per-tenant request would be checked. This closes a gap where a key scoped only to crons could otherwise create a cron reading a source agent it had no relationship to. The same check runs again on POST /v1/crons/:name/trigger, since the caller triggering a cron may not be the one who created it.

A scheduled or triggered run stays bound to its creator's row-policy visibility on the source agent — the JWT claims presented when the cron was created are captured and re-applied on every execution, since the background scheduler has no live caller of its own to check against. See Row-Level Policies.

← Materialized Counters→ Named Procedures→ BlobStore