Skip to content

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 (MONDAYSUNDAY) 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_Howe observe that region's daylight saving rules.
  • Fixed-offset ids UTC, GMT, Z, -05:00, +05:30, Etc/GMT+5 never shift, and so never skip or repeat a fire.
  • Legacy aliases such as US/Eastern and EST5EDT are accepted. Bare abbreviations such as EST or PST are not, and are rejected with a 400.

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, and Monthly do not shift the fire, they skip it. A Daily at 02:30 in America/New_York fires on 2026-03-07 and then on 2026-03-09; nothing runs on 2026-03-08. A Weekly at SUNDAY 02:30 loses the entire week, and a Monthly whose dayOfMonth lands on the transition day loses the entire month.
  • Hourly loses 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_Howe moves 02:00 to 02:30, and a Daily anywhere in 02:0002:59 is skipped that day including 02:45, which does exist locally.

Fall back: a local time that occurs twice

  • Daily, Weekly, and Monthly fire once, at the first (pre-transition) occurrence. A Daily at 01:30 in America/New_York fires at 2026-11-01T05:30:00Z and not again at 06:30Z.
  • Hourly fires 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:30 daily job at 01:31 on the first pass fires it 59 minutes later, at 01:30 on the second pass.

Consequences worth planning for

  • The absolute gap between wall-clock fires is not constant. A Daily job 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.
  • Interval has the mirror-image behavior. It holds its absolute spacing and drifts against the local clock. Use a wall-clock schedule to pin local time, Interval to 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.
  • nextFireAt and lastFireAt are absolute UTC instants, not local times. Convert them into the job's timezone before comparing against at.
  • 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