Scheduled Jobs¶
A job is a named, recurring schedule paired with an action to perform each time it fires. Jobs are how you run the same work on a cadence such as a nightly count, an hourly export, or a weekly cleanup without an external scheduler.
Jobs are system-scoped: one job list serves the whole Quine deployment, and each job is identified by a name you choose. The graph the work runs against is part of the action, not the job's URL.
Today the only action is a background query. Each time a job fires it dispatches one background-query execution, with its own execution id and its own status record.
Jobs and executions¶
A job and a run of that job are different things, and they live at different endpoints:
| Job | Execution | |
|---|---|---|
| Scope | System-wide | One graph |
| Identified by | A name you choose | A server-generated UUID |
| Endpoint | /api/v2/system/jobs |
/api/v2/graph/{graphName}/backgroundQueries |
| Lifetime | Until you delete it | Until its status record expires |
A job does not list its own runs. To find them, filter the executions of the graph the job targets by the job's name:
curl "http://localhost:8080/api/v2/graph/quine/backgroundQueries?jobName=nightly-count"
Each of those records is an ordinary background-query execution: pollable, tappable, and cancellable exactly as described in Background Queries.
Creating a job¶
curl -X POST "http://localhost:8080/api/v2/system/jobs" \
-H 'Content-Type: application/json' \
-d '{
"name": "nightly-count",
"schedule": {
"type": "Daily",
"at": "02:30",
"timezone": "America/New_York"
},
"action": {
"type": "BackgroundQuery",
"namespace": "quine",
"query": "MATCH (n) RETURN count(n) AS total",
"destinations": [{"type": "Drop"}]
}
}'
{"name": "nightly-count"}
| Field | Required | Description |
|---|---|---|
name |
Yes | Unique name identifying the job. Trimmed; must be non-empty and free of control characters. |
schedule |
Yes | When the job fires. See Schedules. |
action |
Yes | What the job does each time it fires. See Actions. |
updateIfExists |
No | If a job with this name already exists: true replaces its definition in place, false (the default) rejects the request with a 400. |
The schedule and the query are both validated when the job is created, so a malformed schedule or an uncompilable query is a 400 at creation rather than a job that fails on every fire.
Replacing a job¶
Jobs are keyed by name, so creating a job whose name is already taken is rejected unless you pass updateIfExists: true, which replaces the definition in place. There is no separate update endpoint.
Replacing a job re-evaluates its schedule as if it were newly created, while preserving its run history. In particular, an Interval schedule that still omits startAt re-anchors to the replacement time and fires immediately again.
Schedules¶
Schedules come in two families, distinguished by the type discriminator.
Interval¶
Interval fires at a fixed cadence anchored to an instant: at startAt, then every every thereafter.
{"type": "Interval", "every": "6h", "startAt": "2026-09-01T00:00:00Z"}
| Field | Required | Description |
|---|---|---|
every |
Yes | The gap between fires, as a duration string such as "1h30m" or "24h". Minimum one hour. |
startAt |
No | RFC-3339 anchor instant. If omitted, the anchor is the moment the job is created or replaced, so the job fires immediately and then every interval. |
Fires land on the grid startAt + k × every, so an interval schedule never drifts: a late or delayed fire does not push subsequent fires back. A future anchor waits; a past anchor collapses the missed slots and resumes on the next multiple.
Interval has no timezone. Its cadence is measured in absolute elapsed time, so it never skips or repeats a fire at a daylight saving transition. Instead it drifts against the local clock by the size of the transition. A 24-hour interval that fires at 09:00 local time fires at 10:00 local after a spring-forward.
Wall-clock schedules¶
The other four schedules fire when the local clock in a named timezone reads the requested time.
{"type": "Hourly", "minute": 15, "timezone": "UTC"}
Fires once an hour at minute (0–59).
{"type": "Daily", "at": "09:30", "timezone": "America/New_York"}
Fires once a day at at, given as "HH:mm" or "HH:mm:ss".
{"type": "Weekly", "dayOfWeek": "MONDAY", "at": "09:30", "timezone": "Europe/London"}
Fires once a week on dayOfWeek (MONDAY … SUNDAY) at at.
{"type": "Monthly", "dayOfMonth": 1, "at": "00:00", "timezone": "UTC"}
Fires once a month on dayOfMonth (1–31) at at. Months that do not have that day are skipped: a dayOfMonth of 31 does not fire in February.
Every wall-clock schedule requires a timezone, so the time of day is never ambiguous.
Time zones and daylight saving time¶
timezone accepts any zone id known to the tz database of the running JVM, matched case-sensitively:
- Region ids
America/New_York,Europe/London,Australia/Lord_Howeobserve that region's daylight saving rules. - Fixed-offset ids
UTC,GMT,Z,-05:00,+05:30,Etc/GMT+5never shift, and so never skip or repeat a fire. - Legacy aliases such as
US/EasternandEST5EDTare accepted. Bare abbreviations such asESTorPSTare not, and are rejected with a400.
Zone rules come from the tz database bundled with the running JVM, so a JVM or OS upgrade that revises a zone's rules also changes the future fire times of jobs already scheduled in it.
Spring forward: a local time that does not exist¶
Daily,Weekly, andMonthlydo not shift the fire, they skip it. ADailyat02:30inAmerica/New_Yorkfires on 2026-03-07 and then on 2026-03-09; nothing runs on 2026-03-08. AWeeklyatSUNDAY 02:30loses the entire week, and aMonthlywhosedayOfMonthlands on the transition day loses the entire month.Hourlyloses exactly one fire, leaving 23 that day.- The skip covers the whole clock hour containing the transition, not only the minutes that literally do not exist. This is visible only in zones whose shift is not a whole hour:
Australia/Lord_Howemoves 02:00 to 02:30, and aDailyanywhere in02:00–02:59is skipped that day including02:45, which does exist locally.
Fall back: a local time that occurs twice¶
Daily,Weekly, andMonthlyfire once, at the first (pre-transition) occurrence. ADailyat01:30inAmerica/New_Yorkfires at2026-11-01T05:30:00Zand not again at06:30Z.Hourlyfires at both occurrences, giving 25 fires that day.- Firing once is a consequence of advancing past the repeated hour, so a job whose next fire is first computed from a moment inside that hour does land on the second occurrence. Creating the
01:30daily job at01:31on the first pass fires it 59 minutes later, at01:30on the second pass.
Consequences worth planning for¶
- The absolute gap between wall-clock fires is not constant. A
Dailyjob fires 23 hours after its predecessor on a spring-forward day and 25 hours after it on a fall-back day. An action that assumes it covers exactly 24 hours of data will under- or over-cover on those two days. Intervalhas the mirror-image behavior. It holds its absolute spacing and drifts against the local clock. Use a wall-clock schedule to pin local time,Intervalto pin elapsed time.- Missed fires collapse. However many slots elapse while the scheduler is down, recovery produces a single catch-up fire and then resumes the schedule normally. Slots are not replayed. This is independent of DST, but compounds with it.
nextFireAtandlastFireAtare absolute UTC instants, not local times. Convert them into the job'stimezonebefore comparing againstat.- A job that must never skip or double up belongs on
UTC, at the cost of its local firing time moving twice a year.
Actions¶
An action is what the job does on each fire, discriminated by type. BackgroundQuery is currently the only one.
{
"type": "BackgroundQuery",
"namespace": "quine",
"query": "MATCH (n:Stale) DETACH DELETE n",
"destinations": [{"type": "Drop"}],
"name": "stale-cleanup",
"parameters": {},
"statusExpiry": "168h"
}
| Field | Required | Description |
|---|---|---|
query |
Yes | The Cypher query to run on each fire. |
destinations |
Yes | A non-empty list of destinations the result rows are streamed to. Use [{"type": "Drop"}] for a pure side-effect run. |
namespace |
No | The graph the query runs in. Defaults to the default graph. |
name |
No | A human-readable name, surfaced in each execution's record. |
parameters |
No | Cypher parameters, as a JSON object. |
statusExpiry |
No | How long each execution's status record is retained after that execution terminates. Defaults to one week. |
Because jobs are system-scoped, the target graph is named here rather than in the URL. This is the one place a job's graph is recorded. The job status endpoints do not report it back.
Every field except namespace matches the run-now request body exactly.
Job status¶
curl "http://localhost:8080/api/v2/system/jobs/nightly-count"
{
"name": "nightly-count",
"jobType": "background-query",
"schedule": {"type": "Daily", "at": "02:30", "timezone": "America/New_York"},
"nextFireAt": "2026-09-02T06:30:00Z",
"lastFireAt": "2026-09-01T06:30:00Z",
"running": true
}
| Field | Description |
|---|---|
jobType |
The kind of work this job dispatches. |
schedule |
The job's schedule, as submitted. |
nextFireAt |
The next scheduled fire, as an absolute UTC instant. Absent for a schedule that never fires again. |
lastFireAt |
The most recent fire, as an absolute UTC instant. |
running |
Whether a dispatched execution is currently in flight. |
Job status does not report the action
A job's status reports its schedule but not its action. To change a job's query, re-create it with updateIfExists: true; to know what it runs, look at one of its executions, which carries the query text.
List every job with GET /api/v2/system/jobs.
Deleting a job¶
curl -X DELETE "http://localhost:8080/api/v2/system/jobs/nightly-count"
Deleting a job removes it from the scheduler, cancels any of its executions that are still running, and erases its persisted state. Status records of past executions are left to expire on their own, and remain queryable by the job's name until they do. The response is the job's status as it was immediately before deletion.
Delivery guarantees¶
Scheduled fires are at-least-once. A run that is interrupted (because the host executing it restarted) is re-fired on recovery. Design job queries to be idempotent, or to tolerate being run twice for the same slot.
This is deliberately different from a background query started directly, which is at-most-once: nothing re-runs it if its host dies. See Delivery Guarantees for how this fits with the rest of Quine's guarantees.
Only one execution of a given job runs at a time. If a fire arrives while the previous one is still in flight, it does not start a second overlapping run.
Using the UI¶
The Streams page has a Scheduled Jobs panel that lists jobs, creates them, expands a job to show its dispatched runs, and deletes them. See Streams.
API reference¶
| Operation | Endpoint |
|---|---|
| Create a scheduled job | POST /api/v2/system/jobs |
| List scheduled jobs | GET /api/v2/system/jobs |
| Get a scheduled job's status | GET /api/v2/system/jobs/{name} |
| Delete a scheduled job | DELETE /api/v2/system/jobs/{name} |
Next steps¶
- Background Queries: status, retention, cancellation, and watching results live.
- Standing Queries: destination types and formats.