A task scheduling service that dispatches work to remote quantum computing backends.
- Task scheduling — accepts tasks and queues them per machine, dispatching to a pluggable backend (a real quantum backend or an in-process test backend).
- Project accounting — tasks can be charged against a project, a named, time-limited budget that tracks accumulated execution time and rejects new tasks once its limit is exceeded or it is deactivated.
- Sessions — an exclusive, time-limited reservation of a machine for a single project, so a sequence of tasks can run back-to-back without interleaving with other projects' work.
- Notifications — optional HTTP callbacks on task completion and session open/close.
- REST API — a JSON HTTP API for submitting and monitoring tasks, sessions, and projects, with an OpenAPI spec served at runtime.
cargo build --release -p qscheduleror build the Docker image:
docker build -t qscheduler .QScheduler is stateless except for PostgreSQL, which is the source of truth for machines,
projects, sessions, and tasks. Every invocation of the qscheduler binary requires the DATABASE_URL environment variable:
DATABASE_URL=postgres://user:password@host/dbname
Migrations (in migrations/) run automatically whenever the binary connects to the database;
no manual migration step is needed.
Local development: A
compose.ymlis included. Rundocker compose up -dto start a local PostgreSQL instance. A.envfile is also picked up automatically (viadotenvy), so you can putDATABASE_URL=...there instead of exporting it in your shell.
Quantum machines are registered directly in the
database using the qscheduler machine subcommand, then loaded by the service at startup.
qscheduler machine add <NAME> [OPTIONS] <BACKEND> [BACKEND OPTIONS]| Argument / option | Description |
|---|---|
<NAME> |
Unique machine name, referenced when submitting tasks and creating sessions. |
--queue-size <N> |
Maximum number of tasks that can be queued on this machine (default: 4). |
--notify-url <URL> |
If set, POST a callback here whenever a task on this machine reaches a terminal state, or a session on this machine opens or closes. See Notifications. |
--notify-token <TOKEN> |
Arbitrary token included in notification payloads. |
--session-check-interval <DURATION> |
How often session time-consumption is persisted to the database, e.g. 5s (default: 5s). |
--max-session-time <DURATION> |
Maximum duration a session on this machine may request, e.g. 2h. Sessions requesting longer are rejected at submit time (default: 2h). |
<BACKEND> is one of:
test — in-process test backend, useful for development and integration testing. Takes no
extra options.
qscheduler machine add SimulatorMachine testiqm — IQM quantum computing backend.
qscheduler machine add QuantumDevice iqm --url https://iqm.machine.com --token your-token --machine-name star24| IQM option | Description |
|---|---|
--url <URL> |
Base URL of the IQM service. |
--token <TOKEN> |
Bearer token for authentication. |
--machine-name <NAME> |
Name of the target quantum device on the IQM server. |
--check-interval <DURATION> |
Polling interval for job status, as a humantime string, e.g. 1s/500ms (the CLI's own --help text says "in milliseconds", but a bare integer is rejected — a unit suffix is required). |
qscheduler machine update <NAME> ... is intended to change an existing machine's configuration
using the same arguments as add.
Machines are only loaded once at service startup, so the service must be restarted after
machine add for the change to take effect.
Projects are created through the HTTP API rather than the CLI — see
POST /projects below.
curl -X POST http://localhost:4300/projects \
-H 'Content-Type: application/json' \
-d '{"name": "my-project", "limit_ms": 3600000}'qscheduler serve --port 4300docker run -it --network host -e DATABASE_URL=postgres://user:password@host/dbname \
qscheduler serve --port 4300Once running, the OpenAPI spec is served at GET /api-docs/openapi.json.
To try out the full flow locally with no external dependencies, use the in-process test
backend (see Backends) instead of a real quantum backend. compose.yml publishes
PostgreSQL on the host's 5432 port, so the service container can just reach it at localhost
by running with --network host — no custom Docker network needed.
-
Start PostgreSQL:
docker compose up -d
-
Build the image:
docker build -t qscheduler . -
Register a machine using the
testbackend:docker run --rm --network host \ -e DATABASE_URL=postgres://postgres:xpass11@localhost/postgres \ qscheduler machine add TestMachine test -
Start the service (machines are only loaded at startup, so this must run after step 3):
docker run -d --name qscheduler --network host \ -e DATABASE_URL=postgres://postgres:xpass11@localhost/postgres \ qscheduler serve --port 4300
-
Exercise it — register a project, then submit a task against
TestMachine:curl -X POST http://localhost:4300/projects \ -H 'Content-Type: application/json' \ -d '{"name": "test-project", "limit_ms": 3600000}' curl -X POST 'http://localhost:4300/tasks?machine=TestMachine&project=test-project&user=me' \ -H 'Content-Type: application/octet-stream' \ --data-raw '{"outcome": {"type": "Ok"}}'
Poll
GET /tasks/{id}with the returned task ID to see it move from"waiting"to"finished".
test — the task payload is a JSON object:
{"outcome": {"type": "Ok"}, "result": "42", "artifacts": {"log": "some log"}}
{"outcome": {"type": "Fail", "message": "error text"}, "submit_time": 0.5, "compute_time": 1.5}| Field | Type | Required | Description |
|---|---|---|---|
outcome |
object | yes | Either {"type": "Ok"} or {"type": "Fail", "message": "..."}. |
result |
string | no | Value returned by GET /tasks/{id}/result once the task completes (default: ""). |
artifacts |
object | no | Map of artifact name to value, served by GET /tasks/{id}/artifacts/{name} once the task completes (default: {}). |
submit_time |
float | no | Seconds to wait before acknowledging submission (default: 0). |
compute_time |
float | no | Seconds to wait before reporting the final result (default: 0). |
iqm — the task payload must be a JSON circuit description accepted by the IQM API
(POST /api/v1/jobs/{machine_name}/circuit).
When --notify-url is configured on a machine, the service sends an HTTP POST request to
that URL:
- after every task on the machine reaches a terminal state (
finished,failed, orcancelled); - whenever a session on the machine opens or closes.
Delivery is retried with exponential backoff up to 16 times.
Task events have this request body, with task in the same shape returned by
GET /tasks/{id}:
{
"event": "task",
"task": {
"id": 42,
"project": "my-project",
"user": "me",
"backend_id": "abc123",
"machine": "QuantumDevice",
"state": "finished",
"created_at": "2026-07-13T10:00:00Z",
"finished_at": "2026-07-13T10:00:05Z",
"exectime_ms": 1234
},
"token": "my-secret-token"
}The task fires once it reaches a terminal state: "finished", "failed", or "cancelled".
Session events have this request body, with session in the same shape returned by
GET /sessions/{id}:
{
"event": "session",
"session": {
"id": 7,
"state": "open",
"machine": "QuantumDevice",
"project": "my-project",
"time_limit_ms": 3600000,
"created_at": "2026-07-13T10:00:00Z",
"opened_at": "2026-07-13T10:00:01Z"
},
"token": "my-secret-token"
}The session fires once when it opens (session.state is "open") and once when it closes
(session.state is "closed"). A session that is cancelled before ever opening only fires the
"closed" event.
The token field is omitted when --notify-token was not configured.
The service exposes a JSON REST API. All responses use Content-Type: application/json unless
noted otherwise. An OpenAPI spec is available at GET /api-docs/openapi.json.
Tasks belong to either a project (a time-accounted budget shared across tasks) or a session (an exclusive, time-limited reservation of a machine for a project) — never both.
Returns the service version string.
Response 200 — plain text, e.g. qscheduler v1.0.0.
Health check. Reports whether the service is up and able to reach the database.
Response 200 — the database is reachable:
{ "status": "ok" }Response 503 — the database is unreachable:
{ "status": "unhealthy" }Submit a task for execution.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
machine |
string | yes | Name of the target machine. |
project |
string | exactly one of project / session_id |
Name of the project to charge the task's time to. |
session_id |
integer | exactly one of project / session_id |
Session to associate the task with. The session must be in the "open" state or the task is rejected. |
user |
string | yes | Free-form identifier of the user submitting the task, stored alongside the task. |
Request body — application/octet-stream — raw task payload forwarded to the backend.
Response 201 — task ID as a JSON integer.
Response 402 — the project has exceeded its time limit, or the project is not active.
Response 422 — neither or both of project/session_id given, unknown machine/project, or the session is invalid or not open.
Get a task's current info. Works for tasks no longer held in memory too (e.g. after a service restart) — the service falls back to the database for terminal tasks.
Response 200
| Field | Type | Present | Description |
|---|---|---|---|
id |
integer | always | Task ID. |
session |
integer | if the task belongs to a session | Session the task ran in. Mutually exclusive with project. |
project |
string | if the task belongs to a project directly | Name of the project the task's time is charged to. Mutually exclusive with session. |
user |
string | always | Free-form identifier of the submitting user. |
backend_id |
string | once submitted to the backend | Backend-assigned job ID. |
machine |
string | always | Name of the target machine. |
state |
string | always | One of "waiting", "running", "finished", "failed", "cancelled". |
created_at |
timestamp | always | When the task was submitted. |
finished_at |
timestamp | once in a terminal state | When the task reached its final state. |
exectime_ms |
integer | if any execution time was consumed | Milliseconds of execution time. |
error |
string | only when state is "failed" |
Failure reason. |
{
"id": 42,
"project": "my-project",
"user": "me",
"backend_id": "abc123",
"machine": "QuantumDevice",
"state": "failed",
"created_at": "2026-07-13T10:00:00Z",
"finished_at": "2026-07-13T10:00:05Z",
"exectime_ms": 1234,
"error": "out of memory"
}Response 404 — task not found.
Request cancellation of a task. Cancellation is asynchronous: a queued task is removed from the
queue, while a running task is cancelled on the backend; in both cases the task eventually
reaches the "cancelled" state (poll GET /tasks/{id} to observe it).
Response 202 — cancellation requested.
Response 404 — task not found.
Response 409 — task already in a terminal state (finished, failed, or cancelled).
Fetch a task's raw result from the backend that ran it.
Response 200 — plain text, backend-specific (e.g. the full job-status JSON document for
the iqm backend, or the raw result string for the test backend).
Response 404 — task not found.
Response 409 — task has not been submitted to a backend yet.
Response 500 — backend request failed.
Fetch a named artifact produced for a task from the backend that ran it.
Response 200 — plain text, backend-specific.
Response 404 — task not found, or the named artifact doesn't exist on the backend.
Response 409 — task has not been submitted to a backend yet.
Response 500 — backend request failed.
Create a session — a time-bounded, exclusive reservation of one machine for one project. Tasks in the session are cancelled when the session closes.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
machine |
string | yes | Name of the target machine. |
project |
string | yes | Name of the project the session's time is charged to. |
time_limit_ms |
integer | yes | Session lifetime in milliseconds. |
Response 201 — session ID as a JSON integer.
Response 402 — the project has exceeded its time limit, or the project is not active.
Response 404 — unknown machine.
Response 422 — unknown project, or time_limit_ms exceeds the machine's --max-session-time.
Get a session's current info. Works for closed sessions no longer held in memory too (e.g. after a service restart) — the service falls back to the database.
Response 200
| Field | Type | Present | Description |
|---|---|---|---|
id |
integer | always | Session ID. |
state |
string | always | One of "waiting", "open", or "closed". |
machine |
string | always | Name of the reserved machine. |
project |
string | always | Name of the project the session's time is charged to. |
time_limit_ms |
integer | always | Session lifetime in milliseconds. |
created_at |
timestamp | always | When the session was created. |
opened_at |
timestamp | once opened | When the session became active. |
closed_at |
timestamp | once closed | When the session ended. |
exectime_ms |
integer | if any execution time was consumed | Milliseconds the session was open and accruing time (may appear before closed_at, as it's checkpointed periodically while open). |
{
"id": 7,
"state": "closed",
"machine": "QuantumDevice",
"project": "my-project",
"time_limit_ms": 3600000,
"created_at": "2026-07-13T10:00:00Z",
"opened_at": "2026-07-13T10:00:01Z",
"closed_at": "2026-07-13T11:00:01Z",
"exectime_ms": 3600000
}Response 404 — session not found.
Request cancellation of a session. Cancellation is asynchronous: a queued ("waiting") session
is removed from the queue, while an "open" session is closed and its tasks are cancelled (both
those already submitted to the backend and those still queued); in both cases the session
eventually reaches the "closed" state.
Response 202 — cancellation requested.
Response 404 — session not found.
Response 409 — session already closed.
Register a project — a named, time-accounted budget that tasks and sessions are charged against.
Request body
{"name": "my-project", "limit_ms": 3600000, "active": true}| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Unique project name. |
limit_ms |
integer | yes | Time budget, in milliseconds. |
active |
boolean | no | Whether the project can accept new tasks/sessions (default: true). |
Response 201 — empty body.
Response 409 — a project with this name already exists.
List all projects.
Response 200 — JSON array of project objects (see below).
Get a single project by name.
Response 200
{"name": "my-project", "consumed_ms": 120000, "limit_ms": 3600000, "active": true}Response 404 — project not found.
Update a project's active flag and/or time limit_ms. Fields omitted from the request body
are left unchanged. Does not affect consumed_ms.
Request body
{"active": false, "limit_ms": 7200000}| Field | Type | Required | Description |
|---|---|---|---|
active |
boolean | no | Whether the project can accept new tasks/sessions. Omit to leave unchanged. |
limit_ms |
integer | no | Time budget, in milliseconds. Omit to leave unchanged. |
Response 200 — the updated project.
{"name": "my-project", "consumed_ms": 120000, "limit_ms": 7200000, "active": false}Response 404 — project not found.
Fetch the backend's architecture description.
Response 200 — plain-text/JSON string, backend-specific.
Response 404 — unknown machine.
Response 500 — backend error.
Fetch a calibration data set from the backend.
Response 200 — plain-text/JSON string, backend-specific.
Response 404 — unknown machine.
Response 500 — backend error.