Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

cronoq is an elastic, priority-aware task scheduler. It decides when each piece of work runs, in what order, and how much capacity a pool of workers needs. Where the work runs is up to you: async functions in your Rust process, Python coroutines, or remote workers written in any language and connected over gRPC.

            submit                 dispatch                  execute
 your code ─────────▶  cronoq  ─────────────▶  executor / remote worker
     ▲                (ordering, limits,             │
     └──── result ───  reservations, scaling) ◀──────┘ outcome

What it does

CapabilityWhat you get
Priorities with agingFour classes (critical, high, normal, low), FIFO within a class, and aging so that a stream of normal work cannot starve low-priority work forever.
DeadlinesTasks with a deadline gain urgency smoothly as their latest start time approaches, and are ordered earliest-deadline-first within their class.
Fixed starts and cronWork that must start at a given time reserves capacity in advance and gets a worker held for it, with a policy for what to do if it still misses its slot.
DependenciesDAGs by task id or key, with on_success, on_failure and on_complete edges. Batches are admitted atomically.
ReliabilityLeases with heartbeats, per-attempt timeouts, retries with exponential backoff and retry_after, dead-lettering, cancellation, idempotent keys.
Shared resourcesSemaphores and token buckets shared across pools: API rate limits, GPU slots, database connections.
ElasticityEach pool computes how many workers it needs from arrival rate, backlog, wait-time SLOs and upcoming reservations, and asks a pluggable scaler for them.
BackpressureBounded queues that make submitters wait, reject, or shed lower-priority work.
Durability and HAPostgres and SQLite stores with leader election and fencing; standby servers take over and resume every accepted task.
ObservabilityOpenTelemetry metrics and trace propagation, Prometheus /metrics, and windowed quality metrics such as priority adherence and fixed-start hit rate.

Three ways to use it

  1. Rust library (cronoq crate): embed the scheduler in a tokio application. Payloads are plain Rust values; nothing is serialized. See the Rust quickstart.
  2. Python package (pip install cronoq): the same engine with asyncio bindings. Payloads are Python objects. See the Python quickstart.
  3. Server (cronoq-server): a standalone service with a gRPC and HTTP/JSON API. Clients in any language submit work and remote workers lease it, with SDKs for Rust, Python, Go and TypeScript. See the Server quickstart.

All three share one engine, so the scheduling rules and metrics are the same everywhere. The Rust library exposes a few engine settings, such as the priority and urgency parameters, that the Python package and the server leave at their defaults.

How to read this book

Why cronoq

Most systems start with a plain queue: push jobs, have workers pop them. That works until the workload stops being uniform. Then the same problems appear again and again:

SymptomRoot causeWhat cronoq does
A user-facing request waits behind thousands of batch jobs.FIFO queues have no notion of importance.Four priority classes; dispatch always picks the most urgent ready task.
Low-priority jobs never run during busy periods.Strict priority starves the bottom.Aging raises a waiting task's score over time.
Jobs miss their SLA even though there was capacity earlier.The queue does not know about deadlines.Deadline urgency and earliest-deadline-first ordering.
The 02:00 report starts at 02:07 because every worker was busy.Cron and the work queue compete for the same workers.Fixed starts reserve capacity and hold a worker ahead of time.
A burst of retries hammers an API into more 429s.Rate limits live in each worker, not in the scheduler.Shared token buckets gate dispatch; retry_after is honoured.
Workers sit idle at night and are saturated at noon.Fixed fleet size, or CPU-based autoscaling that ignores the queue.Queue-pressure autoscaling per pool, with reservations pre-warming capacity.
A crashed worker loses its job.No leases or heartbeats.Leases, heartbeats and retries give at-least-once execution.
A downstream step runs although its input failed.Dependencies managed by hand.DAG edges with success/failure/completion conditions.
Memory grows without bound during an incident.Unbounded queues.Bounded queues with wait, reject or shed policies.

cronoq puts all of these rules in one place, the scheduling engine, so that individual workers stay simple: they receive a task, run it, and report the outcome.

Benefits in practice

  • Better latency for important work without separate infrastructure. Interactive and background work can share one pool of workers. Priority and deadlines decide the order, so you do not need a dedicated "fast lane" cluster that is idle most of the time.
  • Predictable scheduled jobs. A job with a fixed start time has its capacity booked when it is submitted, and a worker is held free shortly before it starts. Admission fails up front (ReservationConflict) instead of silently starting late.
  • Fewer wasted retries and rate-limit errors. Rate limits are enforced before dispatch, and a task that hits a limit waits without occupying a worker.
  • Lower cost. Pools scale on queue pressure: arrival rate, backlog, wait-time objectives and upcoming reservations. Scale-down is damped by a stabilization window, and cost per pool is reported directly.
  • Operational clarity. Every pool reports utilization, wait percentiles, priority adherence, fixed-start hit rate over a sliding window, and cost accumulated since start, as both OpenTelemetry and Prometheus metrics.
  • Speed. The engine handles over a million scheduling decisions per second on one core, so it can sit in the hot path of microtask systems as well as hours-long batch jobs.
  • One engine, three surfaces. The same scheduling rules apply whether you embed the Rust library, use the Python package, or run the server for a polyglot fleet.

How it compares

cronoq is a scheduler: it orders and admits work and sizes capacity. It is not a workflow engine with durable step state, and not a general message broker. The table is a rough guide; each of these tools is excellent at what it was built for.

cronoqPlain queue (SQS, Redis lists)Celery / Sidekiq / RQKubernetes Jobs / CronJobsAirflow / DagsterTemporal
Priorities with aging✅❌Partial (separate queues)❌Partial (pools, weights)❌
Deadlines / EDF✅❌❌❌SLA alerts onlyTimeouts only
Fixed start with capacity reservation✅❌ETA only (no reservation)Start time onlySchedule onlyTimers
Shared rate limits across workers✅❌Per worker❌PoolsActivity task-queue rate limits
Queue-pressure autoscaling✅ built inExternalExternalHPA/KEDAExternalExternal
DAGs✅ simple edges❌Canvas primitives❌✅ rich✅ code-defined
Durable workflow state / replay❌❌❌❌Task-level✅
Embeddable as a library✅ Rust, Python❌❌❌❌❌
Scheduling throughput~1M decisions/s per coreBroker-boundBroker-boundSlow (pods)Slow (DB-polling)Moderate

When to use cronoq

  • Mixed workloads that share capacity and need ordering by importance or deadline.
  • Work against rate-limited or capacity-limited resources (LLM APIs, GPUs, licences).
  • Jobs that must start at specific times without being starved by everything else.
  • Systems that want autoscaling driven by the queue rather than by CPU.
  • An embedded scheduler inside a Rust or Python service, without running extra infrastructure.

When not to use cronoq

  • Long-running business workflows with durable state (sagas, human-in-the-loop steps, days of waiting). Use a workflow engine such as Temporal; cronoq can still schedule its activities.
  • Pure message passing / pub-sub. Use a broker such as Kafka or NATS.
  • Complex data-pipeline orchestration with backfills, lineage and a UI for analysts. Use Airflow or Dagster; cronoq's DAGs are deliberately simple.
  • Exactly-once side effects. cronoq executes tasks at least once. Make handlers idempotent, or use task keys to deduplicate submissions.

Benefits at a glance

This page is for engineers deciding whether cronoq fits their system, and where to start. It lists what changes when you adopt it, how to check that it worked, and what it asks of you in return.

What you gain

BenefitWhat changes for youHow to see itBuilt on
Important work is never stuck behind bulk workUser-facing tasks overtake batch jobs on the same workers; no separate "fast lane" fleet idling most of the day.wait_p99 per pool; priority_adherence close to 1.Priorities
Nothing starvesLow-priority work still progresses during busy periods instead of waiting indefinitely.Pool wait percentiles stay bounded while low tasks keep completing; pending_by_class for low does not grow without limit.Aging
Fewer missed SLAsWork with a deadline is ordered by how much slack it has left, so capacity that was free earlier is used for it.Fewer overdue tasks; deadline-driven tasks finish before their deadline.Deadlines
Scheduled jobs start on timeCapacity is booked at submission and a worker is held just before the start, so the 02:00 job starts at 02:00 even on a busy cluster. Over-subscribed schedules are rejected up front.fixed_start_hit_rate = 1.Fixed starts, cron
No more 429 stormsRate limits and concurrency caps are enforced before dispatch, across all workers; Retry-After hints delay retries. Waiting for a limit does not occupy a worker.Fewer rate-limit errors and retries (cronoq_retries_total).Resources
Right-sized fleetsPools grow on queue pressure and wait-time objectives, pre-warm for scheduled work, and shrink without flapping.desired vs capacity; utilization in the 0.5–0.85 band; cost per pool.Autoscaling
Pipelines without glue codeDependencies by key with success, failure and completion edges; atomic batches; failure handlers that run only when needed.Downstream steps never run on failed input; no hand-written state tables.Dependencies
Safe retries end to endRetries with backoff and jitter, timeouts, leases and heartbeats; idempotent submissions by key.Dead letters are rare and visible (cronoq_tasks_total{status="dead_lettered"}).Reliability
Graceful overloadBounded queues turn overload into backpressure (wait, reject or shed the least important work) instead of memory growth.shed and 429 counts instead of out-of-memory incidents.Backpressure
Survives restarts and failoverWith a SQLite or Postgres store, accepted work, cancellations and schedules are durable; a standby takes over in seconds.Kill the leader: work continues on the standby.Durability
Scheduling quality you can measureEvery pool reports utilization, wait percentiles, priority adherence, fixed-start hit rate and cost, in OpenTelemetry and Prometheus.Dashboards and alerts from day one.Metrics
Negligible overheadAbout a million scheduling decisions per second per core; choosing the next task looks only at queue heads, never the whole backlog.Scheduler CPU stays a rounding error even for millisecond tasks.Performance

Pick your starting point

Where does the work run?
│
├─ Inside one Rust service ───────────────▶ cronoq crate (Rust quickstart)
├─ Inside one Python asyncio service ─────▶ pip install cronoq (Python quickstart)
├─ Code written for ElasticPriorityScheduler ▶ cronoq.compat, then the native API (Migrating)
└─ In other processes, machines or languages ▶ cronoq-server + workers (Server quickstart)
                                                 │
     Must queued work survive restarts? ── yes ─▶ add a SQLite or Postgres store (and a standby for HA)
If you have...Start withThen add
An async Rust service calling rate-limited APIscronoq crate, one pool and a token bucketDeadlines on interactive calls, otel metrics
A Python service mixing user requests with background jobscronoq for Python, prioritiesStatus events for notifications, autoscaling objectives
GPU or model workers on several machinescronoq-server with a pool per worker typeKEDA on cronoq_pool_desired_workers, Postgres for HA
Cron jobs that start lateFixed starts and cronMiss policies and fixed_start_hit_rate alerts
A hand-rolled DAG runnerKeys and dependenciesAtomic batches and failure handlers
A queue that falls over during incidentsBounded pools with shed_lowestAlerts on shed and wait percentiles

Match your symptom

SymptomRead
"User requests wait behind batch jobs."Interactive and batch work on shared capacity
"We keep hitting the LLM provider's rate limit."LLM and agent workloads
"SLAs are missed although the cluster was idle an hour ago."Deadline-driven work
"The nightly job started 20 minutes late."Scheduled jobs with guaranteed capacity
"Step 3 ran although step 2 failed."Pipelines and DAGs
"Our 8 GPUs are oversubscribed."GPUs and other scarce resources
"The scheduler is slower than the tasks."High-throughput microtasks
"Workers are written in three languages."Polyglot worker fleets
"We pay for idle workers at night."Cost-aware autoscaling

What it asks of you

cronoq is deliberately small. Adopting it means accepting a few rules:

  • At-least-once execution. A task can run again if a worker or the leader fails mid-attempt. Make handlers idempotent, and give submissions a key so retries are deduplicated.
  • One active scheduler per shard. HA is active-passive per shard: one leader decides, standbys wait. By default all pools are in one shard; sharding splits pools across shards, each with its own leader.
  • In memory by default. Without a store, a restart loses queued work. Add a SQLite or Postgres store when that matters.
  • A scheduler, not a workflow engine. It orders, admits and sizes work. Long-running workflows with durable step state, human approvals or replays belong in a workflow engine; cronoq can schedule their activities.
  • Payloads are plain Rust values in the library, Python objects in the Python package, and opaque bytes on the server. Large blobs belong in object storage, with a reference in the payload (the server limits messages to 16 MiB by default).

See Why cronoq for a comparison with queues, task frameworks, Kubernetes Jobs and workflow engines.

A typical adoption path

  1. Embed the library (Rust or Python) in the service that already runs the work, with one pool per kind of worker and the defaults everywhere else.
  2. Classify work: priorities for user-facing versus background tasks, deadlines where an SLA exists, keys on everything that may be retried.
  3. Protect dependencies: a token bucket per external API, a semaphore per scarce resource.
  4. Observe: export metrics and alert on wait percentiles, dead letters and fixed-start hit rate.
  5. Scale out when workers outgrow one process: run cronoq-server, move executors into workers, add Postgres and a standby, and drive the worker fleet from cronoq_pool_desired_workers.

Rust quickstart

Install

[dependencies]
cronoq = "0.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

The otel feature (on by default) adds OpenTelemetry metrics and trace propagation. Disable it with default-features = false for a minimal dependency tree.

The minimum supported Rust version is 1.94.

A first scheduler

use std::{sync::Arc, time::Duration};
use cronoq::{Pool, Priority, Scheduler, Task, TaskError};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // A scheduler with one pool. Payloads are `String`s, outputs are `usize`s.
    let sched = Scheduler::<String, usize>::builder()
        .pool(
            Pool::new("default")
                .workers(1, 2, 8) // min, initial, max concurrent tasks
                .executor_fn(|ctx, text: Arc<String>| async move {
                    println!("task {} attempt {}", ctx.id, ctx.attempt);
                    Ok::<_, TaskError>(text.len())
                }),
        )
        .build()
        .await?;

    // Submit and await the output.
    let handle = sched
        .submit(Task::new("hello".to_string()).priority(Priority::High))
        .await?;
    let len = handle.await?; // Arc<usize>
    assert_eq!(*len, 5);

    sched.shutdown(Duration::from_secs(5)).await?;
    Ok(())
}

What happened:

  1. Scheduler::builder() creates a scheduler for payload type String and output type usize.
  2. Pool::new("default") is an autoscaled pool of worker slots. In-process, a slot is simply a concurrently running task, so workers(1, 2, 8) means "start with 2 concurrent tasks, scale between 1 and 8 based on queue pressure".
  3. executor_fn turns an async closure into an Executor. It receives a TaskContext (id, attempt, cancellation, heartbeat) and the payload.
  4. submit returns a TaskHandle that resolves with Result<Arc<O>, TaskFailure> once the task reaches a terminal status.

Adding scheduling rules

Everything else is set on the task or the pool:

use std::time::Duration;
use cronoq::{Dep, Priority, RetryPolicy, Task};

let task = Task::new(payload)
    .pool("default")
    .kind("summarize")                         // used for metrics and duration learning
    .priority(Priority::High)
    .key("summary:doc-42")                     // idempotent submit + dependency handle
    .deadline_in(Duration::from_secs(30))      // urgency rises as the deadline approaches
    .timeout(Duration::from_secs(60))          // per attempt
    .retry(RetryPolicy { max_attempts: 5, ..Default::default() })
    .resource("llm-api", 1)                    // shared rate limit / semaphore
    .after("fetch:doc-42")                     // run after this key succeeds
    .dep(Dep::on_failure("fetch:doc-42"));     // ...or a failure handler edge

Tasks that must start at a given time use start_in / start_at; recurring work uses Scheduler::add_recurring with a cron expression. See Fixed starts and Recurring schedules.

Signalling failures

Return a TaskError from the executor:

ReturnEffect
Ok(output)Task succeeds; handles resolve with the output.
Err(TaskError::retryable(msg))Retried with exponential backoff until the retry policy is exhausted, then DeadLettered.
Err(TaskError::retry_after(msg, d))Retryable, but not before d (e.g. HTTP 429 Retry-After).
Err(TaskError::permanent(msg))Fails immediately with status Failed.
panicTreated as a permanent failure with the panic message.

Blocking (non-async) code

If your application does not use tokio, build a BlockingScheduler. It owns a private runtime and exposes the same operations as blocking calls:

let sched = cronoq::Scheduler::<u64, u64>::builder()
    .pool(cronoq::Pool::fixed("default", 4).executor_fn(|_ctx, x: std::sync::Arc<u64>| async move {
        Ok::<_, cronoq::TaskError>(*x * 2)
    }))
    .build_blocking()?;
let out = sched.submit(cronoq::Task::new(21))?.wait()?;
assert_eq!(*out, 42);

Do not call build_blocking from inside a tokio runtime; use the async API there.

Examples in the repository

ExampleShows
cargo run -p cronoq --example llm_agentsRate limits, priorities, deadlines, 429 retries
cargo run -p cronoq --example dag_pipelineFan-out/fan-in with failure and cleanup handlers
cargo run --release -p cronoq --example firehose -- 1000000Microtask throughput
cargo run -p cronoq --example prototype_demoMixed workload with a fixed-start task and quality metrics

Next: use cases for complete setups, or concepts for the precise rules.

Python quickstart

The cronoq package embeds the same Rust engine in Python, with an asyncio-native API. Payloads and results are ordinary Python objects; nothing is serialized unless you keep state on disk (see Durable state).

Install

pip install cronoq             # embedded scheduler
pip install "cronoq[client]"   # plus the gRPC client and remote worker for cronoq-server

Wheels are published for CPython 3.14+ (abi3) on Linux, macOS and Windows.

A first scheduler

import asyncio
import cronoq

async def summarize(ctx: cronoq.TaskContext, text: str) -> str:
    # ctx.id, ctx.attempt, ctx.kind, ctx.key, ctx.pool, ctx.tags, ctx.timeout, ctx.deadline
    await asyncio.sleep(0.1)          # call a model, an API, a database...
    return text[:20]

async def main() -> None:
    pools = [cronoq.Pool("default", summarize, min=1, max=8)]
    async with cronoq.Scheduler(pools) as sched:
        handle = await sched.submit("a long document ...", priority="high", deadline_in=5.0)
        print(await handle)           # the executor's return value

asyncio.run(main())
  • Pool(name, executor, min=, max=) defines a pool of concurrency slots that autoscales between min and max. Pass autoscale=False for a fixed size.
  • Executors are async def functions run on the event loop that started the scheduler. Plain functions are accepted too; they run in the loop's default thread pool, so blocking calls do not stall the loop.
  • async with Scheduler(...) starts the scheduler and shuts it down on exit (see Lifecycle).
  • Tasks without pool= go to the default pool: the one named "default", or the first.

Scheduling options

Every option of the engine is a keyword argument on submit (and on cronoq.Task for batches):

handle = await sched.submit(
    {"doc": 42},
    pool="default",
    kind="summarize",                 # metrics and duration learning
    priority=cronoq.Priority.HIGH,    # or "critical" / "high" / "normal" / "low"
    key="summary:42",                 # idempotent submit; dependency handle
    deadline_in=30.0,                 # seconds
    timeout=60.0,                     # per attempt
    retry=cronoq.RetryPolicy(max_attempts=5, initial_backoff=0.5),
    resources={"llm-api": 1},         # shared rate limit or semaphore
    after=["fetch:42"],               # run after this key succeeds
)

Retries and failures

async def call_api(ctx, payload):
    resp = await http.post(...)
    if resp.status == 429:
        raise cronoq.Retryable("rate limited", retry_after=float(resp.headers["Retry-After"]))
    if resp.status == 400:
        raise cronoq.Permanent("bad request")   # no retry
    return resp.json()

Any other exception is treated as retryable. Awaiting a task that did not succeed raises cronoq.TaskFailed with .status (failed, dead_lettered, cancelled, skipped, missed, shed, or shutdown), .message and .task_id. All cronoq exceptions can be pickled, so they cross multiprocessing boundaries.

Batches and dependencies

handles = await sched.submit_batch([
    cronoq.Task("extract", key="extract"),
    cronoq.Task("transform", key="transform", after="extract"),
    cronoq.Task("load", key="load", after="transform"),
    cronoq.Task("alert", deps=[cronoq.Dep("load", on="failure")]),
])

A batch is admitted atomically: if any task is rejected (for example because of a cycle), none are.

Status events

sched.events() streams status changes, for logs, metrics or notifying users:

async with sched.events() as events:
    async for e in events:            # cronoq.StatusEvent
        print(e.id, e.key, e.status, e.attempt, e.message)

By default only terminal statuses (succeeded, failed, dead_lettered, ...) are delivered; create the scheduler with emit_transitions=True to also see ready, running, retrying and the rest. Each stream buffers event_capacity events (default 1024); a consumer that falls behind loses the oldest ones (counted in events.lagged), or gets cronoq.EventsLagged with sched.events(on_lag="raise").

Lifecycle

  • A scheduler belongs to the event loop that starts it. Use async with cronoq.Scheduler(...), or await sched.start() and await sched.shutdown(), inside that loop.
  • Leaving async with waits up to shutdown_grace seconds (default 5) for running attempts, then cancels them. Queued tasks are not started.
  • If the loop closes without a shutdown, the scheduler stops and later calls raise cronoq.SchedulerError.
  • Cancelling a task, a timeout or a lost lease cancels the executor's asyncio task. An executor that swallows CancelledError keeps running and may overlap with its retry.

Durable state

By default the scheduler keeps its state in memory. Give it a store to keep queued work, schedules and results across restarts:

store = cronoq.SqliteStore("/var/lib/myapp/cronoq.db")
async with cronoq.Scheduler(pools, store=store) as sched:
    await sched.enqueue({"doc": 42}, key="summary:42")
  • Payloads and results are stored as JSON by default; submitting something JSON cannot hold raises TypeError. Pass codec= (an object with encode and decode) for other types.
  • After a restart, queued and interrupted tasks run again on the new scheduler's pools, and await sched.handle("summary:42") returns the stored result of a task that already finished.
  • Several processes may open the same file. One leads; the others wait in start() and take over when it stops.

See SqliteStore for the options.

Coming from ElasticPriorityScheduler

cronoq.compat.ElasticPriorityScheduler is a synchronous, thread-safe drop-in with the original API (submit, start, stop, get_metrics, get_cost, completed_count). See Migrating from ElasticPriorityScheduler.

Talking to a cronoq server

With the client extra, cronoq.client.Client submits work to a cronoq-server and cronoq.client.Worker serves a pool:

# worker.py: serves the pool until stopped
import asyncio, os
from cronoq.client import Worker

async def handle(lease):
    return {"answer": lease.json()}

asyncio.run(Worker("localhost:7070", "llm", handle, slots=8,
                   token=os.environ["CRONOQ_WORKER_TOKEN"]).run())
# client.py: submits a task and waits for its result
import asyncio, os
from cronoq.client import Client

async def main():
    async with Client("localhost:7070", token=os.environ["CRONOQ_CLIENT_TOKEN"]) as c:
        sub = await c.submit({"prompt": "hi"}, pool="llm", priority="high", wait=True)
        print(sub.result.json())

asyncio.run(main())

See Writing executors and workers, the server quickstart and the Python API reference.

Server quickstart

cronoq-server runs the scheduler as a service. Clients submit tasks over gRPC or HTTP/JSON; remote workers in any language connect over a gRPC stream, register for a pool with a number of slots, and receive leases. In about ten minutes you will run a server, connect a worker and push work through it.

 clients (any language)         cronoq-server                  workers (any language)
 ──────────────────────▶  ┌───────────────────────┐  ◀── Workers.Work stream ───
  gRPC :7070 / HTTP :7071 │ engine + remote pools  │  ─── Lease / Abort ────────▶
                          └───────────┬───────────┘  ◀── Heartbeat / Complete ──
                                      │ optional
                                  PostgreSQL

What you get compared with a plain queue: priorities with aging, deadlines, fixed start times with reserved capacity, dependencies, retries with backoff, shared rate limits, idempotent keys and an autoscaling signal, behind one small binary.

1. Create tokens

The server authenticates every call with a bearer token per role. Tokens must be at least 16 characters; use random 32-character values:

for role in CLIENT WORKER METRICS ADMIN; do
  export CRONOQ_${role}_TOKEN=$(openssl rand -hex 16)
done

2. Start the server

From source:

cargo run --release -p cronoq-server -- --config deploy/cronoq.example.toml

Or with the released container image, which contains the same example configuration:

docker run --rm -p 7070:7070 -p 7071:7071 \
  -e CRONOQ_CLIENT_TOKEN -e CRONOQ_WORKER_TOKEN -e CRONOQ_METRICS_TOKEN -e CRONOQ_ADMIN_TOKEN \
  ghcr.io/synaptiai/cronoq:0.2.1

The example configuration defines two pools (default and llm), two shared resources (openai, a token bucket, and gpu, a semaphore) and the four tokens above. It listens on all interfaces, which the server only allows with authentication configured.

Without --config the server starts with a single default pool, listens on 127.0.0.1 only and has authentication disabled: fine for a laptop, refused on any other address unless you pass --insecure-no-auth.

Validate a configuration without starting anything:

cronoq-server --config cronoq.toml --check     # "configuration is valid"

Check the server is up:

curl -s localhost:7071/readyz        # "ready"

3. Connect a worker

A pool's capacity is the sum of the slots of its connected workers, so nothing runs until a worker connects. In Python (pip install "cronoq[client]"):

import asyncio, os
from cronoq.client import Worker

async def handle(lease):
    data = lease.json()
    return {"echo": data, "attempt": lease.attempt}

asyncio.run(Worker("localhost:7070", "default", handle, slots=4,
                   token=os.environ["CRONOQ_WORKER_TOKEN"]).run())

Or in Rust, with the worker client in the cronoq-server crate:

use bytes::Bytes;
use cronoq_server::worker::{run_worker, WorkerOptions};

let opts = WorkerOptions::new("http://localhost:7070", "default")
    .slots(4)
    .token(std::env::var("CRONOQ_WORKER_TOKEN")?);
run_worker(opts, |lease| async move { Ok(Bytes::from(lease.payload.to_ascii_uppercase())) },
           std::future::pending()).await?;

Both clients heartbeat running leases, reconnect with backoff, and drain on shutdown. Workers in other languages implement the cronoq.v1.Workers/Work stream; see the protocol rules.

4. Submit work

With curl (HTTP/JSON):

curl -s localhost:7071/v1/tasks \
  -H "authorization: Bearer $CRONOQ_CLIENT_TOKEN" -H 'content-type: application/json' \
  -d '{"payload": {"hello": "world"}, "priority": "high", "wait": true}'
{"duplicate":false,"id":"1","result":{"id":"1","output":{"attempt":1,"echo":{"hello":"world"}},"output_available":true,"output_b64":"...","status":"succeeded"}}

Task ids are strings in JSON, because they are 64-bit.

With the Python client:

import asyncio, os
from cronoq.client import Client

async def main():
    async with Client("localhost:7070", token=os.environ["CRONOQ_CLIENT_TOKEN"]) as c:
        sub = await c.submit({"hello": "world"}, priority="high", key="hello-1")
        result = await c.wait(sub.id, timeout=30)
        print(result.status, result.json())

asyncio.run(main())

Submitting again with the same key returns the same task (duplicate = true), so retries after a network error are safe.

5. Look inside

curl -s localhost:7071/v1/snapshot -H "authorization: Bearer $CRONOQ_METRICS_TOKEN" | jq
curl -s localhost:7071/metrics -H "authorization: Bearer $CRONOQ_METRICS_TOKEN" | grep cronoq_pool

cronoq_pool_desired_workers is the number of slots the autoscaler wants for each pool; feed it to KEDA or an HPA to scale your worker deployment.

Next steps

Use cases

Each page in this section starts from a concrete problem, shows a complete cronoq setup, and lists the specific benefits you get compared with a plain queue. Not sure which one applies? Start with Benefits at a glance, which maps symptoms to pages.

Use caseTypical problemKey cronoq features
LLM and agent workloadsRate limits, 429 storms, unpredictable latency, expensive workersToken buckets, retry_after, priorities, deadlines, autoscaling
Interactive and batch work on shared capacityUser requests stuck behind batch jobs; batch jobs starved at peakPriority classes, aging, per-class wait SLOs
Deadline-driven workSLAs missed although capacity existed earlierDeadline urgency, EDF ordering, overdue escalation
Scheduled jobs with guaranteed capacityCron jobs start late because workers are busyFixed starts, reservations, worker holds, miss policies, cron
Pipelines and DAGsHand-rolled dependency tracking; downstream steps run on bad inputKeys, edge conditions, atomic batches, failure handlers
GPUs and other scarce resourcesOversubscribed GPUs, connection pools or licencesSemaphores shared across pools; no head-of-line blocking
High-throughput microtasksScheduler overhead dominates millisecond tasksO(1) dispatch, bulk enqueue, ~1M decisions/s per core
Polyglot worker fleetsWorkers in several languages and machinesgRPC worker protocol, leases, heartbeats, HTTP API
Cost-aware autoscalingOver-provisioned at night, saturated at noonQueue-pressure autoscaling, stabilization, cost metrics, KEDA
Migrating from ElasticPrioritySchedulerExisting code on the original Python schedulercronoq.compat drop-in

Use cases combine freely: an LLM pipeline can use DAGs, rate limits, deadlines and autoscaling at the same time, because all of them are properties of tasks and pools in a single engine.

LLM and agent workloads

The problem

Applications built on large language models send many requests to model APIs: chat turns, tool calls, embeddings, batch summarization, evaluation runs. These requests:

  • share provider rate limits (requests or tokens per minute) across every worker;
  • fail with HTTP 429 when the limit is exceeded, often with a Retry-After header;
  • have mixed urgency: a user is waiting for a chat reply, nobody is waiting for tonight's evaluation run;
  • take seconds to minutes, with a long tail;
  • run on expensive workers (or are billed per concurrent request).

A plain queue sends requests as fast as workers can pop them. During a burst every worker hits the rate limit, retries immediately, and makes it worse. Meanwhile user-facing requests wait behind batch work.

The setup

use std::{sync::Arc, time::Duration};
use cronoq::*;

let sched = Scheduler::<Prompt, Answer>::builder()
    // The provider allows 50 requests/s with bursts of 100; every pool shares it.
    .resource(ResourceConfig::token_bucket("openai", 50.0, 100))
    // At most 20 requests in flight at once (connection or concurrency limit).
    .resource(ResourceConfig::semaphore("openai-concurrency", 20))
    .pool(
        Pool::new("llm")
            .workers(2, 4, 64)
            .autoscale(AutoscaleConfig {
                // Interactive requests should not wait more than 2 s in the queue.
                slo: [None, Some(Duration::from_secs(2)), None, None],
                cost_per_worker_second: 0.0004,
                ..Default::default()
            })
            .executor_fn(|ctx, p: Arc<Prompt>| async move {
                match call_model(&p).await {
                    Ok(a) => Ok(a),
                    Err(ApiError::RateLimited { retry_after }) => {
                        Err(TaskError::retry_after("429", retry_after))
                    }
                    Err(ApiError::BadRequest(m)) => Err(TaskError::permanent(m)),
                    Err(e) => Err(TaskError::from_err(e)),
                }
            }),
    )
    .build()
    .await?;

// A chat turn: high priority, should finish within 2 seconds.
sched.submit(Task::new(chat_prompt)
    .pool("llm").kind("chat")
    .priority(Priority::High)
    .deadline_in(Duration::from_secs(2))
    .timeout(Duration::from_secs(60))
    .resource("openai", 1).resource("openai-concurrency", 1)).await?;

// An evaluation item: low priority, retried generously.
sched.enqueue(Task::new(eval_prompt)
    .pool("llm").kind("eval")
    .priority(Priority::Low)
    .retry(RetryPolicy { max_attempts: 8, ..Default::default() })
    .resource("openai", 1).resource("openai-concurrency", 1)).await?;

The same setup in Python:

limits = [cronoq.Resource.token_bucket("openai", rate=50.0, burst=100),
          cronoq.Resource.semaphore("openai-concurrency", 20)]
pool = cronoq.Pool("llm", call_model, min=2, max=64,
                   autoscale=cronoq.AutoscaleConfig(slo={"high": 2.0}))
async with cronoq.Scheduler([pool], resources=limits) as sched:
    await sched.submit(chat, pool="llm", priority="high", deadline_in=2.0,
                       resources={"openai": 1, "openai-concurrency": 1})

The repository has a runnable version: cargo run -p cronoq --example llm_agents.

What you get

  • No 429 storms. The token bucket is checked before dispatch. A task that cannot get a token is parked on the resource without occupying a worker, and is released in FIFO order as tokens refill. Requests are paced at the provider's rate instead of bursting into it.
  • Correct backoff. TaskError::retry_after (Python: Retryable(retry_after=...)) delays the next attempt by at least the server's Retry-After, combined with exponential backoff and jitter from the retry policy.
  • Users first. Chat turns are High and carry a deadline, so they overtake queued evaluation work. Low-priority work still progresses: aging lets it overtake a continuous stream of Normal work.
  • Capacity that follows demand. The pool scales on arrival rate and backlog, and the per-class SLO adds capacity when predicted queue wait for High work exceeds 2 seconds.
  • Bounded attempts. Per-attempt timeouts abort hung calls; tasks that keep failing end as DeadLettered instead of retrying forever.
  • Cost visibility. cost_per_worker_second makes each pool report its accumulated cost.

Variations

  • Several providers or models: one token bucket per provider; a task can require several resources at once (all are acquired atomically).
  • Token-based limits: request resource("openai-tpm", estimated_tokens / 1000) from a bucket sized in thousands of tokens.
  • Agent tool calls that depend on each other: submit them as a batch with keys and after edges (Pipelines and DAGs).
  • Workers on GPU machines running local models: use the server and remote workers (Polyglot worker fleets).

Interactive and batch work on shared capacity

The problem

A service runs two kinds of work on the same workers: requests a user is waiting for, and background jobs (exports, re-indexing, reports). With one FIFO queue, a large export blocks every user request behind it. The usual fix is separate queues and separate worker fleets, which means paying for interactive capacity that is idle most of the day, while the batch fleet cannot borrow it during quiet hours.

The setup

One pool, four priority classes:

async with cronoq.Scheduler([cronoq.Pool("app", run, min=2, max=32)]) as sched:
    # A user clicked "export": they are waiting.
    await sched.submit(job, priority="high", kind="export-interactive")

    # Nightly re-index of 50,000 documents.
    for doc in docs:
        await sched.enqueue(doc, priority="low", kind="reindex")

    # A payment webhook that must never wait.
    await sched.submit(event, priority="critical", kind="webhook")

Optionally give each class a wait-time objective, so the autoscaler adds capacity when it would be violated:

autoscale = cronoq.AutoscaleConfig(slo={"critical": 0.5, "high": 2.0})
pool = cronoq.Pool("app", run, min=2, max=32, autoscale=autoscale)

How the order is decided

Every waiting task has a score (see Priorities and aging):

score = base[class] + min(2 × seconds_waiting, 800)
base  = critical 10000 · high 1000 · normal 100 · low 10

Dispatch always picks the highest score. In practice:

  • A new critical task goes first.
  • high always beats normal and low, however long they have waited.
  • A low task that has waited about 45 seconds overtakes freshly submitted normal work, so normal traffic cannot starve the batch queue indefinitely.
  • Within a class, order is strictly FIFO.

What you get

  • One fleet instead of two. Interactive work borrows the whole pool when it needs it; batch work uses whatever is left. No idle "fast lane" capacity.
  • Bounded latency for users regardless of how much batch work is queued, because dispatch cost grows only with the logarithm of the queue length and higher classes are always considered first.
  • Batch jobs still finish. Aging prevents starvation by normal traffic, and you can tune the aging rate and caps per class.
  • Measurable fairness. priority_adherence in each pool snapshot is the fraction of dispatches made while no task of a strictly higher class was waiting; higher is better (with aging, a value slightly below 1.0 is expected and healthy).

Variations

  • Protect batch throughput at peak: put batch work in its own pool with min workers and interactive work in another; resources can still be shared between pools.
  • Different aging policy: configure PriorityConfig (Rust) per class, for example a higher age_cap for low to let it overtake high after a long wait.

Deadline-driven work

The problem

Some work has a time by which it must be done: a report promised by 09:00, a fraud check that must finish before a payment settles, a transcoding job due before a broadcast. A priority alone does not capture this: a "normal" job due in five minutes is more urgent than a "high" job due tomorrow, but only once its deadline gets close.

The setup

Give tasks a deadline and, when known, a duration hint:

use std::time::{Duration, SystemTime};
use cronoq::{Priority, Task};

// Must be done in 10 minutes; usually takes ~2 minutes.
sched.submit(Task::new(report)
    .priority(Priority::Normal)
    .deadline_in(Duration::from_secs(600))
    .duration_hint(Duration::from_secs(120))).await?;

// Absolute deadline.
sched.submit(Task::new(batch)
    .deadline_at(SystemTime::now() + Duration::from_secs(3600))).await?;
await sched.submit(report, deadline_in=600, duration_hint=120)

Without a duration hint, cronoq uses the median runtime it has observed for the task's kind in that pool, then the pool's statistics, then the pool's default_duration (1 s).

How urgency works

From the deadline D and the estimated duration d, cronoq computes the latest start time LST = D − d. As the remaining slack LST − now shrinks below a horizon H = max(d, 5 s), the task's score gains urgency, rising linearly to 5000 at the latest start time. After that it keeps rising (100 points per second, up to +3000) so that an overdue task always ranks above one that is merely close. Within a priority class, tasks with deadlines are ordered by latest start time (earliest-deadline-first), which is optimal for meeting deadlines on a single resource.

Because 5000 exceeds the gap between high (1000) and low (10), a low task near its deadline overtakes fresh high work, but not critical work (10000).

What you get

  • Fewer missed deadlines with the same capacity. Work is reordered as deadlines approach, instead of strictly by priority or arrival.
  • Correct ordering among deadline tasks: earliest latest-start-time first.
  • Escalation for overdue work that is monotonic, so overdue tasks never fall behind ones that are merely close to their deadline.
  • Learned estimates. Duration estimates improve as tasks of the same kind complete.
  • Visibility. TaskContext::deadline (Rust) gives executors the deadline so they can choose a cheaper code path when time is short.
  • If the work must start at a specific time rather than finish by one, use a fixed start instead.
  • To add capacity when deadlines are at risk, combine deadlines with per-class wait SLOs on the pool's autoscaling configuration.

Scheduled jobs with guaranteed capacity

The problem

Cron jobs and "run at 14:00" tasks usually share workers with everything else. When the time comes, every worker may be busy with long jobs, so the scheduled job starts late, sometimes much later. Teams compensate with dedicated cron machines that sit idle 99% of the time.

The setup

Submit work with a fixed start time:

use std::time::Duration;
use cronoq::{MissPolicy, Task};

sched.submit(Task::new(close_books)
    .start_at(at_14_00)                        // SystemTime
    .start_tolerance(Duration::from_secs(5))   // "on time" = within ±5 s
    .on_miss(MissPolicy::Skip)                 // if it still can't start, drop it
    .duration_hint(Duration::from_secs(300))).await?;

Or a recurring schedule (cron with optional seconds, IANA time zones):

use std::time::Duration;
use cronoq::{OverlapPolicy, Recurring, Task};

sched.add_recurring(
    Recurring::new("hourly-rollup", "0 0 * * * *", Task::new(rollup).duration_hint(Duration::from_secs(600)))
        .tz("Europe/Lisbon")
        .overlap(OverlapPolicy::Skip),          // skip an occurrence while the previous one runs
).await?;
await sched.add_recurring("hourly-rollup", "0 0 * * * *", rollup_payload,
                          tz="Europe/Lisbon", overlap="skip", duration_hint=600)

What happens

  1. Admission. The task reserves an interval [start − tolerance, start + duration + tolerance] in its pool. Up to half of the pool's max_workers (at least one) may be reserved at any moment; if the new reservation would exceed that, the submission fails immediately with ReservationConflict. You learn about an impossible schedule when you create it, not when it runs late.
  2. Pre-warming. Reservations within the autoscaler's horizon (60 s by default) count towards the pool's desired capacity, so workers are added before they are needed.
  3. Holding. Shortly before the start (by the pool's p90 task duration, at least the tolerance, at most 5 minutes), the next free worker is held for the task: regular work cannot take it.
  4. Start. At the start time the task runs on its held worker. The recorded start time is the actual dispatch time, and the pool's fixed_start_hit_rate metric counts it as on time if it started within the tolerance.
  5. Miss. If it still could not start within the tolerance, the miss policy applies: RunLate (default: run as soon as possible, ahead of everything else), Skip (status Missed), or Fail.

What you get

  • On-time starts without a dedicated fleet. Capacity is reserved and a worker is held only around the start time; the rest of the time it serves regular work.
  • Early feedback. Over-subscribed schedules are rejected at submission time.
  • Explicit miss semantics instead of silently late jobs.
  • Measurable punctuality via fixed_start_hit_rate.
  • Durable schedules. With a SQLite or Postgres store, recurring definitions and their next occurrence survive restarts and failovers. After downtime, at most one missed occurrence runs late (with RunLate); the schedule then resumes from the present instead of replaying every missed occurrence.

Pipelines and DAGs

The problem

Multi-step work (extract → transform → load, or crawl → embed → index) is often chained by hand: each step enqueues the next one when it finishes. Failures leave half-finished pipelines, downstream steps run on missing inputs, and there is nowhere to hang a "notify on failure" step.

The setup

Give tasks keys and declare edges. A batch is admitted atomically and may reference keys in any order:

use cronoq::{Dep, Task};

let mut tasks = vec![Task::new(Step::Extract).key("extract")];
for shard in 0..4 {
    tasks.push(Task::new(Step::Transform(shard)).key(format!("t{shard}")).after("extract"));
}
let mut load = Task::new(Step::Load).key("load");
for shard in 0..4 {
    load = load.after(format!("t{shard}"));            // fan-in
}
tasks.push(load);
tasks.push(Task::new(Step::Alert).dep(Dep::on_failure("load")));   // runs only if load fails
tasks.push(Task::new(Step::Cleanup).dep(Dep::on_complete("load"))); // runs either way

let handles = sched.submit_batch(tasks).await?;
await sched.submit_batch([
    cronoq.Task("extract", key="extract"),
    *[cronoq.Task(("transform", i), key=f"t{i}", after="extract") for i in range(4)],
    cronoq.Task("load", key="load", after=[f"t{i}" for i in range(4)]),
    cronoq.Task("alert", deps=[cronoq.Dep("load", on="failure")]),
    cronoq.Task("cleanup", deps=[cronoq.Dep("load", on="complete")]),
])

Runnable: cargo run -p cronoq --example dag_pipeline.

Edge semantics

EdgeThe child runs when the parent...Otherwise the child becomes
after / Dep::on_successsucceededSkipped
Dep::on_failurefailed or was dead-letteredSkipped
Dep::on_completereached any terminal status(always runs)

Skipped propagates: everything downstream of a skipped task is skipped too, except children attached with on_complete. A task that waits on dependencies has status Blocked; it enters the queue only when every edge is satisfied, so blocked tasks never occupy workers.

Dependencies can reference task ids or keys. A key that has never been seen is rejected by default; configure UnknownDependency::Await { timeout } on the pool to let a child wait for a task that will be submitted later (useful when producers are independent).

What you get

  • Correctness by construction. Children never start before their parents finish. This is checked continuously by cronoq's randomized simulation tests.
  • Failure handling as a first-class step with on_failure and on_complete edges.
  • Atomic submission. A batch with a cycle, an unknown dependency, a reservation conflict or a full queue is rejected as a whole, never half-admitted.
  • Idempotent pipelines. Re-submitting a batch whose keys already exist returns the existing tasks (duplicate = true) instead of running them twice. Keys stay reserved while the task is live and for the retention window after it finishes (1 hour by default).
  • Late dependents still work. A child submitted after its parent finished is resolved from the retained terminal status.

Limits

cronoq's DAGs are a scheduling primitive, not a workflow engine: there are no data passing between steps (pass references in payloads), no conditional branching beyond edge conditions, and no long-term workflow history. For those, pair cronoq with a workflow engine.

GPUs and other scarce resources

The problem

Some work needs something there is little of: GPU slots, database connections, a licensed tool, a partner API with a concurrency cap. Workers usually enforce such limits locally (a semaphore per process), which over-subscribes the resource as soon as you run more than one worker, or under-uses it when the limit is split statically.

Worse, a FIFO queue blocks: if the task at the head needs the GPU and the GPU is busy, the CPU tasks behind it wait too.

The setup

Declare the resource once on the scheduler; tasks request units of it:

use cronoq::{Pool, ResourceConfig, Scheduler, Task};

let sched = Scheduler::<Job, Out>::builder()
    .resource(ResourceConfig::semaphore("gpu", 4))          // 4 GPUs in total
    .resource(ResourceConfig::semaphore("warehouse-db", 10)) // 10 connections
    .pool(Pool::new("ml").workers(1, 4, 16).executor_fn(run_ml))
    .pool(Pool::new("etl").workers(1, 4, 32).executor_fn(run_etl))
    .build().await?;

sched.submit(Task::new(train).pool("ml").resource("gpu", 2)).await?;        // needs 2 GPUs
sched.submit(Task::new(export).pool("etl").resource("warehouse-db", 1)).await?;
sched.submit(Task::new(features).pool("ml").resource("gpu", 1).resource("warehouse-db", 1)).await?;

In the server configuration:

[[resources]]
kind = "semaphore"
name = "gpu"
permits = 4

How it works

  • At dispatch, every resource a task requests is acquired atomically: all units or none.
  • If they are not available, the task is parked on that resource and removed from the dispatch queue, so tasks behind it (needing other resources or none) keep flowing. There is no head-of-line blocking.
  • Semaphore units are released at the end of each attempt (success, failure, timeout, cancel). Parked tasks are re-offered in FIFO order as units become free, and only when their full request fits.
  • A request larger than the resource's capacity is rejected at submission (ResourceExceedsCapacity), instead of waiting forever.

What you get

  • Global limits across all pools, workers and machines (with the server), enforced in one place.
  • Full utilization of the scarce resource with no static splitting between workers.
  • No head-of-line blocking for work that does not need the resource.
  • Fair access in FIFO order among tasks waiting for the same resource.
  • Multi-resource requests (for example GPU + database connection) without deadlock, because acquisition is all-or-nothing.

High-throughput microtasks

The problem

When tasks take milliseconds (cache refreshes, notifications, small transforms, fan-out of events), the scheduler's own overhead can dominate. Broker-based queues spend more time on network round trips than on the work, and many schedulers rescan or re-sort their queues periodically, so their cost grows with the backlog.

The setup

Embed the scheduler and submit in bulk:

use std::sync::Arc;
use cronoq::{Pool, Priority, Scheduler, Task, TaskError};

let sched = Scheduler::<Event, ()>::builder()
    .pool(Pool::fixed("default", 256).executor_fn(|_ctx, e: Arc<Event>| async move {
        handle(&e).await;
        Ok::<_, TaskError>(())
    }))
    .queue_capacity(65_536)
    .build()
    .await?;

// One round trip for up to thousands of tasks.
let batch: Vec<_> = events.into_iter().map(|e| Task::new(e).priority(Priority::Normal)).collect();
sched.enqueue_many(batch).await?;

Runnable: cargo run --release -p cronoq --example firehose -- 1000000.

Why it is fast

  • Constant-time dispatch. Each priority class keeps a FIFO lane. Because every task in a class ages at the same rate, the oldest task is always the best one, so dispatch only compares the heads of at most eight queues: no periodic rescoring, no full-queue scans.
  • Single-threaded engine, no locks. The engine runs on one task and receives commands in batches over channels.
  • No serialization in-process. Payloads are shared via Arc.
  • enqueue and enqueue_many skip result handles when you do not need outputs.

Measured on a shared 4-vCPU VM:

Throughput
Engine only (submit → dispatch → complete)~1.0–1.2 million cycles/s per core
End to end through tokio with no-op tasks~250,000 tasks/s

What you get

  • Scheduling overhead in the microseconds: choosing the next task looks only at queue heads, so a deep backlog barely slows it down.
  • All the scheduling rules still apply (priorities, deadlines, resources, retries) at this rate.
  • Backpressure via bounded queues instead of unbounded memory growth during spikes.

Tips

  • Prefer enqueue_many over many single enqueue calls; each call is a channel round trip.
  • Size queue_capacity to your burst size.
  • Disable the otel feature if you do not export metrics.
  • See Performance tuning.

Polyglot worker fleets

The problem

The work runs somewhere else: Python model servers on GPU machines, a Go service with access to a private network, a Node.js renderer. Each team reinvents leasing, heartbeats, retries and autoscaling on top of a broker.

The setup

Run cronoq-server and connect workers to it. Producers submit over gRPC or HTTP; workers open one bidirectional stream per process:

producer ──HTTP/gRPC──▶ cronoq-server ◀──Workers.Work stream── worker (Python, GPU box)
                              ▲      ◀──Workers.Work stream── worker (Rust)
                              └──────◀──Workers.Work stream── worker (any gRPC language)

A Python worker serving the gpu pool with two slots:

from cronoq.client import Worker
import cronoq

async def infer(lease):
    if lease.cancelled.is_set():
        return None
    try:
        return run_model(lease.json())
    except OutOfMemory:
        raise cronoq.Retryable("OOM", retry_after=10)

await Worker("cronoq:7070", "gpu", infer, slots=2, token=WORKER_TOKEN).run()

A producer in any language, over HTTP:

curl -X POST https://cronoq.internal/v1/tasks -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"pool":"gpu","payload":{"image":"s3://..."},"priority":"high","timeout_s":120}'

The worker protocol

  1. The worker opens Workers/Work and sends Register{pool, slots, protocol_version}.
  2. The server answers Registered{worker_id, lease_ttl_s, slots}. The pool's capacity grows by the granted slots.
  3. The server sends Lease messages, at most slots at a time.
  4. For each lease the worker sends Heartbeat well within lease_ttl_s (the bundled clients send one every third of the TTL), then Complete{output}, Fail{message, permanent, retry_after_s}, or Nack to hand the lease to another worker.
  5. The server may send Abort{lease} (timeout, cancellation, lease expiry). The worker stops that attempt.
  6. When the stream closes, its in-flight leases are retried elsewhere and capacity shrinks. When the server restarts or leadership moves, it sends Shutdown and workers reconnect.

See Writing executors and workers for a full description and the gRPC reference.

What you get

  • Language freedom. Any gRPC-capable language can be a worker; producers can use plain HTTP.
  • At-least-once execution across machines. Crashed or partitioned workers lose their lease (missed heartbeats) and the attempt is retried on another worker.
  • Central policies. Priorities, deadlines, rate limits and retries are enforced by the server, not re-implemented in each worker.
  • Elastic fleets. The server computes the desired number of workers per pool and exports it (cronoq_pool_desired_workers) for KEDA or HPA, or calls your webhook. See Cost-aware autoscaling.
  • High availability. With a durable store (Postgres, or SQLite on one host), standby servers take over; workers reconnect and accepted work resumes. See Durability and HA.

Cost-aware autoscaling

The problem

Fixed fleets are over-provisioned most of the day and saturated at peak. CPU-based autoscaling reacts to the wrong signal for I/O-bound work: a worker waiting on a model API uses no CPU while hundreds of tasks queue up behind it. And nothing scales ahead of a known event such as a scheduled job.

The setup

Each pool computes a desired capacity from queue pressure every second:

use std::time::Duration;
use cronoq::{AutoscaleConfig, Pool};

let pool = Pool::new("workers")
    .workers(1, 2, 50)                                 // min, initial, max
    .autoscale(AutoscaleConfig {
        target_utilization: 0.75,                      // steady-state headroom
        drain_time: Duration::from_secs(30),           // clear a backlog within 30 s
        slo: [Some(Duration::from_millis(500)),        // critical may wait ≤ 0.5 s
              Some(Duration::from_secs(5)), None, None], // high ≤ 5 s
        stabilization_window: Duration::from_secs(120),// scale down slowly
        cooldown: Duration::from_secs(30),
        cost_per_worker_second: 0.0011,                // e.g. $4/h per worker
        ..Default::default()
    });
  • In-process (Rust/Python library), capacity is a number of concurrent task slots and the built-in SlotScaler applies it immediately.
  • With the server, capacity is the slots of connected workers. The desired count is exported as cronoq_pool_desired_workers{pool} for KEDA/HPA, or posted to a webhook (scaler = { kind = "webhook", url = "..." }), which then starts or stops workers.

A KEDA ScaledObject for a worker Deployment:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: llm-workers }
spec:
  scaleTargetRef: { name: llm-workers }      # Deployment of workers with slots=1
  minReplicaCount: 0
  maxReplicaCount: 50
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus:9090
        query: max(cronoq_pool_desired_workers{pool="llm"})
        threshold: "1"

The policy

base   = ceil(arrival_rate × mean_service_time / target_utilization)
drain  = ceil(backlog × mean_service_time / drain_time)
slo    = smallest capacity keeping predicted wait ≤ SLO for every class with backlog
desired = clamp(max(base + drain, slo, busy) + reservations_in_horizon, min, max)

Scale-up happens immediately, limited per step to +100% or +4 workers (whichever is larger). Scale-down only goes to the highest desired value seen over the stabilization window, by at most one worker per cooldown. Details: Pools and autoscaling.

What you get

  • Scaling on the right signal: queue pressure and wait-time objectives, not CPU.
  • Scale-to-zero friendly: with min = 0, idle pools cost nothing; the first arrival raises the desired count.
  • Pre-warming for scheduled work: reservations starting within the horizon (60 s by default) add capacity before they start.
  • Stable fleets: stabilization and cooldown prevent flapping during bursty traffic.
  • Cost reporting: each pool reports worker_seconds and cost, so you can compare policies in money, not just utilization.

Migrating from ElasticPriorityScheduler

cronoq ships a compatibility layer for code written against the Python ElasticPriorityScheduler API (Task, Priority, submit, start, stop, get_metrics, get_cost). It runs on the cronoq engine, so existing code gets the engine's behaviour and performance with a one-line import change.

Step 1: swap the import

# before
from elastic_scheduler import ElasticPriorityScheduler, Priority, Task
# after
from cronoq.compat import ElasticPriorityScheduler, Priority, Task
import time

scheduler = ElasticPriorityScheduler(initial_workers=1, max_workers=4)
scheduler.start()
scheduler.submit(Task(id="report", duration=2.0, priority=Priority.HIGH))
scheduler.submit(Task(id="nightly", duration=0.5, fixed_start=time.time() + 60))
scheduler.submit(Task(id="email", duration=0.2, dependencies=["report"]))
...
print(scheduler.get_metrics())   # QualityMetrics(schedule_quality=..., priority_adherence=...)
scheduler.stop()

The shim is synchronous and thread-safe; it runs the native scheduler on a private event loop in a background thread. As in the original, a task "runs" by sleeping for its duration.

Behavioural differences

AreaOriginalcronoq
Fixed-start conflictsAny two overlapping fixed tasks conflict, regardless of worker count.Overlaps are allowed up to half of max_workers (at least one).
Recorded start of fixed tasksmax(now, fixed_start), even if dispatched early.The actual dispatch time.
DependenciesSatisfied only while the parent is among the last 1000 completed tasks.Late dependents are resolved from retained terminal statuses (kept for 1 hour by default).
Deadline urgencyNon-monotonic (an overdue task could rank below an almost-due one).Monotonic; overdue tasks always rank highest within their class.
Scaling triggerschedule_quality thresholds.Queue pressure; scale_up_threshold / scale_down_threshold are accepted and ignored.
schedule_qualityDivides by the worker count at call time.Total work ÷ worker-seconds actually provisioned.
DispatchPolls every 100 ms; one thread per task.Event-driven; no polling.

The native API gives you everything the shim hides: real executors instead of sleeps, results, retries, timeouts, cancellation, resources and per-pool configuration.

OriginalNative equivalent
Task(id=..., duration=d, priority=p)await sched.submit(payload, key=id, priority=p, duration_hint=d)
Task(..., fixed_start=t)submit(..., start_at=t)
Task(..., deadline=t)submit(..., deadline_at=t)
Task(..., dependencies=[a, b])submit(..., after=[a, b])
ElasticPriorityScheduler(initial_workers, max_workers)Scheduler([Pool("default", run, min=1, initial=i, max=m)])
get_metrics()await sched.snapshot() (per pool: priority_adherence, fixed_start_hit_rate, utilization, wait percentiles, cost)
get_cost()(await sched.snapshot()).pool("default").cost (set cost_per_worker_second in AutoscaleConfig)

What you get

  • A one-line migration. Existing code keeps its API, threads and call sites; the scheduling decisions move to the cronoq engine.
  • Correct semantics where the prototype was approximate. Dependencies are resolved from retained terminal statuses (1 hour by default) instead of the last 1000 completions, deadline urgency is monotonic, and fixed starts are admitted against real capacity instead of conflicting whenever two of them overlap.
  • No polling. Dispatch is event-driven: a task starts as soon as a worker is free instead of on the next 100 ms poll, and an idle scheduler uses no CPU.
  • A path forward. Once on the native API, the same code gains real executors, results, retries, timeouts, cancellation, shared rate limits, cron schedules, status events and, with cronoq-server, durable state and remote workers.

Tasks and their lifecycle

A task is one unit of work: a payload plus the rules for scheduling it. A pool is a set of worker slots that runs tasks. An executor (in-process) or a remote worker (server mode) runs one attempt of a task at a time; a task may need several attempts.

Task fields

FieldDefaultMeaning
payloadrequiredThe work. A Rust value, a Python object, or bytes on the wire.
pool"default"Which pool runs it.
kind"default"A label for metrics and for learning typical durations.
priorityNormalCritical, High, Normal or Low. See Priorities.
keynoneUnique name: deduplicates submissions and can be referenced by dependents.
deadlinenoneSoft "done by" time. See Deadlines.
fixed_startnoneStart time, tolerance and miss policy. See Fixed starts.
duration_hintlearnedExpected run time; used for deadlines and reservations.
timeoutnoneMaximum duration of each attempt.
retrypool defaultRetry policy. See Reliability.
depsnoneDependencies with edge conditions. See Dependencies.
resourcesnoneUnits of named resources needed while running. See Resources.
tagsnoneFree-form key/value labels passed to the executor (e.g. traceparent).
labelsnoneWorker labels the task requires; it runs only on workers that carry them. See Worker labels.
tenantnoneTenant the task belongs to, for fair sharing of a pool between tenants. See Fair sharing.

Every task gets a numeric id assigned by the engine. Ids increase monotonically; in server mode the top bits carry the leader's fencing epoch, so ids stay unique across failovers.

Lifecycle

                 ┌── dependencies unmet ──┐
  submit ──▶  Blocked                      │
     │           │ all edges satisfied      │
     ├──────▶ Scheduled ── start time ──┐   │
     │        (fixed start, in future)   ▼   ▼
     └────────────────────────────▶  Ready ◀──────── backoff elapsed ──── Retrying
                                       │ dispatched                        ▲
                                       ▼                                   │ retryable failure,
                                    Running ──────────────────────────────┘ timeout, lease lost
                                       │
      ┌──────────────┬────────────────┼──────────────┬───────────────────────┐
  Succeeded        Failed       DeadLettered     Cancelled        Skipped · Missed · Shed
                 (permanent)   (retries spent)  (by request)   (edge unmet · start missed · overload)
StatusTerminalMeaning
BlockedWaiting for dependencies.
ScheduledWaiting for its fixed start time.
ReadyEligible and queued (or waiting for a resource).
RunningAn attempt is executing.
RetryingWaiting for a retry backoff to elapse.
Succeeded✔The last attempt returned an output.
Failed✔Permanent failure, or a missed fixed start with MissPolicy::Fail.
DeadLettered✔Retryable failures used up every attempt.
Cancelled✔Cancelled by a client.
Skipped✔A dependency edge can no longer be satisfied, an awaited key never appeared, or a recurring occurrence was skipped by the overlap policy Skip.
Missed✔Fixed start missed with MissPolicy::Skip.
Shed✔Evicted to make room under overload.

Terminal statuses are retained for the retention window (1 hour, up to 1,000,000 entries by default). During that window the task can still be looked up, keys stay reserved, and new tasks may depend on it.

Results

  • Rust: submit returns a TaskHandle<O>; awaiting it yields Ok(Arc<O>) on success or Err(TaskFailure) with the terminal status and message.
  • Python: awaiting a TaskHandle returns the executor's return value or raises cronoq.TaskFailed.
  • Server: Wait / GET /v1/tasks/{id}/result return the output bytes (and JSON, when the bytes are JSON). Outputs are kept in a bounded in-memory cache and, with a durable store (SQLite or Postgres), in the store.

Handles are not required: enqueue submits without one, which is cheaper.

Events

Every terminal transition is published as a StatusEvent { id, pool, key, status, attempt, at, message }. With emit_transitions enabled, every intermediate transition is published as well. Subscribe with Scheduler::events() in Rust, sched.events() in Python, or Tasks/Watch over gRPC (with all_transitions for intermediate ones). Slow subscribers lose the oldest events rather than slowing the scheduler down.

Priorities and aging

Classes

ClassBase score
Critical10,000
High1,000
Normal (default)100
Low10

Effective score

When a worker slot is free, the pool dispatches the ready task with the highest effective score:

score = base[class] + min(aging_rate[class] × wait_seconds, age_cap[class]) + urgency

with defaults aging_rate = 2.0 points per second and age_cap = 800 for every class. urgency is zero unless the task has a deadline. Ties are broken by class, then by submission order.

Aging

Aging prevents starvation: a task that keeps waiting slowly gains score until it overtakes newer work of a higher class.

With the defaults:

  • A Low task reaches 10 + 800 = 810 after 400 seconds. Against a continuous stream of fresh Normal work (score about 100), it wins once 10 + 2w > 100, that is after about 45 seconds of waiting.
  • Normal can reach 900 and High can reach 1800. Neither crosses the next class's base score (1000 and 10000), so fresh High work always beats aged Normal work, and Critical always beats everything except deadline urgency.

This is deliberate: aging rescues background work from floods of ordinary work, but never lets it delay urgent work. If you want stronger mixing, raise age_cap (Rust: Builder::priority(PriorityConfig { ... })).

The time is measured from submission. A task released by its dependencies, or retried after a failure, keeps its original place in its class.

Why dispatch never rescans the backlog

Within one class, every task ages at the same rate from its own submission time, so an older task always has a score at least as high as a younger one. A FIFO queue per class therefore always has its best task at the head. Deadline tasks live in a second per-class queue ordered by latest start time. In pools with fair sharing, tasks without deadlines live in a per-class fair-share queue ordered by start tag instead of the FIFO lane. Dispatch compares at most eight heads (four classes × two queues), computing their scores lazily. There is no periodic rescoring and no full-queue scan, so choosing the next task costs at most the logarithm of the backlog. Fresh tasks join the back of their lane; a retried or woken task goes back to its place by submission order, which in a very long lane moves the entries behind it.

Measuring adherence

Each pool counts priority inversions: dispatches made while a ready task of a strictly higher class was waiting in the same pool (tasks blocked on resources do not count). priority_adherence = 1 − inversions / dispatches over the metrics window. With aging and deadlines some inversions are intended; a sudden drop usually means an aging or deadline policy is overriding priorities more often than expected.

Configuration

SettingDefaultWhere
Base scores10000 / 1000 / 100 / 10PriorityConfig::base
Aging rate2.0 points/sPriorityConfig::aging_rate
Age cap800PriorityConfig::age_cap

All three are arrays indexed by class (critical, high, normal, low).

Deadlines and urgency

A deadline is a soft "should be done by" time. It does not cancel the task when it passes; it raises the task's score as the deadline approaches so that it is dispatched in time.

Latest start time

d   = duration estimate (duration_hint, else learned median for (pool, kind),
      else the pool's learned median, else the pool's default_duration = 1 s)
LST = deadline − d          latest start time
slack = LST − now
H   = max(d, urgency_window)            urgency_window = 5 s

Urgency curve

urgency = 0                                      if slack ≥ H
        = U × (1 − slack / H)                    if 0 ≤ slack < H
        = U + min(overdue_rate × (−slack), cap)  if slack < 0

with U = 5000, overdue_rate = 100 per second and cap = 3000.

 urgency
 8000 ┤                                  ┌──────────── cap reached
      │                               ╱
 5000 ┤                        ●───╱       overdue: keeps rising
      │                     ╱
      │                  ╱      rising linearly over the horizon H
    0 ┼───────────────╱
      └──────────────┬──────────┬──────────────────▶ time
                  LST − H      LST

The curve is monotonic: a task that is further past its latest start always has at least the urgency of one that is less late.

Ordering

  • Within a class, tasks with deadlines are kept in a queue ordered by latest start time (earliest-deadline-first), and the head competes with the class's FIFO lane by full score.
  • Across classes, urgency is added to the base score, so a Low task near its deadline (10 + 5000) overtakes fresh High work (1000) but not Critical work (10000).

Choosing values

  • Give a duration_hint when you know it; otherwise the first tasks of a kind use the pool's learned typical run time (or default_duration if nothing in the pool has completed yet) until cronoq has observed completions of that kind.
  • Deadlines are about finishing. If something must start at a given time, use a fixed start.
  • To get more capacity when deadlines are at risk, set per-class wait SLOs in the pool's autoscaling configuration.

Configuration

SettingDefaultWhere
Maximum urgency U5000UrgencyConfig::max
Minimum horizon5 sUrgencyConfig::window
Overdue rate100 /sUrgencyConfig::overdue_rate
Overdue cap3000UrgencyConfig::overdue_cap

Fixed starts and reservations

A task with a fixed start should begin at a given time. cronoq treats this as a capacity commitment: it books capacity when the task is submitted and holds a worker shortly before the start.

Task::new(job)
    .start_at(when)                          // or .start_in(Duration)
    .start_tolerance(Duration::from_secs(5)) // default 1 s
    .on_miss(MissPolicy::Skip)               // default RunLate

1. Admission: reservations

The task occupies a reservation interval in its pool:

[start − tolerance,  start + estimated_duration + tolerance]

At any instant, at most floor(max_workers × reservable_fraction) reservations may overlap (reservable_fraction defaults to 0.5, and at least one reservation is always allowed). If the new interval would exceed that, submission fails with ReservationConflict. When max_workers is 2 or more, a fraction below 1 keeps part of the pool free for ad-hoc work. A pool with max_workers = 1 can be fully reserved, because one reservation is always allowed.

2. Holding a worker

Shortly before the start, the task asks for a hold: the next free slot of its pool is kept idle for it instead of taking regular work. How early depends on the pool's own behaviour:

hold_lead = min(max(tolerance, p90 run time of regular tasks in the pool), max_hold)

The idea is to hold a slot just early enough for the regular work that would otherwise take it to finish. The lead is computed from the run times of the pool's tasks without a fixed start, and it is re-evaluated as the start approaches (the first check happens at start − max_hold, max_hold = 5 minutes by default), so a pool whose tasks get slower starts holding earlier without any configuration. If no slot is free when the hold is requested, the first one to free up is held, before any queued task can take it.

Reservations starting within the autoscaler's horizon also add to the pool's desired capacity, so an autoscaled pool grows before the hold is needed.

3. Start

At the start time (and once its dependencies are satisfied) the task runs on its held slot. The recorded start time is the actual dispatch time; the pool's fixed_start_hit_rate counts the task as a hit if |actual − start| ≤ tolerance.

A tolerance of zero is allowed: the task must start exactly at its time (in practice, on the first scheduling step at or after it), and anything later is a miss.

4. Missing the start

If the task has not started by start + tolerance, its miss policy applies:

PolicyEffect
RunLate (default)Run as soon as a slot is free, ahead of all regular work.
SkipEnd with status Missed.
FailEnd with status Failed.

Typical reasons for a miss: all held capacity is in use by other fixed tasks, the pool has no workers (server mode with no connected workers), or a dependency finished late.

Interaction with other features

  • Dependencies: a fixed-start task with unmet dependencies waits for both its time and its dependencies.
  • Resources: a fixed-start task also needs its resources; if they are not available at the start time it waits for them while keeping its slot.
  • Retries: a retried attempt has no held slot and no miss policy. It waits in the due queue and takes the next free slot ahead of regular work.
  • Overload: fixed-start tasks are never shed by the shed_lowest / shed_oldest overflow policies; their capacity was promised at admission.
  • Recurring schedules create fixed-start tasks; see Recurring schedules.

Configuration

SettingDefaultWhere
Tolerance1 sper task
Miss policyRunLateper task
reservable_fraction0.5per pool
max_hold5 minper pool (PoolConfig)
default_duration1 sper pool; used when no estimate exists

Recurring schedules

A recurring schedule turns a cron expression into a sequence of fixed-start tasks.

sched.add_recurring(
    Recurring::new("nightly-export", "0 30 2 * * *", Task::new(export).pool("batch"))
        .tz("America/New_York")
        .overlap(OverlapPolicy::Skip)
        .tolerance(Duration::from_secs(60))
        .on_miss(MissPolicy::RunLate),
).await?;
sched.remove_recurring("nightly-export").await?;
await sched.add_recurring("nightly-export", "0 30 2 * * *", payload, pool="batch",
                          tz="America/New_York", overlap="queue", tolerance=60,
                          key="nightly-export", deadline_in=3600)

Over gRPC, use Admin.AddRecurring with an admin token (for example the Python client's Client.add_recurring).

Cron syntax

Five fields (minute hour day month weekday) or six with leading seconds (second minute hour day month weekday). Names (MON, JAN) and nicknames (@hourly, @daily) are accepted. Occurrences fall on whole seconds.

Time zones are IANA names and default to UTC. Daylight-saving transitions follow the zone's rules:

TransitionDaily job (e.g. 0 30 2 * * *)Hourly job (0 0 * * * *)
Spring forward (a local hour is skipped)A time inside the gap runs at the first valid time after it (02:30 → 03:00).Runs every real hour.
Fall back (a local hour repeats)Runs once.Runs every real hour, including both occurrences of the repeated local hour.

Use UTC for schedules that must run at exact intervals.

How occurrences are created

  • Each occurrence is created as a task shortly before it starts: hold_lead + tolerance ahead (capped by the pool's max_hold). Only one occurrence exists ahead of time; the next one is created when the current one is due.
  • Each occurrence is a normal fixed-start task: it goes through reservation, holding and the miss policy, carries the tag cronoq.recurring = <name>, and appears in metrics and status events like any other task.
  • The template is checked when the schedule is added, like a task submission: its pool and resources must exist, resource amounts must fit their capacity, and max_attempts must be at least 1. A schedule recovered from the store whose template the configuration no longer supports (a removed pool or resource) is not armed; the recurring_invalid counter records it, and it runs again once the configuration supports it.
  • If an occurrence cannot be reserved (the pool's reservable capacity is full at that time), it is not created; the snapshot's recurring_conflicts counter records it.

Overlap

PolicyEffect
Skip (default)If the previous occurrence is still live when the next one is due, the next one ends as Skipped ("previous occurrence still running").
AllowOccurrences run independently and may overlap.
Queue ("queue")Each occurrence depends on the previous one and starts once it has finished, whatever the outcome. Occurrences run one after another instead of being skipped.
QueueOnSuccess ("queue_on_success")Like Queue, but an occurrence runs only if the previous one succeeded. After a failure the following occurrences end as Skipped, until the schedule is added again, which starts a new chain.

A queued occurrence that waits past its start tolerance follows the miss policy, so keep on_miss at its default, RunLate, unless a late run should be dropped.

The overlap guard and the chain survive failover: with a durable store, the occurrence a queued schedule depends on is stored with the schedule, and the occurrence that was running is recognised by its cronoq.recurring tag.

The template

The template's payload, pool, kind, priority, duration hint, timeout, retry policy, resources, tags, labels and tenant are copied to every occurrence. Two options apply per occurrence:

Template optionEffect on each occurrence
Relative deadline (Task::deadline_in, Python deadline_in=, gRPC deadline_in_s)The deadline is that long after the occurrence's start.
Key (Task::key, key=, key)A prefix: the occurrence's key is <key>@<start in UTC, RFC 3339>, for example nightly-export@2026-10-22T06:30:00Z. Look occurrences up by it. An occurrence that already exists under its key (for example one created before a failover) is not created again; if a task that is not this schedule's occurrence holds the key, the occurrence runs without a key. The server refuses client keys of this form, so they cannot be taken in advance.

A template may not set an absolute deadline, a fixed start or dependencies: they describe a single task, so add_recurring rejects them (Error::Config in Rust, SchedulerError in Python, INVALID_ARGUMENT on the server). Use Queue to make occurrences depend on each other, and the schedule's tolerance and on_miss (or the template's start tolerance and miss policy) to control how late an occurrence may start.

Downtime and failover

With a durable store, schedules and their next occurrence time are persisted. After downtime:

  • if the missed occurrence's policy is RunLate, one late occurrence runs;
  • otherwise the missed occurrence is counted as missed and not run;
  • the schedule then resumes from the present; missed occurrences are not replayed one by one.

Re-adding a schedule with the same name replaces it without duplicating or overlapping an occurrence that was already created. Removing a schedule does not cancel an occurrence that was already created.

Dependencies and keys

Keys

A key is an optional unique name for a task.

  • Deduplication: submitting a task whose key belongs to a live task, or to a terminal task still in the retention window, does not create a new task. It returns the existing id with duplicate = true. This makes producers safe to retry.
  • References: dependencies, cancellation, status lookups and result waits accept a key wherever they accept an id.

Keys stay reserved while the task is live and for the retention window after it ends (limits.key_retention_s on the server, CoreConfig::retention in the library; 1 hour by default). Separately, the server deletes finished rows from a Postgres or SQLite store after store.keep_finished_s (7 days by default).

Edges

A dependency is a reference to a parent (id or key) plus a condition:

ConditionRustPythonSatisfied when the parent...
on success (default).after(x) / Dep::on_success(x)after=[x] / Dep(x)succeeded
on failureDep::on_failure(x)Dep(x, on="failure")failed or was dead-lettered
on completeDep::on_complete(x)Dep(x, on="complete")reached any terminal status

A task with unmet dependencies is Blocked and does not enter any queue. When a parent ends:

  • every satisfied edge decrements the child's count of unmet dependencies; at zero the child becomes eligible;
  • an edge that can no longer be satisfied makes the child Skipped, and this propagates to its own dependents.

If the parent had already finished when the child was submitted, the edge is resolved immediately from the retained status.

After a restart with a durable store, a parent that finished so long ago that its status is no longer retained counts as satisfied: it was accepted, and only work that has not finished is recovered.

Unknown parents

A dependency on a key that does not exist yet is handled per pool:

SettingEffect
UnknownDependency::Reject (default)Submission fails with UnknownDependency.
UnknownDependency::Await { timeout }The child waits for a task with that key; if none appears within timeout, it is Skipped.

In Python: Pool(unknown_dependency="await", await_timeout=...); on the server: await_unknown_dependencies_s on the pool. Waiting children are tracked per key and cleaned up when they are cancelled, time out or are resolved, so abandoned awaits do not accumulate.

A dependency on an id that does not exist is always rejected.

Cycles

Cycles are rejected with Cycle: a task depending on its own key, cycles within a batch, and cycles closed through a task that is awaiting a key the new submission defines.

Batches

submit_batch admits several tasks atomically. Dependencies may reference keys defined anywhere in the batch, in any order. If any task is invalid, would close a cycle, conflicts with a reservation or exceeds a bound, nothing is admitted and the error names the offending index.

Complexity

Dependencies are tracked with in-degree counters and reverse edges, so a parent finishing costs O(number of its children). Completed parents are never scanned.

Retries, timeouts and leases

cronoq executes tasks at least once: an attempt that may have been lost is retried. Make handlers idempotent, or make their side effects exactly-once with fencing tokens, and deduplicate submissions with keys.

Attempts and outcomes

Each dispatch starts an attempt identified by a lease. An attempt ends with:

OutcomeRustPythonNext
SuccessOk(output)return valueSucceeded
Retryable failureTaskError::retryable(msg)raise cronoq.Retryable(msg) or any other exceptionretry, or DeadLettered when attempts are exhausted
Retryable with delayTaskError::retry_after(msg, d)raise cronoq.Retryable(msg, retry_after=s)retry no earlier than d
Permanent failureTaskError::permanent(msg)raise cronoq.Permanent(msg)Failed
CancelledTaskError::Cancelled(task cancelled)Cancelled
Panic (Rust executor)permanent failure (Failed); the scheduler and other attempts keep running

Exactly-once side effects

No scheduler can stop a handler from running twice: a worker can lose its connection after charging a card but before reporting success. What cronoq guarantees is enough to make the effect happen once:

  • Attempt numbers never repeat for a task, also across failovers: an attempt is dispatched only after it is durably recorded, and a new leader continues from the recorded number. So (task id, attempt) is a fencing token: a later attempt always has a higher number.
  • Stale reports change nothing. A completion or failure for an attempt that timed out, was aborted, was already reported or came from a previous leader is ignored, and counted in stale_completions (metric cronoq_stale_completions_total). Lease ids carry the leader epoch, so they never repeat across leaders either.
  • Resubmitting a finished keyed task returns it. Within the key retention window (limits.key_retention_s, default 1 hour; CoreConfig::retention in the library), a submission with the key of a finished task is a duplicate: it returns that task and its stored output instead of running again.

Record the token with the side effect, in the same transaction, and let the write succeed only for the newest attempt:

INSERT INTO charges (task_id, attempt, amount) VALUES ($1, $2, $3)
ON CONFLICT (task_id) DO UPDATE SET attempt = excluded.attempt, amount = excluded.amount
WHERE charges.attempt < excluded.attempt;   -- an older attempt arriving late changes nothing

For effects that must happen once at all (not "last attempt wins"), insert on task_id alone and skip when the row exists. The token is ctx.id and ctx.attempt in Rust and Python executors, and Lease.task_id and Lease.attempt for remote workers.

Retry policy

RetryPolicy {
    max_attempts: 3,                          // total, including the first
    initial_backoff: Duration::from_secs(1),
    multiplier: 2.0,
    max_backoff: Duration::from_secs(60),
    jitter: true,                             // full jitter
}

The delay before attempt n (n ≥ 2) is min(initial × multiplier^(n−2), max_backoff); with jitter, a uniformly random fraction of it. A retry_after hint raises the delay to at least that value. The policy is set per pool and can be overridden per task; RetryPolicy::none() (or Task::no_retry()) disables retries.

A retried task keeps its original submission time, so it keeps its place in its priority class.

Timeouts

timeout limits each attempt. When it expires the engine aborts the attempt (the executor's future is cancelled, or the remote worker receives Abort) and records a retryable failure.

Leases and heartbeats

Pools with a lease TTL (always the case for server pools; lease_ttl_s, default 30 s) require the executor or worker to heartbeat each lease:

  • Rust: ctx.heartbeat(); Python bindings: ctx.heartbeat(); Python worker client and Rust worker client: automatic, every TTL / 3.
  • If no heartbeat arrives within the TTL, the lease expires, the attempt is aborted and treated as a retryable failure. This catches crashed workers and network partitions.

In-process pools have no TTL by default because the engine knows when the attempt's future ends.

Cancellation

cancel(id or key) ends a task immediately with status Cancelled:

  • a queued, blocked or retrying task is removed;
  • a running attempt is aborted (the cancellation token fires and the future is dropped; remote workers get Abort);
  • dependents attached with an on-success or on-failure edge are Skipped; for dependents attached with an on-complete edge, that edge is satisfied.

Shutdown and dropped schedulers

shutdown(grace) stops admitting work, lets running attempts finish for grace, then aborts the rest; queued tasks are not started (with a durable store they stay queued for the next leader). If every Scheduler handle is dropped without shutdown, the runtime shuts down by itself with drop_grace (default 5 s). Handles awaiting unfinished tasks then resolve with TaskFailure::Shutdown (Python TaskFailed(status="shutdown")).

Outputs

A TaskHandle resolves with the executor's output. Handles attached after the task finished (handle(key), a duplicate submission) get the output from a bounded cache of recent outputs (output_cache, 1024 by default) or from the store. If neither still holds it, the handle resolves with TaskFailure::OutputUnavailable (Python: TaskFailed with status succeeded; server: output_available = false): the task did succeed, but its result is gone.

Dead letters

Tasks that end as DeadLettered or Failed keep their final message, available through status events, Wait results and (with a SQLite or Postgres store) the store. Monitor cronoq_tasks_total{status="dead_lettered"} to catch systematic failures.

Recovery after a crash

With a durable store, a task that was running when the leader died counts that attempt as used. If attempts remain, it is retried after a backoff; otherwise it is dead-lettered. See Durability and HA.

Resources: semaphores and rate limits

A resource is a named, shared limit that tasks request units of. Resources are global to a scheduler: tasks in any pool can use the same resource.

KindRustPythonServer configBehaviour
SemaphoreResourceConfig::semaphore("gpu", 4)Resource.semaphore("gpu", 4)kind = "semaphore", permitsAt most permits units held at once; units return at the end of each attempt.
Token bucketResourceConfig::token_bucket("api", 50.0, 100)Resource.token_bucket("api", rate=50.0, burst=100)kind = "token_bucket", rate, burstUnits are consumed; they refill at rate per second up to burst.

Request units on the task: .resource("gpu", 1) (Rust), resources={"gpu": 1} (Python), "resources": {"gpu": 1} (HTTP).

Dispatch rules

  1. When a task reaches the front of the queue, all its resources are checked together. If every one has enough units, they are all taken at once (no partial acquisition, so no deadlocks between tasks needing several resources).
  2. If any is short, the task is parked on that resource and removed from the dispatch queue. The next task in the pool is considered immediately, so work that does not need the resource is not blocked.
  3. When units return (semaphore) or refill (token bucket), parked tasks are re-queued in FIFO order, as many as the available units can satisfy. A token bucket schedules a timer for the moment enough tokens will have accumulated.
  4. A request larger than the resource's capacity (permits or burst) is rejected at submission with ResourceExceedsCapacity. Requests for unknown resources fail with UnknownResource.

Choosing between them

  • Use a semaphore for things that are held while working: GPUs, connections, licences, concurrent requests to a partner.
  • Use a token bucket for rates: requests per second or minute, tokens per minute (request one unit per thousand tokens, for example).
  • Combine both for APIs with a rate limit and a concurrency limit.

Interaction with priorities

Parking happens after priority selection, so the most urgent task that can run is dispatched. A parked high-priority task gets the resource as soon as it is available, ahead of later waiters for that resource. Parked tasks are not counted as priority inversions.

Resources and shards

With sharding, each shard counts its resources on its own, so every resource belongs to one shard (shard = "...") and only that shard's tasks may use it. To share an outside limit between shards, define one resource per shard with its share of the limit.

Worker labels

One pool can hold workers that differ: some have a GPU, some run in another region. Labels describe them, and a task can require labels to run only on matching workers.

// The task needs an A100.
sched.submit(Task::new(payload).pool("inference").label("gpu", "a100")).await?;
await sched.submit(payload, pool="inference", labels={"gpu": "a100"})

Worker groups

The workers of a pool that carry exactly the same labels form a group. The scheduler keeps the capacity of each group:

  • Remote workers send their labels when they register (Register.labels; WorkerOptions::label in Rust, Worker(labels=...) in Python). The server adds each worker's slots to its group.
  • Embedded schedulers report groups with Scheduler::set_group_capacity(pool, labels, n) (Python set_group_capacity). set_capacity sets the unlabeled group.
  • A pool's capacity is the sum over its groups; snapshots list each group's capacity and busy slots (PoolSnapshot.groups, /v1/snapshot → pools[].groups).

How tasks are placed

TaskRuns in
No labelsAny group with a free slot, preferring the least specialized one (fewest labels, then the most free slots), so workers with rare labels stay available for tasks that need them.
With labelsOnly a group whose labels include every required name = value pair. A group may carry more labels than the task requires.

A task whose required labels no free group has waits without blocking the pool: other tasks keep running in the free slots, and the waiting task starts as soon as a matching slot frees or a matching worker connects, in submission order among the tasks waiting for that group. A task that requires labels no worker has waits until one connects (or until it is cancelled; deadlines raise urgency but never cancel).

Each attempt is assigned to a group: remote leases go only to a worker of that group, and embedded executors see it as ctx.group.

Limits

  • Fixed-start reservations are counted per pool, not per group: a fixed-start task that requires labels starts when a matching slot is free, so give it enough tolerance if matching workers are scarce.
  • Autoscaling targets a pool's total capacity. Scale labeled worker fleets separately, for example one deployment per label set.

Security

Workers declare their own labels, so a worker credential could claim labels it should not have and receive tasks meant for other machines. Restrict what a credential may claim:

[[auth.tokens]]
token_env = "CRONOQ_GPU_WORKER_TOKEN"
role = "worker"
pools = ["inference"]
labels = ["gpu=a100", "zone=eu"]   # a worker with this token may claim only these

A worker that claims a label its credential does not allow is refused with PERMISSION_DENIED.

Fair sharing between tenants

When several teams or customers share a pool, one of them submitting a burst of work should not hold everyone else back. A pool with fair sharing splits its dispatches between tenants by weight.

[[pools]]
name = "llm"
[pools.tenant_weights]   # implies fair sharing; tenants not listed weigh 1
acme = 3
globex = 1
let mut pool = PoolConfig::new("llm");
pool.fairness = Some(Fairness::weights([("acme", 3.0), ("globex", 1.0)]));
sched.submit(Task::new(payload).pool("llm").tenant("acme")).await?;
cronoq.Pool("llm", run, tenant_weights={"acme": 3, "globex": 1})
await sched.submit(payload, pool="llm", tenant="acme")

Set fair_share = true (Python fair_share=True) for equal shares without listing weights.

How it works

  • Priority comes first. Fair sharing orders tasks within a priority class: a critical task still runs before any normal one, whatever its tenant.
  • Shares follow weights. While several tenants have waiting work in a class, each gets dispatches in proportion to its weight. With weights 3 and 1, acme gets three dispatches for every one of globex's, even if globex queued all of its work first.
  • No banked credit. A tenant that had nothing waiting does not catch up later: when it submits again, it shares from that moment on. A tenant alone in the pool gets everything.
  • Tasks without a tenant share as one more tenant with the default weight.
  • Deadlines keep their order. Tasks with a deadline are ordered by urgency, as in any pool, and are not part of the fair share.
  • Shares count dispatches, not time: give long and short tasks different pools if their run times differ widely.

The scheduler uses start-time fair queuing: each task gets a virtual start tag, the later of the class's virtual time and its tenant's previous finish tag, and tasks run in tag order. Dispatch stays logarithmic in the backlog.

Tenants and credentials

The caller names a task's tenant (tenant in the API). To stop a client from spending another tenant's share, pin the tenant on its credentials:

[[auth.tokens]]
token_env = "CRONOQ_ACME_TOKEN"
role = "client"
tenant = "acme"   # tasks from this token are acme's; naming another tenant is refused

A task submitted with such a token gets its tenant filled in; a task that names another tenant is refused with PERMISSION_DENIED.

Pools and autoscaling

Pools

A pool is a group of worker slots with one executor (or one set of remote workers), one autoscaling policy and its own queue bounds. A slot runs one attempt at a time.

Setting (library / server key)Default (library)Default (server)
min_workers / min10
max_workers / max1010
initial_workers / none10 (capacity comes from workers)
Autoscalingonon
Lease TTLnone30 s
Overflow policyWaitReject
Retry policy3 attempts, 1 s ×2 up to 60 s, jittersame

The minimum bounds the autoscaler's desired capacity: desired is never below it. It does not set capacity itself. In the library, initial_workers is the capacity at start (at most the maximum, and not raised to the minimum); in the server, capacity comes only from connected workers.

Use several pools to isolate workloads with different executors, hardware or scaling needs; use resources to share limits between them.

Capacity

  • In-process (Rust or Python library): a slot is a concurrently running task. The default SlotScaler sets capacity to whatever the autoscaler asks for, immediately.
  • Server: capacity is the sum of slots of the workers connected to the pool. The scaler only requests capacity (External: export a metric; Webhook: POST the number), and capacity changes when workers connect or disconnect.
  • You can report capacity yourself with Scheduler::set_capacity (Rust) or set_capacity (Python) for externally managed pools.

The queue-pressure policy

Every interval (1 s) each autoscaled pool computes:

λ        arrival rate (EWMA, α = 0.3) of tasks becoming ready
S        mean service time (EWMA of observed run times; default_duration before any)
B        backlog per class: ready tasks plus tasks waiting for a retry
         (fixed-start tasks that are due count as critical)

base     = ceil(λ × S / target_utilization)             target_utilization = 0.75
             (0 when below 0.01: the smoothed rate decays toward zero but never reaches it)
drain    = ceil(ΣB × S / drain_time)                    drain_time = 30 s
slo      = max over classes c with backlog and an SLO:
             ceil(B_≤c × S / slo[c])                    B_≤c = backlog in classes c and higher
reserved = peak concurrent reservations in [now, now + reservation_horizon]   horizon = 60 s

desired  = clamp(max(base + drain, slo, busy) + reserved, min_workers, max_workers)

Then:

  • Scale up immediately, by at most max(ceil(current × max_step_up_ratio), max_step_up_abs) per evaluation (defaults: +100% or +4).
  • Scale down only to the highest desired value seen over the stabilization_window (60 s), by at most max_step_down (1) per cooldown (30 s).
  • Running tasks are never interrupted: when capacity shrinks below the busy count, slots retire as their attempts finish.

Cost

Set cost_per_worker_second and each pool reports worker_seconds and cost, accumulated since start, in snapshots. Cost is also exported as a metric (cronoq_pool_cost_total in Prometheus, cronoq.pool.cost in OpenTelemetry); worker_seconds is in snapshots only.

Configuration reference

FieldDefaultMeaning
interval1 sEvaluation period.
target_utilization0.75Steady-state busy fraction to aim for.
drain_time30 sTime within which a backlog should be cleared.
slononePer-class maximum predicted queue wait.
reservation_horizon60 sReservations starting within this add capacity.
stabilization_window60 sScale-down looks at the maximum desired value over this window.
cooldown30 sMinimum time between scale-down steps.
max_step_up_ratio / max_step_up_abs1.0 / 4Largest scale-up step.
max_step_down1Largest scale-down step.
ewma_alpha0.3Smoothing of the arrival rate.
cost_per_worker_second0For cost reporting.

The policy is implemented in cronoq-core and is fully deterministic, which makes it testable with simulated time.

Backpressure

Queues in cronoq can be bounded, so that overload turns into explicit backpressure instead of unbounded memory growth.

Bounds

A task counts as pending from submission until it starts running (and again while it waits for a retry). Bounds limit pending tasks:

BoundRustPythonServer
Per poolPoolConfig::bounds.max_pendingPool(max_pending=...)[[pools]] max_pending (default limits.max_pending_per_pool, 100 000)
Per pool and classPoolConfig::bounds.max_pending_per_class
GlobalCoreConfig::global_boundslimits.max_pending_total (1 000 000)

The library leaves queues unbounded unless you set a bound; the server always bounds them.

Overflow policies

What happens when a submission would exceed a bound is set per pool:

PolicyEffectDefault for
WaitThe submit call waits until space frees up (FIFO per pool).library
RejectThe submit fails with Overloaded (HTTP 429, gRPC RESOURCE_EXHAUSTED).server
ShedLowestEvict the newest queued task of the lowest class strictly below the incoming one (status Shed); reject if there is none.
ShedOldestEvict the oldest queued Low task; reject if there is none.

Shedding rules:

  • Victims are tasks waiting to run: queued tasks, including those parked waiting for a shared resource. Running tasks are never shed.
  • Fixed-start tasks are never shed: their capacity was promised when they were admitted.
  • Only when it helps. A bound on the incoming task's own class is never relieved by evicting tasks of other classes, so such submissions are rejected without shedding.
  • Only for admissible tasks. All other admission checks (unknown pool or dependency, cycle, reservation conflict) run first, so a submission that would be rejected anyway never evicts anything.
  • Not for batches. A batch that does not fit is rejected (or waits, with Wait).
  • A shed task ends with status Shed; its dependents are resolved like for any other terminal status.

Choosing a policy

  • In-process producers (Wait): natural flow control; a fast producer slows to the pool's pace.
  • Network clients (Reject): clients should not hold connections open; they retry with backoff on 429 / RESOURCE_EXHAUSTED.
  • Best-effort background work (ShedLowest): important work is always admitted; low-value work is dropped under pressure and you see it in cronoq_tasks_total{status="shed"}.

A waiting submitter whose caller gives up (dropped future, timed-out request) is removed from the wait queue without being admitted.

Metrics and quality signals

cronoq reports two kinds of telemetry:

  1. Snapshots: per-pool quality metrics over a sliding window (60 s by default), returned by Scheduler::snapshot() (Rust), await sched.snapshot() (Python), GET /v1/snapshot and Admin/Snapshot.
  2. Exported metrics: OpenTelemetry instruments (library, otel feature) and Prometheus text at /metrics (server). See the metrics reference.

Pool snapshot fields

FieldMeaning
capacityCurrent worker slots.
busySlots running an attempt.
heldSlots held for upcoming fixed-start tasks.
desiredAutoscaler target.
pending_by_classPending tasks per class (critical, high, normal, low).
readyTasks queued for dispatch now.
reservationsFixed-start reservations booked.
dispatched, succeeded, failed, retriedCounts within the window.
priority_adherenceFraction of dispatches made with no strictly higher class waiting.
fixed_start_hit_rateFraction of fixed-start tasks started within their tolerance.
utilizationBusy slot-seconds ÷ capacity slot-seconds.
schedule_efficiencyCompleted work seconds ÷ capacity slot-seconds.
wait_mean, wait_p50/p90/p99Queue wait of first attempts (submission to dispatch).
run_p50, run_p99Run time of successful attempts.
worker_seconds, costSince start.

Snapshots also include totals since start (submitted, succeeded, failed, dead_lettered, cancelled, skipped, missed, shed, retries, timeouts, lease_expiries, stale_completions, recurring_conflicts, recurring_invalid).

Reading the signals

SignalHealthyInvestigate when
wait_p99Below your latency objectiveRising while utilization is high: capacity-bound; while low: resource-bound (check rate limits).
utilization0.5–0.85Near 1.0 with growing backlog (scale out); very low with desired at min (over-provisioned).
priority_adherenceClose to 1.0Drops: aging or deadlines override priorities often; review classes or caps.
fixed_start_hit_rate1.0Below 1.0: reservable capacity too small, or no workers connected around start times.
desired vs capacityEqual (in-process) or converging (server)Persistently desired > capacity in server mode: your scaler is not adding workers.
dead_lettered rateNear zeroSpikes indicate a failing dependency; inspect status messages.

Implementation notes

Window metrics use a ring of 12 buckets, and wait/run percentiles come from a log-linear histogram (about 9% resolution). Utilization integrates busy and capacity over time, so it is exact for the window, not sampled.

Durability and high availability

By default the scheduler keeps its state in memory: fast, but a process restart loses queued work. The Store trait makes state durable. Two stores implement it, both with active-passive high availability:

StoreUse it for
cronoq-store-postgresReplicas on several hosts sharing a PostgreSQL database.
cronoq-store-sqliteOne host, no database server: state in a local file, with standby processes on the same host.

Both pass the same conformance suite, so the behaviour below holds for either.

Model

The engine is the system of record while it runs. The store is a write-ahead log of the engine's state transitions:

  • task submissions (full spec and payload), status changes (running, retrying, terminal, with attempt number and message), outputs of successful tasks;
  • cancellations;
  • recurring schedules, their next occurrence and the last task each one created.

Persisted mutations are group-committed: everything produced in one engine step is written in one transaction, so durability costs one round trip per step, not per task.

Durable acknowledgements

With a persistent store, a call returns only after its effect is committed:

OperationReturns after
submit, enqueue, submit_batch (gRPC Submit, HTTP POST /v1/tasks)The task is stored.
cancelThe cancellation is stored.
add_recurring, remove_recurringThe schedule change is stored.

The engine also dispatches an attempt only after the mutations that led to it are durable, so a worker never runs a task that a crash could "unsubmit".

Time

Engine time is Unix time in nanoseconds, so persisted timestamps (fixed starts, deadlines, retry times, next cron occurrences) mean the same thing to every replica and survive restarts without translation.

Recovery

When a scheduler starts with a durable store it:

  1. becomes leader (see below) and obtains a new fencing epoch;
  2. loads non-terminal tasks, terminal tasks within the store's retention window (for dependencies and key deduplication), terminal tasks that live tasks depend on, and recurring schedules;
  3. re-inserts tasks with their original ids, submission order and attempt counts.

Tasks that were running when the previous leader died count that attempt as used: they are retried after a backoff, or dead-lettered if no attempts remain. Fixed-start tasks whose start has already passed apply their miss policy. A dependency on a parent that finished before the retention window counts as satisfied. Recurring schedules keep their overlap guard: an occurrence still running from before the failover is not overlapped by the next one. This is what makes execution at-least-once.

Store failures

The driver distinguishes three kinds of store errors:

ErrorBehaviour
Unavailable (connection lost, timeout, database restarting)Retried with capped backoff for up to max_store_outage (default 5 minutes). The engine keeps running in memory meanwhile; acknowledgements and dispatches that need durability wait. If the outage lasts longer, the scheduler stops with Error::Store.
OtherRetried with backoff; the scheduler stops on the 5th such failure since the last successful commit.
FencedAnother instance is leader: the scheduler stops immediately.

A commit that takes longer than commit_timeout (default 30 s) counts as Unavailable. Callers whose operation could not be made durable get Error::Store (Python SchedulerError, server UNAVAILABLE / 503); retry them, ideally with a key so the retry is idempotent.

Leader election and fencing (Postgres)

Several cronoq-server replicas (or embedding processes) can point at the same database:

  • Each replica tries to take a Postgres advisory lock on a dedicated session, every lock_poll (0.5 s). The holder is the leader; it increments the epoch and serves. The others wait and report GET /readyz = 503.
  • Every commit checks the epoch in the same transaction. A leader that lost its lock (for example after a network partition) can no longer write: its commit fails with Fenced and it stops.
  • The leader checks its lock session every keepalive (1 s) and steps down if the session is unresponsive for session_timeout (10 s); the same timeout configures TCP keepalives on the database side, so Postgres releases the lock of a vanished leader promptly.
  • When the leader stops, a standby acquires the lock, recovers and starts serving. Task ids carry the epoch in their upper bits, and the store keeps a high-water mark of issued ids, so ids are never reused, even after old rows are pruned.

Clients and workers reach the leader either through a load balancer that routes on /readyz (only the leader is ready), or through one that sends connections to every replica. In the second case a standby answers each call with UNAVAILABLE, and closes every connection about a second after accepting it (and a leader that steps down closes its connections the same way), so a client that retries opens a new connection and the balancer picks a replica again. Workers reconnect on their own. During a graceful shutdown connections stay open, so running attempts can still report. The Helm chart routes to every replica, so that rolling updates can proceed.

Leader election and fencing (SQLite)

Several processes on one host can open the same database file:

  • Leadership is a lease row in the file: the holder's identity and an expiry time. A process that finds the lease free or expired takes it, increments the epoch and serves. The others check every lock_poll (0.5 s) and report GET /readyz = 503.
  • The leader renews the lease every lease_renew (2 s); it lasts lease_ttl (10 s). If the leader dies or hangs, a standby takes over once the lease runs out. A leader that shuts down cleanly releases the lease, so the standby takes over at once.
  • SQLite lets one writer at a time into the file. Every commit checks the epoch and lease holder inside its write transaction, so a leader that was paused past its lease and replaced can no longer write: its commit fails with Fenced and it stops. Its renewal also notices the takeover.
  • Lease expiry uses the wall clock, which processes on one host share. Keep the file on a local filesystem: SQLite's WAL journal does not work over network filesystems, which is also why this store does not span hosts.
  • The file is created readable and writable by its owner only (on Unix), and SQLite gives its journal files the same permissions. By default every commit syncs the journal to disk (full_sync).
            ┌──────────────┐   advisory lock    ┌──────────────┐
 clients ──▶│ replica A    │◀──────────────────▶│              │
 workers ──▶│ (leader)     │   commits + epoch  │  PostgreSQL  │
            └──────────────┘                    │              │
            ┌──────────────┐  waiting for lock  │              │
            │ replica B    │◀──────────────────▶│              │
            │ (standby,503)│                    └──────────────┘
            └──────────────┘

Schema and migrations

  • PostgreSQL tables live in one schema, public by default (PgStoreConfig::schema when you embed the store, store.schema in the server). Instances configured with the same schema form one cluster; different schemas are fully independent, so one database can host several clusters. With SQLite, each file is one cluster.
  • Migrations are versioned in cronoq_schema_version and applied automatically on connect, under a lock, by whichever instance starts first. An older build refuses to run against a newer schema instead of corrupting it.
  • PostgreSQL text cannot hold NUL characters. The APIs reject them in keys, kinds, tags and names; NUL in messages reported by executors or workers is stored as U+FFFD (in both stores, so they behave alike).
  • A row that cannot be decoded during recovery (for example after manual edits) is quarantined: marked failed with an explanatory message, instead of preventing the leader from starting.

Guarantees and limits

PropertyGuarantee
Acknowledged submissions, cancellations, schedule changesDurable.
ExecutionAt least once; a task may run again if its worker or the leader fails mid-attempt.
Order after failoverSubmission order and priorities are preserved.
Split brainPrevented by fencing: only the current epoch can commit.
OutputsPersisted for successful tasks; Wait returns them after a failover.
IdsUnique across leaders and restarts.

Retention: the server prunes finished rows after keep_finished_s (7 days by default). Recovery loads finished tasks from the last retention (1 hour by default) for dependency resolution and key deduplication.

Custom stores

Implement cronoq::Store<P, O> (acquire_leadership, commit, recover, optionally release_leadership and load_output) to persist to another database. Return StoreError::Unavailable for transient failures and StoreError::Fenced from commit when the caller's epoch is stale. Apply the TimeBase you are given when converting timestamps; the runtime always passes Unix-epoch time (unix_nanos_at_zero = 0).

Sharding

One server leads all pools by default, with standbys taking over when it stops (Durability and high availability). When one leader is not enough, or pools should fail independently, split the pools into shards: each shard has its own leader, standbys and store, and shards keep serving when another one is down.

[[pools]]
name = "default"
[[pools]]
name = "llm"

[[shards]]
name = "cpu"
number = 1
pools = ["default"]
endpoint = "https://cronoq-cpu.internal:7070"

[[shards]]
name = "gpu"
number = 2
pools = ["llm"]
endpoint = "https://cronoq-gpu.internal:7070"

[store]
kind = "postgres"
url_env = "DATABASE_URL"   # each shard uses the schema cronoq_<shard>

All servers read the same file and are started for one shard each:

cronoq-server --config cronoq.toml --shard cpu   # 2+ replicas for failover
cronoq-server --config cronoq.toml --shard gpu

What a shard is

  • Its own pools. A server runs only its shard's pools.
  • Its own resources. Shards do not share counts, so each [[resources]] entry names the shard that owns it (shard = "gpu"), and only that shard's tasks may use it; a task of another shard asking for it is refused. To share an outside limit between shards, split it into one resource per shard with its share: api_cpu with 6 calls per second in cpu, api_gpu with 4 in gpu. [limits], such as max_pending_total, apply to each shard separately.
  • Its own store. Postgres keeps each shard in its own schema (store.schema must contain {shard} and defaults to cronoq_{shard}); SQLite in its own file (store.path must contain {shard}). A server refuses to start on a store that holds another shard's tasks. Start a sharded deployment on new stores: an unsharded server's tasks are not always recognised, and moving them into a shard is not supported.
  • Its own ids. Task ids carry the shard's number in their top 8 bits, so ids never collide between shards, and any server can tell which shard a task belongs to.
  • Its own keys. Keys deduplicate within a shard; two shards can each have a task with the same key.

Calling any server

Clients can call the server of any shard. It answers from every shard that the call concerns:

  • Work for another shard is forwarded to it: submissions and batches for its pools, lookups, waits and cancellations of its tasks, and schedules for its pools. Lookups by key ask every shard, so the answer does not depend on which server receives the call: a key that names tasks in two shards is refused (FAILED_PRECONDITION, use the task id). While a shard cannot be asked, Get and Wait by key still find a task that one reachable shard holds and name the missing shards (cronoq-unavailable-shards metadata; unavailable_shards over HTTP), but Cancel by key is refused with UNAVAILABLE, since the missing shard might hold the same key. Keys are unique only within a shard, so prefer ids for anything that changes a task, or keep each key's tasks in one shard's pools.
  • Reads cover every shard: task listings (paged across shards in id order), Watch, worker and schedule listings, and snapshots. When a shard cannot be reached, the others still answer, and the response names the missing shard in unavailable_shards. A Watch ends when any shard's stream ends; reconnect. Prometheus /metrics stays per server: scrape each shard.

A forwarded call carries the caller's own bearer token, and the other shard authenticates and authorizes it again, so forwarding never grants more than the caller's own credentials. Each shard's server sees the tokens of the callers it forwards; protect every shard's servers as you would one server. Forwarded calls are marked (cronoq-forwarded) and answered by the receiving shard only, so they never travel further.

Configure how servers reach each other's https:// endpoints with [server.peer_tls] (ca, and cert / key for shards that require mTLS); the system roots are the default.

When a call is not forwarded

Some calls are refused instead, with FAILED_PRECONDITION (HTTP 409) naming the shard and its endpoint in the message and in the response metadata (cronoq-shard, cronoq-shard-endpoint), so the caller can go there directly:

  • Client certificates. A caller that authenticated with a certificate has no token to forward. Its reads cover only the receiving server's shard (the others are listed as unavailable), and its Watch streams only that shard's events.
  • Plain http:// to another host. Tokens are not sent unencrypted across the network: calls for a shard whose endpoint is http:// on another host are refused with its endpoint, unless server.insecure_peer_http = true (trusted networks only). Loopback endpoints and https:// endpoints are always used.
  • Workers register with the shard of their pool. A worker that connects to another shard's server is refused with the endpoint to use, and the Rust, Python, Go and TypeScript workers connect there on their own (up to three redirects in a row). They keep their TLS settings, never follow a redirect from https:// to http://, and send their token over plain http:// only to loopback unless the worker allows it (insecure_redirect_http, trusted networks only). When that shard cannot be reached, the worker asks its configured endpoint again, so a load balancer in front of all shards works as the worker's endpoint.

Credentials are checked first: a caller that may not use a pool learns nothing about where it is served.

What cannot span shards

  • Dependencies on another shard's task (INVALID_ARGUMENT): a task can only wait for tasks of its own shard. Dependencies by key refer to keys of the task's own shard.
  • Batches with tasks of several shards (INVALID_ARGUMENT): a batch is admitted atomically by one shard.
  • Resources of another shard (INVALID_ARGUMENT).

Writing executors and workers

An executor runs attempts in-process (library mode). A worker runs attempts in another process and talks to cronoq-server (server mode). The rules are the same: run the payload, report an outcome, respect cancellation, and heartbeat long attempts when a lease TTL applies.

Rust executors

Most executors are closures:

Pool::new("default").executor_fn(|ctx: TaskContext, job: Arc<Job>| async move {
    tokio::select! {
        r = do_work(&job) => r.map_err(TaskError::from_err),
        _ = ctx.cancelled() => Err(TaskError::Cancelled),
    }
})

For more control, implement the trait:

use std::sync::Arc;
use cronoq::{BoxFuture, Executor, LeaseId, TaskContext, TaskError};

struct HttpExecutor { client: reqwest::Client }

impl Executor<Request, Response> for HttpExecutor {
    fn execute(&self, ctx: TaskContext, req: Arc<Request>) -> BoxFuture<Result<Response, TaskError>> {
        let client = self.client.clone();
        Box::pin(async move { /* ... */ })
    }

    // Optional: release per-lease resources synchronously when the engine abandons an attempt.
    fn abort(&self, _lease: LeaseId) {}
}

TaskContext provides id, lease, attempt, pool, kind, key, tags, deadline, timeout, is_cancelled(), cancelled(), cancellation_token(), heartbeat(), tag(name) and traceparent().

Guidelines:

  • Be idempotent. Execution is at-least-once; record (ctx.id, ctx.attempt) with side effects that must happen once (fencing tokens).
  • Classify errors. Use permanent for errors a retry cannot fix (validation, 4xx), and retry_after when the remote side says when to come back.
  • Do not block the runtime. Use tokio::task::spawn_blocking for CPU-heavy or blocking work.
  • Check ctx.deadline to pick a faster path when time is short.
  • Panics are contained. A panicking executor fails its task permanently; the scheduler and other attempts keep running.
  • Heartbeat when the pool has a lease TTL. Long attempts call ctx.heartbeat(), or they are failed as retryable when the TTL expires.

Python executors

async def run(ctx: cronoq.TaskContext, payload):
    if ctx.attempt > 1:
        log.info("retrying %s", ctx.id)
    for chunk in work(payload):
        await process(chunk)
        ctx.heartbeat()                 # only needed with lease_ttl
    return result

Plain (non-async) functions run in the event loop's default thread pool. When the engine cancels an attempt (cancellation, timeout, lease loss) the executor's asyncio task is cancelled, so asyncio.CancelledError is raised at the next await.

Remote workers: Python

import os
import cronoq
from cronoq.client import CancelTask, Reject, Worker

async def handle(lease):
    # lease.id, .task_id, .attempt, .payload (bytes), .json(), .text(), .content_type, .kind,
    # .key, .tags, .timeout, .deadline, .pool, .lease_ttl, .cancelled (asyncio.Event)
    if overloaded():
        raise Reject("local GPU busy")              # hand it to another worker
    try:
        return await process(lease.json())          # bytes, str, JSON-serialisable or None
    except TransientError as e:
        raise cronoq.Retryable(str(e), retry_after=5)
    except ValidationError as e:
        raise cronoq.Permanent(str(e))

worker = Worker("cronoq:7070", "default", handle, slots=8,
                token=os.environ["CRONOQ_WORKER_TOKEN"], tls=True, grace=30)
await worker.run()          # until worker.stop()

The worker:

  • runs at most slots handlers at once, or fewer if the server grants fewer (worker.granted_slots);
  • heartbeats every running lease every lease_ttl / 3;
  • cancels the handler's task and sets lease.cancelled when the server aborts an attempt;
  • reconnects with exponential backoff when the stream drops, and right away when the server announces a leadership change;
  • connects to the right shard when the pool belongs to another one (Sharding); pass insecure_redirect_http=True only on trusted networks where shards use plain http://;
  • on stop(), stops taking leases, lets running ones finish for up to grace seconds, then cancels the rest.

Pass labels={"gpu": "a100"} to register the worker with labels: tasks submitted with labels= run only on workers that have them (Worker labels).

Raise Reject to give a lease back unrun: the server offers it to another worker of the pool with the same labels without counting an attempt, or, if no such worker can take it, fails the attempt as retryable. Raise CancelTask to end the task as cancelled. For mTLS, pass credentials=grpc.ssl_channel_credentials(root, key, cert).

Remote workers: Rust

use bytes::Bytes;
use cronoq::TaskError;
use cronoq_server::worker::{run_worker, WorkerOptions, WorkerTls};

let opts = WorkerOptions::new("https://cronoq.internal:7070", "default")
    .slots(8)
    .token(token)
    .name("w1")
    .tls(WorkerTls { ca_pem: Some(ca), identity: None, domain: None })
    .drain_timeout(Duration::from_secs(60));
run_worker(opts, |lease| async move {
    // lease.lease, task_id, attempt, pool, payload, content_type, kind, key, tags,
    // timeout, deadline, cancel (a CancellationToken)
    tokio::select! {
        out = process(&lease.payload) => out.map(Bytes::from).map_err(TaskError::from_err),
        _ = lease.cancel.cancelled() => Err(TaskError::Cancelled),
    }
}, shutdown_signal()).await?;

run_worker enforces the granted slots, heartbeats each lease, cancels lease.cancel on Abort, turns a panicking handler into a retryable failure, reconnects with backoff (initial_backoff 200 ms up to max_backoff 10 s), and when shutdown resolves drains running handlers for up to drain_timeout (30 s). It returns an error only for problems retrying cannot fix, such as a rejected token or an unknown pool. Use identity: Some((cert_pem, key_pem)) for mTLS. TLS settings need an https:// endpoint: with http:// the worker returns an error rather than connecting in plaintext. .label("gpu", "a100") registers the worker with a label. When another shard serves the pool, the worker connects to that shard's endpoint (.insecure_redirect_http(true) allows plain http:// to other hosts).

Go workers

The Go SDK runs workers with cronoq.RunWorker:

err := cronoq.RunWorker(ctx, cronoq.WorkerOptions{
	Target: "http://127.0.0.1:7070", Pool: "default", Slots: 8, Token: token,
}, func(ctx context.Context, l *cronoq.Lease) ([]byte, error) {
	return process(ctx, l.Payload)
})

It enforces the granted slots, heartbeats each lease, ends the handler's context on Abort, turns a panic into a retryable failure, reconnects with backoff, follows its pool to the right shard, and when ctx ends drains running handlers for up to DrainTimeout (30 s).

TypeScript workers

The TypeScript SDK runs workers in Node with runWorker from @synaptiai/cronoq/node:

await runWorker(
  { target: "http://127.0.0.1:7070", pool: "default", slots: 8, token, signal: stop.signal },
  async (lease) => handle(lease.json()),
);

It enforces the granted slots, heartbeats each lease, aborts lease.signal on Abort, reconnects with backoff, follows its pool to the right shard, and when signal aborts drains running handlers for up to drainTimeout (30 s).

Any language over gRPC

Workers in other languages implement one bidirectional stream, cronoq.v1.Workers/Work, from cronoq.proto. Print it with cronoq-server --print-proto and generate stubs with your language's protobuf tooling.

  1. Open the stream with metadata authorization: Bearer <worker token> (or a client certificate).
  2. Send register{pool, slots, worker_name, protocol_version: 3, labels} first. If the stream fails with FAILED_PRECONDITION and cronoq-shard-endpoint metadata, another shard serves the pool: connect there instead, without moving from https:// to http://.
  3. Receive registered{worker_id, lease_ttl_s, slots, protocol_version, max_message_bytes}; run at most the granted slots leases at once.
  4. For each lease:
    • run the task (payload, content_type, kind, key, tags, timeout_s, deadline_unix_ns);
    • send heartbeat{lease} well within lease_ttl_s while it runs;
    • finish with exactly one of complete{lease, output}, fail{lease, message, permanent, retry_after_s}, nack{lease, reason} (give it back) or cancelled{lease}.
  5. On abort{lease, reason}, stop that attempt; its slot is already free.
  6. Send set_slots{slots} to change concurrency; 0 drains the worker.
  7. On shutdown{reason, not_leader}, or when the stream ends, reconnect with backoff.

In-flight leases of a dropped stream are failed as retryable and retried elsewhere. The full rules are in the gRPC reference.

Using the HTTP API

The HTTP gateway exposes the client side of the gRPC API as JSON, so any language with an HTTP client can submit and inspect work. It listens on server.http_addr (default 127.0.0.1:7071). When authentication is enabled, every /v1 route needs authorization: Bearer <token>: the /v1/tasks routes need the client role, and /v1/snapshot, /v1/workers and /v1/recurring need the metrics role (admin works for all of them). Request bodies need content-type: application/json.

export CRONOQ=http://localhost:7071
export AUTH="authorization: Bearer $CRONOQ_CLIENT_TOKEN"
export JSON="content-type: application/json"

Submit

curl -s $CRONOQ/v1/tasks -H "$AUTH" -H "$JSON" -d '{
  "payload": {"prompt": "summarize ..."},
  "pool": "llm",
  "kind": "summarize",
  "priority": "high",
  "key": "summary-42",
  "deadline_in_s": 30,
  "timeout_s": 60,
  "retry": {"max_attempts": 5, "initial_backoff_s": 0.5},
  "resources": {"openai": 1},
  "tags": {"tenant": "acme"}
}'
# {"duplicate":false,"id":"17"}
  • payload is any JSON value (sent to workers as application/json); use payload_b64 for binary data.
  • key makes the submission idempotent: sending it again returns the same id with "duplicate": true. Use it for every request you might retry.
  • Add "wait": true (and optionally "wait_timeout_s") to block until the task finishes and include its result.
  • Unknown fields are rejected, so typos fail loudly instead of being ignored.

Dependencies and batches

curl -s "$CRONOQ/v1/tasks:batch" -H "$AUTH" -H "$JSON" -d '{"tasks": [
  {"payload": "extract", "key": "extract"},
  {"payload": "load", "key": "load", "after": ["extract"]},
  {"payload": "alert", "deps": [{"key": "load", "cond": "on_failure"}]}
]}'
# {"tasks":[{"duplicate":false,"id":"18"},{"duplicate":false,"id":"19"},{"duplicate":false,"id":"20"}]}

after accepts keys (strings) and ids ({"id": "18"} or a number). A batch is all-or-nothing, and its tasks may reference each other in any order. alert runs only if load fails; if load succeeds, alert ends as skipped.

Status, results, cancellation

Ids and keys have separate routes:

curl -s $CRONOQ/v1/tasks/17 -H "$AUTH"
curl -s $CRONOQ/v1/tasks/by-key/summary-42 -H "$AUTH"
# {"attempt":1,...,"id":"17","key":"summary-42","kind":"summarize",...,"pool":"llm",...,"status":"running"}

curl -s "$CRONOQ/v1/tasks/17/result?timeout_s=60" -H "$AUTH"
# {"id":"17","output":{...},"output_available":true,"output_b64":"...","status":"succeeded"}

curl -s -X DELETE $CRONOQ/v1/tasks/by-key/summary-42 -H "$AUTH"
# {"cancelled":true}

Percent-encode keys that contain / (report%2F2024). A result request that times out returns 504 while the task keeps running: ask again.

Scheduled work

# Start in 10 minutes; skip it if it cannot start within 30 s of that time.
curl -s $CRONOQ/v1/tasks -H "$AUTH" -H "$JSON" -d '{
  "payload": {"report": "daily"}, "start_in_s": 600, "tolerance_s": 30, "on_miss": "skip"}'

The server reserves capacity for the start time, and holds a worker slot free shortly before it. Recurring schedules are managed through the gRPC Admin service with an admin token, for example with the Python client's add_recurring.

Operations

curl -s $CRONOQ/v1/snapshot -H "authorization: Bearer $CRONOQ_METRICS_TOKEN"  # pools, waits, adherence, cost
curl -s $CRONOQ/metrics -H "authorization: Bearer $CRONOQ_METRICS_TOKEN"      # Prometheus text
curl -s $CRONOQ/healthz                  # process is up
curl -s $CRONOQ/readyz                   # 200 on the leader, 503 on standbys

Set server.metrics_addr to serve /metrics without a token on a separate, internal port.

Errors

Errors are JSON {"error": "...", "code": "..."}:

StatusWhenRetry?
400Invalid JSON, unknown field or enum value, invalid duration, cycleno
401Missing or invalid tokenno
403Token lacks the role, or the pool is outside its scopeno
404Unknown task, pool, resource or dependencyno
409Fixed-start reservation conflictafter changing the start
413 / 415Body too large / missing content-type: application/jsonno
429Queue fullyes, with backoff
503Not the leader, shutting down, or store unavailableyes, with backoff
504A wait ended before the task finishedask for the result again

Full details: HTTP API reference.

Command-line tool

cronoq talks to a running cronoq-server over gRPC. It is in the release archives next to cronoq-server, in the container image (/usr/local/bin/cronoq), and installs with cargo install cronoq-cli.

Connecting

SettingFlagEnvironmentDefault
Server--server URLCRONOQ_SERVERhttp://127.0.0.1:7070
Token--token-file PATHCRONOQ_TOKENnone
CA bundle--tls-ca PATHsystem roots
Client certificate (mTLS)--tls-cert PATH --tls-key PATHnone
Server name to verify--tls-domain NAMEthe URL's host

There is no --token flag: values on a command line are visible to other users of the machine. Use https:// with TLS; the TLS flags are refused with an http:// server rather than silently ignored, and a token sent over plain http:// to a host other than loopback prints a warning.

Each command needs a role (Security hardening): task commands need client, workers, schedules and snapshot need metrics, and schedule add / schedule remove need admin. An admin token works everywhere.

Commands

cronoq submit "hello"                       # prints the task id
cronoq submit '{"doc": 42}' --json --pool llm --priority high --tenant acme \
    --label gpu=a100 --tag team=search --deadline-in 30 --key doc-42
cronoq submit --file report.pdf --kind ocr --wait > text.txt
cronoq get 17                               # details; --key to look up by key
cronoq get --key doc-42
cronoq wait 17 --timeout 60                 # prints the output
cronoq cancel 17
cronoq tasks --pool llm --status running --status failed --limit 50
cronoq tasks --all                          # follow every page
cronoq workers
cronoq schedules
cronoq schedule add nightly "0 0 3 * * *" '{"report": "daily"}' --json \
    --tz Europe/Berlin --overlap queue --pool reports --key nightly
cronoq schedule remove nightly
cronoq snapshot
cronoq watch --pool llm --all               # every transition, until Ctrl-C

submit sends a string argument as UTF-8 text, --json as JSON (checked first), and --file PATH or - (stdin) as bytes; --content-type overrides the type the worker sees. --after TASK (repeatable, an id or a key) makes the task wait for another to succeed.

wait and submit --wait write the task's output to standard output unchanged when it is redirected to a file or a pipe. Keys, messages and other text come from other clients of the server, so on a terminal, control characters in them (and in outputs) are shown as escapes such as \u{1b} instead of being sent to the terminal.

-o json prints JSON shaped like the HTTP API, with ids and nanosecond times as strings so that tools reading numbers as doubles do not round them.

Exit codes:

CodeMeaning
0Success.
1wait or submit --wait: the task finished without succeeding (the status and message go to standard error).
2The command failed: bad arguments, connection error, or an error from the server such as permission_denied or not_found.

cronoq top

cronoq top refreshes a full-screen view every second (--interval):

  • Pools: capacity, busy slots, pending tasks, waits at the median and 90th percentile, utilization, and tasks succeeded and attempts failed over the metrics window (the last minute).
  • Workers: name, pool, busy and total slots, labels, and when each one connected.
  • Schedules: cron expression, pool and next occurrence.
  • Live tasks: running tasks first, then waiting ones, up to --tasks (200).
  • Recent events: the latest 200 status transitions, newest first, with the task's key and message. The stream reconnects by itself after errors or a leader change.

--pool limits the task and event panes to some pools.

Press q to quit and r to refresh now. The pool, worker and schedule panes need the metrics role, the task and event panes the client role. With a token that has only one of them, the other panes say which role they need; use an admin token to see everything. When the server is a standby or restarting, the panes show the error and recover on the next refresh.

Deploying the server

Container image

Releases publish a multi-architecture image (linux/amd64, linux/arm64) to ghcr.io/synaptiai/cronoq, tagged with the version (0.2.1) and the minor series (0.2). The image is distroless: it contains the cronoq-server binary, the cronoq CLI and the example configuration, runs as a non-root user, and has no shell or curl. Run it with your own configuration:

docker run -p 7070:7070 -p 7071:7071 \
  -v $PWD/cronoq.toml:/etc/cronoq/cronoq.toml:ro \
  -e CRONOQ_CLIENT_TOKEN -e CRONOQ_WORKER_TOKEN -e CRONOQ_METRICS_TOKEN -e CRONOQ_ADMIN_TOKEN \
  ghcr.io/synaptiai/cronoq:0.2.1

To build the image from a checkout instead, run docker build -t cronoq ..

The configuration path comes from --config or CRONOQ_CONFIG (the image sets /etc/cronoq/cronoq.toml). Every token_env the configuration names must be set, or the server refuses to start. Log verbosity follows RUST_LOG (default info). The server stops gracefully on SIGTERM or SIGINT: it stops admitting work, gives running attempts shutdown_grace_s (default 10 s) to finish, then tells workers to reconnect elsewhere.

Validate configuration changes before rolling them out:

docker run --rm -v $PWD/cronoq.toml:/c.toml:ro ghcr.io/synaptiai/cronoq:0.2.1 --config /c.toml --check

Kubernetes

The Helm chart in deploy/helm/cronoq deploys the server with standbys, a store, tokens, probes and, if you use them, shards. Releases publish it, and the operator's chart, to oci://ghcr.io/synaptiai/charts; the examples below install from a checkout (deploy/helm/cronoq), and oci://ghcr.io/synaptiai/charts/cronoq --version 0.2.1 works the same way.

Create the tokens (one key per role) and a Secret with the database URL (postgres://USER:PASSWORD@HOST:5432/DB?sslmode=verify-full, key url), then install:

kubectl create secret generic cronoq-tokens \
  --from-literal=client="$(openssl rand -hex 24)" --from-literal=worker="$(openssl rand -hex 24)" \
  --from-literal=metrics="$(openssl rand -hex 24)" --from-literal=admin="$(openssl rand -hex 24)"
kubectl create secret generic cronoq-db --from-file=url=./database-url
helm install cronoq deploy/helm/cronoq \
  --set auth.existingSecret=cronoq-tokens --set store.postgres.urlSecret.name=cronoq-db

Pools, resources and limits go in config, as the TOML that follows the chart's own tables:

config: |
  [[pools]]
  name = "default"
  max = 50
  lease_ttl_s = 30
  [[pools]]
  name = "llm"
  max = 200
  lease_ttl_s = 120
ValueDefaultDescription
replicas2Server pods (per shard). One leads; the others stand by and take over.
auth.existingSecretrequiredSecret with keys client, worker, metrics and admin, each at least 16 characters. auth.insecureNoAuth: true runs without tokens, on trusted networks only.
store.kindpostgrespostgres, or memory with replicas: 1 (each memory-store pod would lead on its own, so the chart refuses more).
store.postgres.urlSecretrequired{name, key} of the connection URL. schema (default cronoq) holds the tables.
configone poolTOML for [[pools]], [[resources]], [limits] and the rest. Start it with a table header.
shardsnone[{name, number, pools}]; see below.
tls.secretNamenoneA kubernetes.io/tls Secret (tls.crt, tls.key, ca.crt) for both listeners; tls.requireClientCerts turns on mTLS (the probes then only check that the HTTP port accepts connections, since the kubelet has no client certificate).
grpcWeb.allowedOriginsnoneBrowser origins for gRPC-Web.
podDisruptionBudgetmaxUnavailable: 1Per server group.
serviceMonitor.enabledfalseA Prometheus Operator ServiceMonitor scraping /metrics with the metrics token; serviceMonitor.tlsConfig for a private CA or a client certificate.

Every pod is ready as soon as it is up (readiness uses /healthz), so the Service routes to the leader and the standbys, and rolling updates proceed. A standby answers UNAVAILABLE (503 over HTTP) and closes each connection about a second after accepting it, so a client that retries reconnects and the Service picks a pod again until it reaches the leader; workers reconnect on their own. /readyz still answers 200 only on the leader, for your own checks. The pods run as non-root with a read-only root filesystem and no service-account token.

gRPC streams are long-lived. If an ingress or L7 load balancer sits in front, enable HTTP/2 (gRPC) and raise idle timeouts above your longest wait; the server sends HTTP/2 keepalive pings every 20 s (limits.keepalive_interval_s).

Shards in the chart

shards:
  - { name: cpu, number: 1, pools: [default] }
  - { name: gpu, number: 2, pools: [llm] }
store:
  postgres:
    schema: "cronoq_{shard}"   # each shard gets its own tables

Each shard gets its own Deployment, Service (<release>-cronoq-<shard>, or <release>-<shard> when the release name contains cronoq; the shard's endpoint) and disruption budget, from one shared configuration. The main Service reaches every shard's servers, and any server answers for every shard (Sharding).

Servers forward callers' tokens to other shards, and workers follow redirects to them, so shard endpoints must be encrypted or explicitly trusted:

  • with tls.secretName, the endpoints use https:// and the servers verify each other with ca.crt; the certificate must name every shard's Service;
  • without TLS, set insecurePeerHttp: true (trusted cluster networks only); workers then need their own insecure-redirect option (insecure_redirect_http, InsecureRedirectHTTP, insecureRedirectHttp). The chart refuses shards with neither.

Without the chart

Write the configuration yourself (bind to 0.0.0.0 inside the pod, which requires credentials) and run the image with probes on the HTTP port: /healthz for liveness, and for readiness either /healthz (all pods ready, as in the chart) or /readyz (only the leader; rolling updates of a Deployment with standbys then stall, so use strategy: Recreate). With [server.tls], set scheme: HTTPS on the probes.

Durable state on one host with SQLite

For a single machine (a VM, a bare-metal host, a container with a persistent volume), keep state in a file:

[store]
kind = "sqlite"
path = "/var/lib/cronoq/state.db"   # a local filesystem, not NFS
keep_finished_s = 604800            # prune finished tasks after 7 days

A second server process pointed at the same file stands by and takes over when the first stops (at once on a clean shutdown, after the 10 s lease otherwise). Back the file up with SQLite's online backup (sqlite3 state.db ".backup backup.db"), not by copying it while it is in use.

High availability with Postgres

Add a store and run two or more replicas:

[store]
kind = "postgres"
url_env = "DATABASE_URL"   # postgres://...?sslmode=verify-full
keep_finished_s = 604800   # prune finished tasks after 7 days
spec:
  replicas: 2

Standbys poll for the leader lock every 0.5 s. The leader checks its lock session every second and steps down if the session stays unresponsive for 10 s; when its session ends (process exit, pod deletion, a connection terminated by Postgres, or that timeout), a standby takes the lock, recovers all accepted work and becomes ready. Clients retry on UNAVAILABLE / 503; workers receive Shutdown{not_leader} and reconnect. See Durability and HA.

The database role needs permission to create tables in store.schema on first start (public by default; the chart sets cronoq), and to create that schema if it does not exist. Migrations are versioned and applied automatically, under a lock, by whichever replica starts first; replicas of an older version refuse to run against a newer schema.

Scaling workers with the operator

Run workers as their own Deployment (one worker process per pod, with several slots each). The operator in deploy/helm/cronoq-operator sets each Deployment's replicas from its pool's desired capacity. Install it once per cluster, naming the servers it may read and a token with the metrics role for each:

helm install cronoq-operator deploy/helm/cronoq-operator \
  --set 'servers[0].name=main' --set 'servers[0].url=http://cronoq.cronoq:7070' \
  --set 'servers[0].tokenSecret.name=cronoq-tokens' --set 'servers[0].tokenSecret.key=metrics'

Then add a scaler next to each worker Deployment:

apiVersion: cronoq.io/v1alpha1
kind: CronoqWorkerScaler
metadata: { name: default-workers }
spec:
  server: main              # a server from the operator's configuration
  pool: default
  deployment: default-workers
  slotsPerReplica: 4        # the slots each worker pod registers
  minReplicas: 0
  maxReplicas: 100

Every intervalSeconds (15 by default) the operator reads the pool's desired capacity in slots and sets the Deployment's replicas to ceil(desired / slotsPerReplica), clamped to [minReplicas, maxReplicas], through its scale subresource. When the server cannot be reached, or does not report the pool (for example while its shard is unreachable), the operator leaves the Deployment alone; kubectl get cronoqworkerscalers shows the desired slots, the replicas set and why. Give each Deployment one scaler, and no other autoscaler.

ValueDefaultDescription
serversrequired[{name, url, tokenSecret: {name, key}, caSecret: {name, key}}]. The Secrets are in the operator's namespace and are mounted into its pod.
intervalSeconds15How often each scaler is reconciled.
image, resources, nodeSelector, ...As usual.

The server URLs and tokens live in the operator's configuration, so a scaler can only name a configured server; the operator has no access to Secrets through the API. Its ClusterRole allows reading scalers, writing their status, and reading and changing deployments/scale. Anyone who may create a scaler in a namespace can therefore have the operator scale any Deployment in that namespace, within the scaler's bounds: grant cronoqworkerscalers only to those who may scale the Deployments there. A scaler's status also shows the desired slots of the pool it names, read with the operator's metrics token, so such users can read that counter for any pool on the configured servers.

Autoscaling workers with KEDA

Without the operator, scale a worker Deployment from the server's desired-capacity metric:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: cronoq-default-workers }
spec:
  scaleTargetRef: { name: default-workers }
  minReplicaCount: 0
  maxReplicaCount: 100
  pollingInterval: 5
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring:9090
        # desired slots ÷ slots per pod
        query: ceil(max(cronoq_pool_desired_workers{pool="default"}) / 4)
        threshold: "1"

cronoq_pool_desired_workers already accounts for queue pressure, wait-time objectives and upcoming fixed-start reservations, and is smoothed against flapping. Alternatively, set scaler = { kind = "webhook", url = "...", token_env = "..." } on the pool and have your own service create workers when it receives {"pool": ..., "desired": n}.

Give worker pods a terminationGracePeriodSeconds longer than their drain timeout, so running leases finish before the pod is killed.

Sizing

The scheduler itself is light: one core comfortably handles hundreds of thousands of task transitions per second. Memory is dominated by pending tasks (a few hundred bytes plus the payload each), the output cache ([outputs], 64 MiB by default) and the task-info cache. With a durable store, the leader writes the mutations queued since its last commit in one transaction; replies wait for that commit.

Checklist

  • One token per role and consumer, delivered through secrets (token_env), scoped to pools.
  • TLS on both listeners (or a service mesh), and mTLS for workers if required.
  • /healthz for liveness; for readiness /healthz with rolling updates (standbys answer UNAVAILABLE, which clients retry) or /readyz (only the leader) with strategy: Recreate.
  • Prometheus scraping /metrics with a metrics token or on metrics_addr; alerts on dead letters, wait times and missing workers.
  • max_pending and overflow set on pools fed by untrusted producers.
  • sslmode=verify-full in the database URL.
  • Workers drain on shutdown, with a termination grace period to match.
  • cronoq-server --check in CI for configuration changes.

Observability

cronoq tells you not only how busy it is, but how well it is scheduling: whether priorities are respected, whether fixed starts are on time, and how long work waits. This guide shows how to collect those signals; Metrics and quality signals explains how to read them.

Prometheus (server)

GET /metrics serves Prometheus text built from the scheduler snapshot at scrape time. On the main HTTP port it requires a token with the metrics (or admin) role:

scrape_configs:
  - job_name: cronoq
    authorization: { credentials_file: /etc/prometheus/cronoq-metrics-token }
    static_configs: [{ targets: ["cronoq:7071"] }]

If your scraper cannot send a token, set server.metrics_addr = "0.0.0.0:9090" and scrape that port instead; it serves /metrics, /healthz and /readyz without authentication or TLS, so keep it on an internal network.

Standbys answer 503; scrape every replica and the leader's series are the live ones. Every metric is listed in the metrics reference.

OpenTelemetry (library)

With the default otel feature, the cronoq crate records metrics through the globally installed OpenTelemetry meter provider and propagates trace context through the global text-map propagator. Configure both once at startup:

use opentelemetry::global;
use opentelemetry_sdk::{metrics::SdkMeterProvider, propagation::TraceContextPropagator};

global::set_text_map_propagator(TraceContextPropagator::new());
let meter_provider = SdkMeterProvider::builder()
    .with_periodic_exporter(opentelemetry_otlp::MetricExporter::builder().with_tonic().build()?)
    .build();
global::set_meter_provider(meter_provider);
// Build the scheduler after this, so its instruments use the provider.

Without a provider the instruments cost almost nothing. Disable the feature (default-features = false) to drop the OpenTelemetry dependencies entirely.

Traces

Each attempt runs inside a tracing span named cronoq.task with fields task.id, task.pool, task.kind and task.attempt. When a task is submitted inside a span and a propagator is installed, its W3C traceparent is stored as a tag; each attempt's span is then parented to the submitter's trace (with tracing-opentelemetry installed), so retries show up under the request that caused them. Remote workers receive the tag in lease.tags["traceparent"] and can continue the trace.

Snapshots and events

For dashboards inside your application, read snapshots directly:

snap = await sched.snapshot()
llm = snap.pool("llm")
print(llm.capacity, llm.busy, llm.wait_p99, llm.priority_adherence, snap.totals["dead_lettered"])

and subscribe to status events for audit logs or user notifications:

async with sched.events() as events:
    async for e in events:
        if e.status == "dead_lettered":
            await alert(e.id, e.message)

The server equivalents are GET /v1/snapshot / Admin.Snapshot and the Tasks.Watch stream.

Key alerts

AlertExpression (Prometheus)Why
Dead lettersincrease(cronoq_tasks_total{status="dead_lettered"}[10m]) > 0Tasks failing every retry.
Queue waitcronoq_pool_wait_seconds{quantile="0.99"} > 30Users or SLAs waiting.
Under-capacitycronoq_pool_desired_workers > cronoq_pool_workers for 15 mThe scaler is not delivering workers.
No workerssum by (pool) (cronoq_pool_pending) > 0 and on (pool) cronoq_pool_workers_connected == 0 for 5 mWork is queued and nothing can run it.
Fixed starts latecronoq_pool_fixed_start_hit_rate < 0.95Scheduled jobs starting late.
Sheddingincrease(cronoq_tasks_total{status="shed"}[5m]) > 0Overload is dropping work.
Lease expiriesincrease(cronoq_lease_expiries_total[10m]) > 0Workers are dying or not heartbeating.
No leadermax(probe_success{job="cronoq-readyz"}) == 0 (blackbox probe of /readyz on every replica)No replica is serving.

Logs

The server logs with tracing to stdout; set RUST_LOG (for example RUST_LOG=info,cronoq_server=debug). Notable events: listening addresses, leadership acquired or lost, worker registration and disconnection (with granted slots), store outages and retries, failed scale requests, and configuration warnings such as short tokens or a database URL without sslmode=verify-full.

Security hardening

This guide is the checklist for running cronoq-server outside a laptop. The threat model (what the server protects and what it does not) is in SECURITY.md.

Roles

RoleCanCannot
clientSubmit, batch, cancel, get, wait, watch (Tasks, /v1/tasks*)Serve pools, read metrics, manage schedules
workerOpen Workers/Work streams and receive leases of its pools; report outcomes for its own leasesSubmit or read tasks
metricsAdmin.Snapshot, Admin.ListWorkers, Admin.ListRecurring; /v1/snapshot, /v1/workers, /v1/recurring, /metricsAnything that touches tasks
adminEverything, including Admin.AddRecurring / RemoveRecurring–
no credentials/healthz, /readyz, and /metrics on metrics_addrAnything else

Every token or certificate mapping can be limited to pools = [...]. Scoped credentials cannot submit into, serve, watch or look up tasks of other pools; those tasks look like they do not exist. Keys, however, are one namespace across pools (see the threat model).

Checklist

  1. Configure credentials. Without [[auth.tokens]] or [[auth.certs]], authentication is off: the server then refuses non-loopback addresses unless you pass --insecure-no-auth, and its HTTP API answers only requests whose host is localhost, *.localhost, 127.0.0.1 or [::1], which stops web pages that rebind their name to 127.0.0.1 (the gRPC listener does the same when gRPC-Web is on). Create one token per role and per consumer, scoped to the pools it needs:

    [[auth.tokens]]
    token_env = "CRONOQ_LLM_WORKER_TOKEN"
    role = "worker"
    pools = ["llm"]
    
  2. Keep tokens out of files. Use token_env and inject secrets from your platform. Tokens need at least 16 characters and must be unique; use 32+ random characters (openssl rand -hex 16). The server keeps only SHA-256 digests and never logs tokens.

  3. Encrypt traffic. Configure [server.tls] with cert and key; it covers both the gRPC and the HTTP listener. Bearer tokens over plaintext are acceptable only on a trusted local network or inside a service mesh that encrypts. Workers given TLS settings must use an https:// endpoint; they refuse an http:// one instead of connecting in plaintext.

  4. Use mTLS where you can. With client_ca, every client must present a certificate signed by that CA. Map certificates to roles by DNS subject alternative name, so workers need no bearer token at all:

    [server.tls]
    cert = "/etc/cronoq/tls/server.pem"
    key = "/etc/cronoq/tls/server.key"
    client_ca = "/etc/cronoq/tls/ca.pem"
    
    [[auth.certs]]
    dns_name = "llm-worker.internal.example.com"
    role = "worker"
    pools = ["llm"]
    

    A bearer token, when present, takes precedence over the certificate's mapping.

  5. Keep metrics internal. /metrics on http_addr requires the metrics role. If your scraper cannot send a token, set metrics_addr and bind it to an internal interface: that listener has no TLS and no authentication.

  6. Bind deliberately. The defaults are 127.0.0.1:7070 and 127.0.0.1:7071. Listen on 0.0.0.0 only behind a firewall, security group or network policy.

  7. Bound everything. Keep the [limits] defaults or lower them, and set max_pending and overflow on pools fed by less trusted producers, so one client cannot exhaust memory. max_slots_per_worker caps how many leases a single worker can hold.

  8. Protect the database. Read the URL from url_env (default DATABASE_URL), use sslmode=verify-full for a remote database, and grant the server's role privileges on its own schema only. Put the tables in a dedicated schema (store.schema) that only the server's role can create objects in; if you keep public on a database created before PostgreSQL 15, run REVOKE CREATE ON SCHEMA public FROM PUBLIC. The server refuses existing cronoq tables owned by another role. PostgreSQL holds payloads and outputs in clear text: use encryption at rest or encrypt sensitive payloads in your application.

  9. Run the provided image. It is distroless, runs as a non-root user and has no shell. Validate configuration changes in CI with cronoq-server --config cronoq.toml --check.

  10. Treat workers as trusted with their pools. A worker sees every payload of the pools it serves and decides their outputs. Give each worker fleet credentials for its own pools only, and, where workers carry labels, a labels = [...] allow-list so a credential cannot claim labels of another fleet.

  11. Give browser dashboards narrow tokens. Enable [server.grpc_web] only for the exact origins of your dashboards. A token held by a page can be read by any script on that page, so give it the least role it needs (often metrics), scoped to the pools it shows, and serve the page over HTTPS with TLS on the server.

Reporting vulnerabilities

See SECURITY.md. Please report privately through GitHub Security Advisories.

Performance tuning

What to expect

PathTypical cost
Engine decision (submit, dispatch or complete)under 1 µs
Engine cycle (submit → dispatch → complete), one core~1.0–1.2 M/s
End-to-end in-process, no-op tasks, bulk submit~250 k tasks/s
Sequential submit().await then handle.await~70 µs round trip

Choosing the next task looks only at the heads of a few sorted queues, so its cost grows at most with the logarithm of the backlog, with no rescans (see Architecture). Re-queuing a retried or woken task into a very long lane also moves the entries behind it.

Tuning the library

  • Submit in bulk. enqueue_many(Vec<Task>) admits thousands of tasks in one channel round trip; individual submit calls each wait for a reply.
  • Skip handles you do not need. enqueue is cheaper than submit (no result waiter).
  • Size the command queue. Builder::queue_capacity(n) (default 8192) should cover bursts.
  • Disable telemetry you do not export. Build with default-features = false to remove OpenTelemetry instrumentation.
  • Keep executors async. A blocking executor holds a tokio worker thread; move CPU-heavy work to spawn_blocking or a dedicated pool.
  • Prefer fewer, larger pools. Each pool adds queues and an autoscaling timer; resources can express most sharing needs.
  • Retention. Terminal statuses are kept for an hour (up to 1 M entries) for dependencies and deduplication. Lower CoreConfig::retention / retention_max for very high-rate workloads that do not need them.

Tuning the server

  • Give workers enough slots that per-lease round trips are amortized; heartbeats are cheap.
  • With a durable store (SQLite or Postgres), commit latency bounds how fast the engine can acknowledge submissions in a single step. Batched submissions (SubmitBatch, /v1/tasks:batch) share one commit.
  • Keep the database close (same region/zone) to the server.

Measuring

cargo bench -p cronoq-core --bench core      # engine
cargo bench -p cronoq --bench runtime        # end to end
cargo run --release -p cronoq --example firehose -- 1000000

Shared or virtualized machines are noisy; compare instruction counts (valgrind --tool=callgrind on crates/cronoq-core/examples/prof.rs) when optimizing.

Testing your executors

Rust: paused time

The runtime uses tokio's clock, so tests with paused time run schedules, timeouts and backoffs instantly and deterministically:

#[tokio::test(start_paused = true)]
async fn retries_then_succeeds() {
    let s = Scheduler::<u64, u64>::builder()
        .pool(Pool::fixed("default", 1).executor_fn(|ctx, x: Arc<u64>| async move {
            if ctx.attempt < 3 { Err(TaskError::retryable("flaky")) } else { Ok(*x) }
        }))
        .build()
        .await
        .unwrap();
    let h = s.submit(Task::new(7)).await.unwrap();
    assert_eq!(*h.await.unwrap(), 7);   // backoffs elapse in virtual time
}

Fixed starts and cron schedules work the same way: tokio::time::sleep(Duration::from_secs(60)) inside a paused test advances the scheduler's clock by a minute without waiting.

Rust: the engine directly

cronoq-core is a pure state machine. Drive it with explicit times and inspect the actions it emits; this is how cronoq's own deterministic simulation tests work:

use cronoq_core::*;

let mut core: Core<&str> = Core::new(CoreConfig::default());
core.submit(Time::ZERO, TaskSpec::new("a").priority(Priority::High)).unwrap();
let Some(Action::Dispatch(d)) = core.poll_action() else { panic!() };
core.complete(Time::from_secs_f64(0.5), d.lease, Outcome::Success);
core.check_invariants();

Python

Run executors through a real scheduler inside an asyncio test (pytest-asyncio):

import cronoq, pytest

@pytest.mark.asyncio
async def test_executor_retries():
    calls = 0
    async def run(ctx, x):
        nonlocal calls
        calls += 1
        if ctx.attempt < 2:
            raise cronoq.Retryable("flaky")
        return x * 2
    retry = cronoq.RetryPolicy(max_attempts=3, initial_backoff=0.01, jitter=False)
    async with cronoq.Scheduler([cronoq.Pool("default", run, retry=retry)]) as s:
        assert await (await s.submit(21)) == 42
    assert calls == 2

Against a server

Start cronoq-server with a temporary config on free ports (grpc_addr = "127.0.0.1:0" works when you embed it with cronoq_server::run), connect a test worker, and submit through the client. The repository's crates/cronoq-server/tests/server.rs and crates/cronoq-py/python/tests/test_client.py are complete examples.

run reports the addresses it bound once the server is the leader. When a test needs them earlier, for example to reach a standby, bind the listeners on port 0 yourself and pass them to cronoq_server::run_with_listeners. Choosing a free port, closing it, and putting its number in the config is not safe when tests run in parallel: another process can take the port before the server binds it. A server started as a separate process has to bind its ports itself, so retry the start on new ports if it exits with "Address already in use".

Server configuration

cronoq-server reads one TOML file, given with --config <path> (or the CRONOQ_CONFIG environment variable). Without a file it starts with defaults: one default pool, in-memory state, no auth tokens and listeners on 127.0.0.1 only. Unknown keys are rejected and every value is validated at startup, so typos and out-of-range numbers fail fast instead of at runtime.

cronoq-server --config cronoq.toml --check   # validate and exit
cronoq-server --config cronoq.toml           # run
cronoq-server --print-proto                  # print the gRPC API definition
FlagDescription
-c, --config <path>TOML file (env CRONOQ_CONFIG).
--checkValidate the configuration and exit (configuration is valid).
--insecure-no-authAllow running without auth on non-loopback addresses (same as server.insecure_no_auth). Trusted networks only.
--print-protoPrint cronoq/v1/cronoq.proto and exit.
--shard NAMEThe shard this server serves (also CRONOQ_SHARD; overrides server.shard).

The log level comes from RUST_LOG (default info).

An annotated example lives in deploy/cronoq.example.toml; it is the configuration baked into the container image.

Durations are seconds (floats). They must be finite and between their minimum and one year.

[server]

KeyTypeDefaultDescription
grpc_addrsocket address"127.0.0.1:7070"gRPC listener (Tasks, Workers, Admin). Port 0 picks a free port.
http_addrsocket address"127.0.0.1:7071"HTTP listener (/v1/*, /metrics, /healthz, /readyz).
metrics_addrsocket address, optionalunsetExtra unauthenticated listener serving only /metrics, /healthz and /readyz. Bind it to an internal network for scrapers. Without it, /metrics on http_addr needs a metrics or admin token.
insecure_no_authboolfalseAllow starting with no tokens and no certificate mappings while listening on a non-loopback address. Without it the server refuses to start in that situation.
shutdown_grace_sfloat ≥ 010On shutdown (or loss of leadership), running attempts may finish for this long before they are aborted.
shardstring, optionalunsetWith [[shards]], the shard this server serves. Usually set per deployment with --shard or CRONOQ_SHARD, so all shards share one file.
insecure_peer_httpboolfalseForward callers' tokens to other shards over plain http:// endpoints on other hosts. Without it, such calls are refused with the shard's endpoint. Trusted networks only.

[server.tls]

Optional. Enables TLS on both the gRPC and the HTTP listener (the optional metrics listener stays plain HTTP). Files are read once at startup; a missing file is reported with its path.

KeyTypeDescription
certpathPEM certificate chain.
keypathPEM private key.
client_capath, optionalRequire client certificates signed by this CA on both listeners (mutual TLS). Needed for [[auth.certs]].

[server.peer_tls]

Optional. How this server verifies other shards' https:// endpoints when it forwards calls (Sharding).

KeyTypeDescription
capath, optionalPEM CA bundle for the other shards' certificates (default: the system roots).
cert, keypaths, optionalClient certificate and key, for shards that require mTLS. Both or neither; only allowed when this server has credentials configured, so that forwarded calls always carry the caller's token.

[server.grpc_web]

Optional. Serves gRPC-Web for browser clients on the gRPC listener, alongside native gRPC. Without this table the listener speaks only HTTP/2 gRPC.

KeyTypeDescription
allowed_originslist of 1–64 stringsOrigins whose pages may call the API, each exactly scheme://host[:port] (http or https; no path, no wildcard), e.g. ["https://dash.example.com"]. Other origins get no CORS headers, so browsers block their requests.

The origin list only controls which pages a browser lets call the server; every call still needs credentials with the right role, as over native gRPC.

[auth]

Callers authenticate with authorization: Bearer <token> (gRPC metadata or HTTP header), or with a client certificate mapped to a role. Roles:

RoleMay
clientSubmit, inspect, wait for, cancel and watch tasks (Tasks service, /v1/tasks*).
workerOpen the Workers.Work stream and serve leases.
metricsRead snapshots, workers, recurring schedules and metrics (Admin.Snapshot, Admin.ListWorkers, Admin.ListRecurring, /v1/snapshot, /v1/workers, /v1/recurring, /metrics).
adminEverything, including recurring schedules (Admin.AddRecurring, Admin.RemoveRecurring).

/healthz and /readyz never need credentials.

[[auth.tokens]]

[[auth.tokens]]
token_env = "CRONOQ_CLIENT_TOKEN"
role = "client"
pools = ["llm"]            # optional
KeyTypeDescription
tokenstringThe bearer token. Prefer token_env.
token_envstringRead the token from this environment variable (it must be set at startup).
roleclient | worker | metrics | adminWhat the token may do.
poolslist of pool names, optionalRestrict a client or worker token to these pools. Tasks in other pools behave as if they did not exist (NOT_FOUND), Watch filters them out, and workers may only register for these pools. Submitting a key already used in another pool fails with ALREADY_EXISTS, and dependencies on tasks of other pools are refused.
tenantstring, optionalTenant of every task submitted with a client token: tasks without a tenant get it, and naming another tenant is refused (PERMISSION_DENIED). See Fair sharing.
labelslist of "name=value", optionalLabels a worker token may claim when it registers; a worker claiming any other label is refused (PERMISSION_DENIED). Without it, a worker may claim any labels. See Worker labels.

Exactly one of token / token_env is required. Tokens must be at least 16 characters (the server warns below 32; use 32+ random characters, e.g. openssl rand -hex 24) and unique. Tokens are stored as SHA-256 digests and compared in constant time; Debug output and logs never contain them.

[[auth.certs]]

Map mTLS client certificates to roles by an exact DNS subject alternative name. Requires server.tls.client_ca.

[[auth.certs]]
dns_name = "worker-1.internal"
role = "worker"
pools = ["gpu"]            # optional
KeyTypeDescription
dns_namestringDNS SAN to match exactly.
roleroleRole granted to that certificate.
poolslist, optionalPool allow-list, as for tokens.
labelslist, optionalWorker-label allow-list, as for tokens.
tenantstring, optionalTenant pinned to the certificate, as for tokens.

A bearer token, when present, takes precedence over the certificate.

[store]

[store]
kind = "postgres"
url_env = "DATABASE_URL"
keep_finished_s = 604800
KeyTypeDefaultDescription
kindmemory | postgres | sqlitememorymemory keeps all state in the process (lost on restart). postgres and sqlite make state durable and enable active-passive HA (sqlite: processes on one host).
urlstring–postgres only. Connection URL. Prefer url_env; the server warns about inline URLs and, unless the host is local, about URLs that set neither sslmode=verify-full nor sslmode=verify-ca (sslmode=require also gets the warning).
url_envstringDATABASE_URLpostgres only. Read the URL from this environment variable. Set url or url_env, not both.
keep_finished_sfloat ≥ 60604800 (7 days)Finished tasks older than this are pruned hourly (in batches; tasks that live tasks depend on are kept).
pathpath–sqlite only, required: the database file (created if missing, owner-only permissions). Keep it on a local filesystem.
schemastringpublicpostgres only. PostgreSQL schema for the server's tables (letters, digits and _; created if missing). Servers sharing a schema form one cluster; different schemas are independent.

The server refuses to start if the schema already holds cronoq tables owned by a role other than the one it connects as (or a superuser). Prefer a dedicated schema that only the server's role can create objects in (see Security hardening).

[limits]

Request and resource limits. The defaults are safe for a shared deployment; every key is optional.

KeyTypeDefaultDescription
max_tasks_per_batchint ≥ 11000Tasks per SubmitBatch / POST /v1/tasks:batch.
max_pending_totalint ≥ 11000000Pending (not yet running) tasks across all pools.
max_pending_per_poolint ≥ 1100000Default pending bound per pool (override with pools.max_pending).
max_message_bytesint ≥ 409616777216 (16 MiB)Largest gRPC message accepted or sent. Payloads and worker outputs travel in these; a worker output above it fails the attempt permanently instead of dropping the worker.
max_http_body_bytesint ≥ 409616777216Largest HTTP request body.
max_key_lenint ≥ 1256Longest task key.
max_kind_lenint ≥ 1128Longest task kind.
max_tagsint32Most tags per task.
max_tag_lenint ≥ 11024Longest tag key or value.
max_depsint256Most dependencies per task.
max_resourcesint16Most resource entries per task.
max_wait_sfloat ≥ 0300Longest server-side wait for a result (Wait, Submit{wait}, GET …/result). Longer requested waits are capped; when it expires the caller gets DEADLINE_EXCEEDED / HTTP 504 and can wait again.
request_timeout_sfloat ≥ 0.130Request time limit. Every gRPC call gets request_timeout_s + max_wait_s. Over HTTP, waiting routes (/v1/tasks and paths ending in /result) get request_timeout_s + max_wait_s and other routes get request_timeout_s.
max_concurrent_requestsint ≥ 14096On the gRPC listener, requests processed concurrently (excess requests queue). On the HTTP listener, open connections; further connections are refused.
max_concurrent_streamsint ≥ 11024HTTP/2 streams per connection.
max_slots_per_workerint ≥ 11024Slots a single worker may register (also capped by the pool's max).
keepalive_interval_sfloat ≥ 0.120HTTP/2 keepalive ping interval; detects half-open workers and clients.
keepalive_timeout_sfloat ≥ 0.120HTTP/2 keepalive ping timeout.
key_retention_sfloat ≥ 03600How long the key of a finished task keeps deduplicating submissions (a resubmission returns the finished task and its output), and how far back a new leader loads finished tasks from the store.
key_retention_maxint1000000Most finished tasks kept for key deduplication (bounds memory).
max_listint, 1–1000001000Most tasks one ListTasks or GET /v1/tasks page returns; larger requested limits are capped.

Strings in task specs (keys, kinds, tags, resource names, worker messages) must not contain NUL characters; worker failure messages are sanitized (NUL replaced, cut at 8 KiB) instead of rejected.

[[pools]]

Server pools are served by remote workers: a pool's capacity is the number of slots its connected workers registered. Defaults to a single pool named default.

[[pools]]
name = "llm"
max = 200
lease_ttl_s = 120
overflow = "reject"
retry = { max_attempts = 5, initial_backoff_s = 2, max_backoff_s = 120 }
KeyTypeDefaultDescription
namestringrequired1–64 characters from [A-Za-z0-9_.:-], unique.
minint ≤ max0Lower bound on the autoscaler's desired capacity: the desired capacity never drops below it. Capacity itself still comes only from connected workers.
maxint ≥ 110Autoscaler upper bound, basis for reservation capacity, and a cap on slots per worker.
autoscalebooltrueCompute a desired capacity from queue pressure (exported as cronoq_pool_desired_workers and sent to the scaler).
scalertable{ kind = "external" }How the desired capacity is communicated, see below.
lease_ttl_sfloat ≥ 0.130Workers must heartbeat each lease within this many seconds, or the attempt is treated as lost and retried.
max_pendingint ≥ 1limits.max_pending_per_poolPending-task bound for this pool.
overflowwait | reject | shed_lowest | shed_oldestrejectBehaviour when the bound is reached (see Backpressure). reject answers RESOURCE_EXHAUSTED / HTTP 429.
retrytableengine default (3 attempts, 1 s ×2 up to 60 s, jitter)Default retry policy for the pool, see below.
default_duration_sfloat ≥ 01Duration estimate before any run time has been observed.
reservable_fractionfloat in (0, 1]0.5Share of max that overlapping fixed-start reservations may occupy (at least one slot).
await_unknown_dependencies_sfloat ≥ 0unsetAllow dependencies on keys that do not exist yet; such tasks wait up to this long, then end skipped. Unset: unknown keys are rejected.
target_utilizationfloat in (0, 1]0.75Autoscaler utilization target.
drain_time_sfloat > 030Autoscaler: clear the backlog within this time.
cooldown_sfloat ≥ 030Autoscaler: minimum time between scale-down steps.
cost_per_worker_secondfloat ≥ 00Cost per slot-second, for cronoq_pool_cost_total.
max_slots_per_workerint ≥ 1limits.max_slots_per_workerSlots one worker may register in this pool (never above max). Workers asking for more are granted the cap.
fair_shareboolfalseShare the pool fairly between tenants with equal weights (implied by tenant_weights).
tenant_weightstable of tenant = weight{}Share the pool fairly between tenants with these weights (each finite and > 0; others weigh 1). See Fair sharing.

scaler

FormDescription
{ kind = "external" }Capacity follows connected workers; use the desired-workers metric with KEDA/HPA or your own controller.
{ kind = "webhook", url = "https://…", token_env = "SCALER_TOKEN" }POST {"pool": "...", "desired": n} to url whenever the target changes. token_env (optional) must be set if given; it is sent as a bearer token. HTTPS certificates are verified against the operating system's trust store. The server warns about http:// URLs.

retry

KeyTypeDefaultDescription
max_attemptsint ≥ 1requiredAttempts including the first.
initial_backoff_sfloat ≥ 01First backoff.
multiplierfloat in [1, 1000]2Backoff growth factor.
max_backoff_sfloat ≥ 060Backoff ceiling.
jitterbooltrueFull jitter (uniform in [0, backoff]).

[[resources]]

Shared limits that tasks request by name (resources = {"openai": 1}).

[[resources]]
kind = "token_bucket"
name = "openai"
rate = 50
burst = 100

[[resources]]
kind = "semaphore"
name = "gpu"
permits = 8
KeyKindsDescription
kind–semaphore or token_bucket.
nameboth1–64 characters from [A-Za-z0-9_.:-], unique.
permitssemaphoreUnits held concurrently (≥ 1); released when each attempt ends.
ratetoken_bucketRefill per second (> 0).
bursttoken_bucketBucket size (≥ 1).
shardbothWith [[shards]], required: the shard that owns the resource. Only that shard's tasks may use it; others are refused with INVALID_ARGUMENT. Without shards it must be unset.

[[shards]]

Optional. Splits the pools between shards, each served by its own leader and standbys with a store of its own (Sharding). Every shard's servers use the same list and pick their own with server.shard.

KeyTypeDescription
namestring1–32 lower-case letters, digits and _, starting with a letter.
numberint, 0–255Unique; carried in the top 8 bits of the shard's task ids. Do not change it once the shard has tasks.
poolslist of pool namesThe pools this shard serves. Every [[pools]] entry belongs to exactly one shard.
endpointURLhttp:// or https:// address that reaches the shard's leader (for example a Kubernetes service in front of its replicas). Other servers forward calls for the shard to it, and name it when they cannot forward a call.

With shards, each shard keeps its own state: a Postgres store.schema must contain {shard} (default cronoq_{shard}), and a SQLite store.path must contain {shard}; the server fills in its shard's name. Each resource belongs to one shard (its shard key), because shards do not share counts: a resource shared by two shards would have its full capacity in each. [limits] apply to each shard separately.

[outputs]

Recent outputs and task records, used by Wait/Get after a task finished. With a durable store (postgres or sqlite), outputs that are no longer cached are read from the database.

KeyTypeDefaultDescription
cache_entriesint10000Most outputs kept in memory (0 disables the cache).
cache_bytesint67108864 (64 MiB)Most output bytes kept in memory. Must be positive while the cache is enabled.
info_entriesint100000Most task records kept for Get (pool, kind, attempts, timestamps, last error).

Validation summary

--check (and every startup) rejects:

  • unknown keys, duplicate pool or resource names, invalid names;
  • non-finite, negative or out-of-range numbers (durations, fractions, multipliers, rates, tenant weights), and a pool min above its max;
  • [[auth.tokens]] entries without exactly one of token/token_env, duplicate literal token values, pool allow-lists naming unknown pools;
  • [[auth.certs]] without server.tls.client_ca;
  • server.grpc_web.allowed_origins that is empty, has more than 64 entries, or holds anything other than exact origins;
  • both store.url and store.url_env;
  • [[shards]] with duplicate names, numbers or endpoints, unknown pools, pools in no shard or in two, a missing or unknown server.shard, a store namespace without {shard}, or a resource without a known shard.

Startup also rejects the following, which --check does not test because they depend on the environment:

  • a token_env variable that is not set;
  • tokens shorter than 16 characters, and duplicate tokens (including tokens read from token_env);
  • non-loopback listeners with no credentials, unless insecure_no_auth is set.

HTTP API

The HTTP gateway exposes the Tasks service of the gRPC API as JSON, except the Watch stream, plus the read-only Admin calls, metrics and health probes. It listens on server.http_addr (default 127.0.0.1:7071) and serves HTTP/1.1 and HTTP/2 (h2c without TLS, ALPN with TLS). With [server.tls] configured it serves HTTPS and accepts client certificates on the same terms as the gRPC listener.

Remote workers cannot use HTTP; they need the gRPC worker stream. Recurring schedules are managed over gRPC (Admin.AddRecurring) or in the embedding runtime.

Conventions

  • Authentication. Send authorization: Bearer <token>, or connect with a client certificate mapped in [[auth.certs]]. The admin role passes every check. Without any [[auth.tokens]] or [[auth.certs]], authentication is off and every request is treated as admin; the server only starts that way on loopback addresses unless --insecure-no-auth is given. In that mode, unless --insecure-no-auth is given, a request whose Host is not a loopback name or address (localhost, *.localhost, any 127.x.x.x, [::1]) gets 403.
  • Pool scope. A token or certificate with pools = [...] may only submit into those pools. Tasks in other pools look like they do not exist (404), so their ids and keys do not leak. Submitting a key that is already used by a task in a pool outside the scope returns 409.
  • Tenant. Credentials with a tenant submit for that tenant: tasks without a tenant get it, and naming another tenant returns 403.
  • Request bodies must be JSON with content-type: application/json (otherwise 415) and at most limits.max_http_body_bytes (default 16 MiB, otherwise 413). Unknown fields are rejected with 400, so a typo such as "priorty" never passes silently.
  • Task ids are strings in every JSON response ("id": "17"), because they are 64-bit and JavaScript numbers lose precision above 2^53. Requests accept ids as strings or numbers.
  • Timestamps (*_unix_ns) are Unix nanoseconds, returned as strings for the same reason. Durations (*_s) are seconds as JSON numbers and must be finite and non-negative.
  • Every request has a time limit of limits.request_timeout_s (default 30 s). Every request to /v1/tasks (POST and GET) and to every path ending in /result gets limits.max_wait_s (default 300 s) on top. A request that runs out of time returns 504.
  • Only the leader serves. A standby (or a leader that is shutting down) answers every /v1 route and /metrics with 503 not the leader (standby or shutting down). Put the servers behind a load balancer that routes on /readyz.

Routes

Method and pathRoleDescription
POST /v1/tasksclientSubmit one task; optionally wait for its result.
GET /v1/tasksclientList tasks, live and finished, one page at a time.
POST /v1/tasks:batchclientSubmit up to max_tasks_per_batch tasks atomically.
GET /v1/tasks/{id}clientTask details by numeric id.
DELETE /v1/tasks/{id}clientCancel a task by id.
GET /v1/tasks/{id}/resultclientWait for a task's result by id.
GET /v1/tasks/by-key/{key}clientTask details by key.
DELETE /v1/tasks/by-key/{key}clientCancel a task by key.
GET /v1/tasks/by-key/{key}/resultclientWait for a task's result by key.
GET /v1/snapshotmetricsPer-pool state, quality signals and totals.
GET /v1/workersmetricsConnected remote workers.
GET /v1/recurringmetricsRecurring schedules.
GET /metricsmetricsPrometheus text exposition (metrics reference).
GET /healthznone200 ok while the process runs (liveness).
GET /readyznone200 ready on the leader, 503 standby otherwise (readiness).

{id} must be a decimal task id; anything else returns 400 and points to the by-key route. Keys are percent-decoded path segments: encode / as %2F (/v1/tasks/by-key/report%2F42).

When server.metrics_addr is set, a second listener serves /metrics, /healthz and /readyz without authentication, for a Prometheus scraper or kubelet on a private network. The authenticated /metrics on http_addr stays available.

Task object

The body of POST /v1/tasks and each element of a batch's tasks. Every field is optional; {} submits an empty payload to the default pool.

FieldTypeDefaultDescription
payloadany JSON–Payload, sent to the worker as JSON bytes with content type application/json.
payload_b64string–Binary payload, standard base64 (content type application/octet-stream). Mutually exclusive with payload.
content_typestringfrom the payloadOverrides the content type passed to the worker.
poolstring"default"Target pool. Must exist (404) and be allowed for the caller (403).
kindstring"default"Free-form label passed to the worker (at most max_kind_len).
priority"critical", "high", "normal", "low""normal"Priority class.
keystring–Unique key: deduplicates submissions and names the task for dependencies and the by-key routes (at most max_key_len). Keys of the form <prefix>@<UTC time, RFC 3339> (for example report@2026-01-01T03:00:00Z) are reserved for recurring occurrences and rejected with 400.
deadline_in_s / deadline_unix_nsnumber / integer–Deadline, relative or absolute. Raises urgency as it approaches.
start_in_s / start_unix_nsnumber / integer–Fixed start time, relative or absolute (fixed starts).
tolerance_snumber1How late a fixed start may begin; 0 is allowed. Requires start_in_s or start_unix_ns.
on_miss"run_late", "skip", "fail""run_late"What happens when a fixed start misses its window.
duration_hint_snumberpool's default_duration_sExpected run time, used for reservations and hold leads.
timeout_snumbernonePer-attempt timeout; the worker's lease is aborted when it expires.
retryobjectpool's [pools.retry]{"max_attempts": n, "initial_backoff_s", "multiplier", "max_backoff_s", "jitter"}. max_attempts is required; jitter defaults to true.
afterarray[]Tasks that must succeed first: strings are keys; numbers or {"id": "..."} are ids.
depsarray[]{"key": "..."} or {"id": "..."}, with optional "cond": "on_success" (default), "on_complete", "on_failure".
resourcesobject{}{"<resource>": units} acquired for each attempt. At most max_resources entries.
tagsobject{}String labels passed to the worker. At most max_tags; each key and each value at most max_tag_len. Keys starting with cronoq. are reserved.
tenantstring–Tenant, for pools that share capacity fairly (Fair sharing).
labelsobject{}Worker labels the task requires: it runs only on workers that registered with all of them (Worker labels). Same limits as tags.
waitboolfalsePOST /v1/tasks only: respond when the task has finished. Rejected in batches.
wait_timeout_snumberlimits.max_wait_sUpper bound for wait, capped at limits.max_wait_s.

Use either the relative or the absolute form of a time, not both. Strings must not contain NUL characters. Dependencies (after plus deps) are limited to max_deps.

POST /v1/tasks

curl -s localhost:7071/v1/tasks -H "authorization: Bearer $CLIENT_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"pool": "default", "payload": {"prompt": "hi"}, "priority": "high", "key": "job-1"}'
{"id": "17", "duplicate": false}

duplicate is true when a task with the same key is already known; id is then the existing task's id and nothing new is enqueued. With a durable store (Postgres or SQLite), the response is sent after the task is stored.

With "wait": true the response also carries the result:

{"id": "17", "duplicate": false,
 "result": {"id": "17", "status": "succeeded", "output_available": true,
            "output": {"answer": 42}, "output_b64": "eyJhbnN3ZXIiOjQyfQ=="}}

If the task is still running when wait_timeout_s expires, the request fails with 504; the task keeps running and its result can be fetched with GET /v1/tasks/{id}/result.

POST /v1/tasks:batch

{"tasks": [
  {"key": "extract", "payload": {"doc": 1}},
  {"key": "summarise", "payload": {"doc": 1}, "after": ["extract"]}
]}
{"tasks": [{"id": "18", "duplicate": false}, {"id": "19", "duplicate": false}]}

The batch is admitted atomically: either every task is accepted, or none is and the error names the offending task. Dependencies may reference keys defined anywhere in the same batch, in any order. Results are in request order. At most limits.max_tasks_per_batch tasks (default 1000).

GET /v1/tasks/{id} and GET /v1/tasks/by-key/{key}

{"id": "17", "status": "running", "pool": "default", "key": "job-1", "kind": "default",
 "attempt": 1, "max_attempts": 3, "output_available": false,
 "created_unix_ns": "1760000000000000000", "started_unix_ns": "1760000000120000000"}
FieldPresentDescription
idalwaysTask id (string).
statusalwaysSee statuses.
poolalwaysPool name (empty if unknown).
attemptalwaysCurrent or last attempt number (0 before the first dispatch).
output_availablealwaystrue when the task succeeded and its output is still retained.
key, kindwhen setTask key and kind.
tenantwhen setTenant the task was submitted for.
prioritywhen known"critical", "high", "normal" or "low".
max_attemptswhen knownAttempt budget from the task's or the pool's retry policy.
created_unix_ns, started_unix_ns, finished_unix_nswhen knownLifecycle timestamps (strings).
messagewhen setLast error or status message.

id and status are authoritative; the other fields are best effort and come from the serving leader's bounded info cache (outputs.info_entries) or, with a durable store, from the stored task. Unknown tasks, tasks past retention, and tasks in pools the caller may not use return 404.

GET /v1/tasks

Lists tasks in id order, oldest first, as task objects like those of GET /v1/tasks/{id}:

GET /v1/tasks?pool=default,reports&status=running,failed&limit=50
{"tasks": [{"id": "17", "status": "running", "pool": "default", "attempt": 1, ...}],
 "next_after": "17"}
Query parameterDefaultDescription
poolevery pool the caller may useComma-separated pool names. Unknown pools return 404, pools the caller may not use 403.
statusanyComma-separated statuses.
after–Only tasks with a larger id: pass the previous page's next_after.
limit100Page size, at most limits.max_list.

next_after is present when more tasks follow. With shards, the list covers every shard, and unavailable_shards names shards that could not be reached. It is present only when a shard was missing, on this route and on /v1/workers, /v1/recurring, the task-detail routes and the result routes; /v1/snapshot always includes it (empty when every shard answered). Live tasks come from the scheduler. Finished tasks come from the store, or, without one, from the bounded info cache, so only the most recent ones are listed. A page is not an atomic snapshot: a task that finishes while the page is built can be missing from it.

GET /v1/workers

{"workers": [{"id": "3", "name": "gpu-1", "pool": "default", "labels": {"gpu": "a100"},
              "slots": 4, "busy": 1, "connected_unix_ns": "1760000000000000000"}]}

Connected remote workers of the pools the caller may use, in connection order. id is assigned by the server per connection; name is what the worker registered with.

GET /v1/recurring

{"schedules": [{"name": "nightly", "cron": "0 0 3 * * *", "tz": "Europe/Berlin",
                "pool": "default", "kind": "report", "overlap": "queue",
                "next_unix_ns": "1760061600000000000", "last_task_id": "41"}]}

Recurring schedules of the pools the caller may use, by name. tz is absent for UTC, next_unix_ns when the schedule has no further occurrence, and last_task_id before the first occurrence.

DELETE /v1/tasks/{id} and DELETE /v1/tasks/by-key/{key}

{"cancelled": true}

false when the task already finished. Cancelling a running task aborts the worker's lease and cancels its handler. Dependents on an on_success or on_failure edge end as skipped; on_complete dependents proceed. With a durable store (Postgres or SQLite), the cancellation is stored before the response is sent.

GET /v1/tasks/{id}/result and GET /v1/tasks/by-key/{key}/result

Waits until the task finishes, at most timeout_s seconds (query parameter, default and maximum limits.max_wait_s). ?timeout_s=0 returns immediately.

curl -s "localhost:7071/v1/tasks/17/result?timeout_s=60" -H "authorization: Bearer $CLIENT_TOKEN"

Returns a result object, or 504 task is still running; wait again or poll. Results of finished tasks remain available while the leader's output cache (outputs.cache_entries, outputs.cache_bytes) or the durable store (Postgres or SQLite, store.keep_finished_s) still holds them.

Result object

{"id": "17", "status": "failed", "output_available": false, "message": "asked to fail"}
FieldPresentDescription
idalwaysTask id (string).
statusalwaysA terminal status.
output_availablealwaysfalse for a succeeded task whose output is no longer retained.
output_b64output availableThe output bytes, base64.
outputoutput is valid JSONThe output decoded as JSON.
messagewhen setWhy the task did not succeed.

GET /v1/snapshot

{"live_tasks": 12, "running": 4, "pending": 8, "workers_connected": 2, "unavailable_shards": [],
 "pools": [{"name": "default", "capacity": 4, "busy": 4, "held": 0, "desired": 6,
            "pending": 8, "pending_by_class": [0, 2, 6, 0], "reservations": 0,
            "dispatched": 120, "succeeded": 118, "failed": 1,
            "priority_adherence": 1.0, "fixed_start_hit_rate": 1.0,
            "utilization": 0.83, "schedule_efficiency": 0.80,
            "wait_p50_s": 0.2, "wait_p90_s": 1.4, "wait_p99_s": 2.9, "cost": 0.0,
            "workers_connected": 2,
            "groups": [{"labels": {}, "capacity": 3, "busy": 3},
                       {"labels": {"gpu": "a100"}, "capacity": 1, "busy": 1}]}],
 "totals": {"submitted": 130, "dispatched": 124, "succeeded": 118, "failed": 1,
            "dead_lettered": 0, "cancelled": 2, "skipped": 0, "missed": 0, "shed": 0,
            "retries": 4, "timeouts": 1, "lease_expiries": 0,
            "stale_completions": 0}}
FieldDescription
live_tasks, running, pendingTasks not yet finished; of those, running and waiting.
workers_connectedRemote worker streams across all pools.
unavailable_shardsShards that could not be reached; their part is missing from the response.
pools[].capacityWorker slots (sum of the slots of connected workers).
pools[].busy, held, desiredBusy slots; slots held idle for an upcoming fixed start; the autoscaler's target.
pools[].pending, pending_by_classWaiting tasks, total and by class (critical, high, normal, low).
pools[].reservationsFixed-start reservations not yet started.
pools[].dispatched, succeeded, failedAttempts started, tasks succeeded and attempts failed (including retried ones) within the metrics window (the last 60 s).
pools[].priority_adherence, fixed_start_hit_rate, utilization, schedule_efficiencyQuality signals in [0, 1] over the metrics window.
pools[].wait_p50_s, wait_p90_s, wait_p99_sTime from submission to first start, over the metrics window.
pools[].costCost since start (cost_per_worker_second × worker-seconds).
pools[].groupsWorker groups (workers with the same labels), unlabeled first: labels, slots and busy slots.
totalsLifetime counters of this leader since it started.

Field meanings: Metrics and quality signals.

Statuses

StatusTerminalMeaning
blockednoWaiting for dependencies.
schedulednoWaiting for its fixed start time.
readynoEligible and queued for a worker.
runningnoAn attempt is running on a worker.
retryingnoAn attempt failed; waiting for the retry backoff to elapse.
succeededyesFinished successfully.
failedyesFailed permanently, or missed a fixed start with on_miss = "fail".
dead_letteredyesRetryable failures used up every attempt.
cancelledyesCancelled by a caller.
skippedyesA dependency edge can no longer be met, an unknown dependency was awaited too long, or a recurring run overlapped the previous one.
missedyesMissed its fixed start with on_miss = "skip".
shedyesEvicted by a pool's shed_lowest / shed_oldest overflow policy.

Errors

Errors have the body {"error": "<message>", "code": "<reason>"}, where code is the lower-case HTTP reason phrase (for example too_many_requests). 401 responses carry www-authenticate: Bearer.

StatusgRPC equivalentCause
400INVALID_ARGUMENTMalformed JSON, unknown field or enum value, invalid duration, oversize string, dependency cycle, invalid id.
401UNAUTHENTICATEDMissing, malformed or unknown credentials.
403PERMISSION_DENIEDThe credentials lack the route's role, the pool or tenant is outside their scope, or authentication is off without --insecure-no-auth and the Host is not a loopback name or address.
404NOT_FOUNDUnknown task, pool, resource or dependency, or unknown route.
405–Method not allowed on this route.
409FAILED_PRECONDITION, ALREADY_EXISTSFixed-start reservation conflict; a key already used by a task in a pool outside the credentials' scope; a key that names tasks in several shards (use the id); a task or pool served by another shard that this server cannot forward to.
413–Body larger than limits.max_http_body_bytes.
415–Missing or wrong content-type.
429RESOURCE_EXHAUSTEDPool (max_pending) or server (max_pending_total) queue full. Retry with backoff.
500INTERNALUnexpected server error.
503UNAVAILABLENot the leader, shutting down, or the store is unavailable. Retry, preferably against the leader.
504DEADLINE_EXCEEDEDA wait ended before the task finished, or the request exceeded its time limit.

See also Errors for retry guidance.

gRPC API

Package cronoq.v1, defined in crates/cronoq-proto/proto/cronoq/v1/cronoq.proto. Print it from a running binary with cronoq-server --print-proto, depend on the cronoq-proto crate in Rust, or use the generated stubs shipped in the Python package (cronoq._proto). The server listens on server.grpc_addr (default 127.0.0.1:7070), with TLS when [server.tls] is configured.

This page describes worker protocol version 3.

Conventions

  • Authentication. Send the metadata entry authorization: Bearer <token>, or present a client certificate mapped in [[auth.certs]]. Each service requires a role (below); admin passes every check. A token or certificate scoped to pools = [...] may only submit into, watch and serve those pools; tasks in other pools are reported as NOT_FOUND.
  • Times are relative seconds (*_in_s, *_s, double) or absolute Unix nanoseconds (*_unix_ns, int64). Durations must be finite and non-negative. Set at most one of the relative and absolute forms.
  • Enum zero values (*_UNSPECIFIED) mean "default": PRIORITY_NORMAL, EDGE_COND_ON_SUCCESS, MISS_POLICY_RUN_LATE, OVERLAP_SKIP.
  • Limits. Messages are limited to limits.max_message_bytes (default 16 MiB), strings and collections to the [limits] table in the configuration reference. Strings must not contain NUL characters.
  • Only the leader serves. A standby or a stopping leader answers every RPC with UNAVAILABLE; retry with backoff. A standby also closes each connection about a second after accepting it, so behind a load balancer that sends connections to every replica, a retry on a new connection can reach the leader. A load balancer that routes on /readyz sends connections to the leader only.
  • Deadlines. Every unary call is bounded by limits.max_wait_s + limits.request_timeout_s; a call that runs longer fails with CANCELLED. Submit with wait and Wait also stop waiting after limits.max_wait_s (or the shorter timeout the call asks for).

Tasks (role: client)

RPCRequest → ResponseDescription
SubmitSubmitRequest → SubmitResponseSubmit one task; with wait, block until it finishes.
SubmitBatchSubmitBatchRequest → SubmitBatchResponseSubmit up to max_tasks_per_batch tasks atomically.
CancelCancelRequest{task} → CancelResponse{cancelled}Cancel by id or key; false if the task already finished.
GetGetRequest{task} → TaskInfoTask details; NOT_FOUND for unknown tasks.
ListTasksListTasksRequest → ListTasksResponse{tasks, next_after}One page of tasks, live and finished, in id order.
WaitWaitRequest{task, timeout_s} → TaskResultWait for the task to finish.
WatchWatchRequest → stream StatusEventLive status events.

TaskSpec

FieldTypeDescription
payloadbytesPassed to the worker unchanged.
content_typestringPassed to the worker with the payload.
kindstringLabel passed to the worker; default "default".
poolstringTarget pool; default "default".
priorityPriorityDefault PRIORITY_NORMAL.
keystringOptional unique key (deduplication, dependencies).
deadline_in_s / deadline_unix_nsoptionalDeadline.
start_in_s / start_unix_nsoptionalFixed start time.
tolerance_soptional doubleFixed-start tolerance; default 1 s, 0 allowed.
on_missMissPolicyWhat a missed fixed start does.
duration_hint_soptional doubleExpected run time; default the pool's default_duration_s.
timeout_soptional doublePer-attempt timeout.
retryRetryPolicy{max_attempts, initial_backoff_s, multiplier, max_backoff_s, jitter}; unset = the pool's policy.
depsrepeated Dep{target: TaskRef, cond: EdgeCond}.
resourcesmap<string, uint32>Units of each named resource per attempt.
tagsmap<string, string>Labels passed to the worker.
labelsmap<string, string>Worker labels the task requires (Worker labels).
tenantstringTenant, for pools that share capacity fairly (Fair sharing).

The fields match the HTTP task object, which documents them in detail. TaskRef is a oneof of id (uint64) and key (string).

Submit and batches

SubmitRequest{task, wait, wait_timeout_s} returns SubmitResponse{id, duplicate, result}. duplicate is true when the key already exists (then id is the existing task). With wait, result is set once the task finished; if wait_timeout_s (capped at limits.max_wait_s) expires first the call fails with DEADLINE_EXCEEDED and the task keeps running.

SubmitBatchRequest{tasks} is admitted atomically: all tasks or none. Dependencies may name keys defined anywhere in the batch. The response lists one SubmitResponse per task, in request order, without result.

With a durable store (Postgres or SQLite), Submit, SubmitBatch and Cancel return after the change is stored.

TaskInfo

FieldDescription
id, statusAuthoritative.
pool, key, kind, tenantAs submitted.
priorityPRIORITY_UNSPECIFIED if unknown.
attemptCurrent or last attempt (0 before the first dispatch).
max_attemptsAttempt budget; 0 if unknown.
created_unix_ns, started_unix_ns, finished_unix_nsLifecycle timestamps, when known.
messageLast error or status message.
output_availableTrue when the task succeeded and its output is retained.

Fields other than id and status are best effort: they come from the serving leader's info cache (outputs.info_entries) or, with a durable store, from the stored task.

ListTasks

ListTasksRequest has pools (empty: every pool the caller may use), statuses (empty: any; TASK_STATUS_UNSPECIFIED is INVALID_ARGUMENT), after (only larger ids; 0 starts from the beginning) and limit (0 means 100; capped at limits.max_list). The response's next_after is the value to pass as after for the next page, and 0 on the last page. Pools are checked as for Watch: unknown ones are NOT_FOUND, pools the caller may not use PERMISSION_DENIED.

Live tasks come from the scheduler. Finished tasks come from the store, or, without one, from the bounded info cache, which holds only the most recent ones. A page is not an atomic snapshot: a task that finishes while the page is built can be missing from it.

TaskResult

{id, status, output, message, output_available}. status is always terminal. output is set when the task succeeded and its output is still retained (output_available); a succeeded task whose output was evicted from the cache and the store has output_available = false. message explains failures.

Wait without timeout_s waits up to limits.max_wait_s; on timeout it fails with DEADLINE_EXCEEDED (task is still running; wait again or poll). It fails with UNAVAILABLE if the server stops serving while waiting: wait again on the new leader.

Watch

WatchRequest fields:

FieldDescription
poolsPools to watch; empty = all pools the caller may see. Unknown pools are NOT_FOUND; pools outside a scoped token are PERMISSION_DENIED.
ids, keysOptional task filters (at most max_tasks_per_batch together).
all_transitionsDeliver every transition (BLOCKED, READY, RUNNING, RETRYING, ...), not only terminal statuses.

Each StatusEvent carries {id, pool, key, status, attempt, at_unix_ns, message}. The stream ends with UNAVAILABLE when the server shuts down or loses leadership, and with DATA_LOSS when the client reads too slowly and events were dropped. Events are live only: reconnect and use Get / Wait to reconcile anything that happened while disconnected.

Workers (role: worker)

RPCStreamDescription
Workstream WorkerMessage ↔ stream ServerMessageOne bidirectional stream per worker (or per pool a worker serves).

Messages

DirectionMessageMeaning
worker → serverRegister{pool, slots, worker_name, protocol_version, labels}Must be the first message, within 10 s. Send protocol_version 3; older versions are refused with FAILED_PRECONDITION. labels place the worker in a group; a credential with labels = [...] may claim only those (PERMISSION_DENIED otherwise).
server → workerRegistered{worker_id, lease_ttl_s, slots, protocol_version, max_message_bytes}Registration accepted. slots is what was granted: at most the pool's max_slots_per_worker.
server → workerLease{lease, task_id, attempt, payload, content_type, kind, key, tags, timeout_s, deadline_unix_ns, pool, priority, lease_ttl_s}Run this attempt. priority is currently always UNSPECIFIED.
worker → serverHeartbeat{lease}Keep a lease alive.
worker → serverComplete{lease, output, content_type}Success; output becomes the task's result.
worker → serverFail{lease, message, permanent, retry_after_s}Failure: permanent, or retryable with an optional minimum delay.
worker → serverNack{lease, reason}The worker cannot take this lease (for example it is draining).
worker → serverCancelled{lease}The handler stopped because the lease was aborted or cancelled.
worker → serverSetSlots{slots}Change concurrency; 0 drains (no new leases). Capped like Register.slots.
server → workerAbort{lease, reason}Stop this attempt (timeout, cancellation, lease expiry).
server → workerShutdown{reason, not_leader}The server is going away; reconnect with backoff.

Protocol rules

  1. Register first. A stream whose first message is not Register fails with INVALID_ARGUMENT; one that sends nothing for 10 s fails with DEADLINE_EXCEEDED. A second Register on the same stream ends it. Unknown pools are NOT_FOUND.
  2. Capacity is slots. A pool's capacity is the sum of the granted slots of its connected workers. The server never sends more concurrent leases to a worker than its slots. Use the slots value from Registered, not the one you requested.
  3. Heartbeat each running lease more often than lease_ttl_s (every lease_ttl_s / 3 is a good rule). A lease without a heartbeat for lease_ttl_s expires: the attempt fails as retryable and the worker receives Abort.
  4. Report exactly one outcome per lease: Complete, Fail, Nack or Cancelled. Reports for leases the worker does not hold (already aborted, or unknown) are ignored.
  5. Nack offers the lease to another worker of the pool, without counting an attempt. If no other worker has a free slot, the attempt fails as retryable.
  6. Abort means the server has already given up the attempt and freed the slot. Stop the handler; sending Cancelled afterwards is allowed and ignored.
  7. Read the stream promptly. Each worker has a bounded outbound queue (about one lease and one abort per slot). A worker that stops reading is disconnected rather than allowed to hold leases it cannot see.
  8. Disconnects fail running leases as retryable (worker disconnected); they are retried according to the task's retry policy.
  9. Shutdown. On a graceful stop the server first gives running attempts server.shutdown_grace_s to finish (workers keep reporting outcomes), then sends Shutdown{not_leader: false}. When it loses leadership it sends Shutdown{not_leader: true} immediately. Either way, leases still running are failed as retryable and the stream ends about a second later: stop running work and reconnect with backoff.
  10. Messages in both directions are limited to max_message_bytes from Registered.

The Rust (cronoq_server::worker::run_worker) and Python (cronoq.client.Worker) workers implement these rules; see Writing executors and workers.

Admin

RPCRoleRequest → Response
SnapshotmetricsEmpty → SnapshotResponse
AddRecurringadminRecurringRequest{name, cron, tz, template, overlap} → Empty
RemoveRecurringadminRemoveRecurringRequest{name} → RemoveRecurringResponse{removed}
ListWorkersmetricsEmpty → ListWorkersResponse{workers}
ListRecurringmetricsEmpty → ListRecurringResponse{schedules}

ListWorkers and ListRecurring list only the pools the caller may use. A WorkerInfo has the connection id the server assigned, the name, pool and labels the worker registered with, its slots and busy slots, and connected_unix_ns. A RecurringInfo has the schedule's name, cron, tz (empty for UTC), pool, kind and overlap, next_unix_ns (unset when no further occurrence is due) and last_task_id (unset before the first occurrence); schedules are sorted by name.

SnapshotResponse carries pools (one PoolSnapshot per pool: name, capacity, busy, held, desired, pending_by_class, reservations, dispatched, succeeded, failed, priority_adherence, fixed_start_hit_rate, utilization, schedule_efficiency, wait_p50_s, wait_p90_s, wait_p99_s, cost, workers_connected, and groups: one WorkerGroup{labels, capacity, busy} per set of worker labels, unlabeled first), live_tasks, running, pending, workers_connected, unavailable_shards and totals (submitted, dispatched, succeeded, failed, dead_lettered, cancelled, skipped, missed, shed, retries, timeouts, lease_expiries, stale_completions). The HTTP snapshot documents each field.

AddRecurring registers (or replaces) a cron schedule named name. cron has six fields (seconds first) or five (seconds = 0); tz is an IANA time zone (default UTC). The template is a TaskSpec without deadline_unix_ns, start_* or deps (INVALID_ARGUMENT otherwise). Each occurrence gets its own fixed start at the cron time, with the template's tolerance_s (default 1 s) and on_miss (default RUN_LATE). The template's deadline_in_s is relative to each occurrence's start, and its key is a prefix: each occurrence's key is <key>@<start in UTC, RFC 3339>. overlap is OVERLAP_SKIP (the default: an occurrence whose previous run is still going ends as SKIPPED), OVERLAP_ALLOW, OVERLAP_QUEUE (wait for the previous run) or OVERLAP_QUEUE_ON_SUCCESS (wait, and run only if it succeeded). Schedules are durable with a durable store; AddRecurring returns once the schedule is stored. See Recurring schedules.

Shards

With [[shards]], any server answers for every shard (Sharding): calls for another shard are forwarded to it with the caller's token, and ListTasks, Watch, ListWorkers, ListRecurring and Snapshot cover all shards. The listing responses and SnapshotResponse name shards that could not be reached in unavailable_shards; Get and Wait by key name them in the cronoq-unavailable-shards response metadata, and Cancel by key is refused while any shard cannot be asked. A call that is not forwarded (client-certificate callers, plain-HTTP endpoints on other hosts, workers) gets FAILED_PRECONDITION with the shard's name and endpoint in the cronoq-shard and cronoq-shard-endpoint metadata. Workers reconnect to that endpoint (see Sharding).

gRPC-Web

With [server.grpc_web], the gRPC listener also answers gRPC-Web requests (application/grpc-web and application/grpc-web+proto, binary framing) for browser clients such as the TypeScript SDK's @synaptiai/cronoq/web, or those generated by protoc-gen-grpc-web or Connect-ES's gRPC-Web transport, over HTTP/1.1 or HTTP/2.

  • Calls. Unary calls and the server stream Watch work. Workers.Work is a bidirectional stream, which gRPC-Web cannot carry; workers use native gRPC.
  • Authentication is the same as for native gRPC: send authorization: Bearer <token>. Browsers do not attach credentials themselves, and the server does not allow credentialed CORS requests, so cookies and browser-held client certificates are not used.
  • CORS. Preflights from the configured allowed_origins are answered for POST with the headers authorization, content-type, x-grpc-web, x-user-agent and grpc-timeout; the response headers grpc-status, grpc-message and grpc-status-details-bin are exposed. Other origins get no CORS headers.
  • HTTP/1.1 carries only gRPC-Web: a plain gRPC request over HTTP/1.1 gets UNIMPLEMENTED.
  • Without credentials (loopback-only mode), every request on the gRPC listener must name a loopback host (localhost, *.localhost, 127.0.0.1, [::1]), or it gets PERMISSION_DENIED, as on the HTTP API. With --insecure-no-auth this rule is off, as it is for the HTTP API, so a page on a machine that can reach the server could call every service, Admin included: do not enable gRPC-Web without credentials outside a trusted network.

Status codes

CodeCause
INVALID_ARGUMENTInvalid spec, duration, reference, cron expression or time zone; dependency cycle; oversize string or collection.
NOT_FOUNDUnknown task, pool, resource or dependency.
ALREADY_EXISTSThe key is already used by a task in a pool outside the credentials' scope.
FAILED_PRECONDITIONFixed-start reservation conflict; a task or pool served by another shard that this server cannot forward to (named in the cronoq-shard metadata); a key that names tasks in several shards (use the id); a worker with an older protocol version.
RESOURCE_EXHAUSTEDQueue bounds reached (max_pending, max_pending_total); retry later.
UNAUTHENTICATEDMissing, malformed or unknown credentials.
PERMISSION_DENIEDThe credentials lack the role; the pool or tenant is outside their scope; a worker claims labels its credentials do not allow; or, on gRPC-Web with no credentials configured and no --insecure-no-auth, the request does not name a loopback host.
DEADLINE_EXCEEDEDA wait timed out while the task is still running, or no Register within 10 s.
CANCELLEDThe call ran longer than limits.max_wait_s + limits.request_timeout_s, or the client cancelled it.
UNAVAILABLENot the leader, shutting down, or the store is unavailable. Retry with backoff.
DATA_LOSSA Watch stream fell behind and events were dropped.
UNIMPLEMENTEDA plain gRPC request over HTTP/1.1 (with gRPC-Web enabled, HTTP/1.1 carries only gRPC-Web).
INTERNALUnexpected server error.

Enumerations

EnumValues (zero value first)
PriorityPRIORITY_UNSPECIFIED (= normal), PRIORITY_CRITICAL, PRIORITY_HIGH, PRIORITY_NORMAL, PRIORITY_LOW
TaskStatusTASK_STATUS_UNSPECIFIED, BLOCKED, SCHEDULED, READY, RUNNING, RETRYING, SUCCEEDED, FAILED, DEAD_LETTERED, CANCELLED, SKIPPED, MISSED, SHED (prefixed TASK_STATUS_)
EdgeCondEDGE_COND_UNSPECIFIED (= on success), EDGE_COND_ON_SUCCESS, EDGE_COND_ON_COMPLETE, EDGE_COND_ON_FAILURE
MissPolicyMISS_POLICY_UNSPECIFIED (= run late), MISS_POLICY_RUN_LATE, MISS_POLICY_SKIP, MISS_POLICY_FAIL
OverlapOVERLAP_UNSPECIFIED (= skip), OVERLAP_SKIP, OVERLAP_ALLOW, OVERLAP_QUEUE, OVERLAP_QUEUE_ON_SUCCESS

Status meanings: HTTP statuses.

Protocol version

Workers send protocol_version 3; a server refuses older versions with FAILED_PRECONDITION and a message naming the version it needs.

Python API

pip install cronoq            # embedded scheduler (module: cronoq)
pip install "cronoq[client]"  # + cronoq.client: gRPC client and remote worker for cronoq-server

Wheels use the stable ABI (abi3, CPython 3.14+) for Linux, macOS and Windows, on x86_64 and arm64. The client extra needs grpcio, which has no prebuilt wheel for Windows on arm64, so there pip tries to build it from source. Type stubs ship with the package (py.typed). Durations are seconds (float); absolute times accept a datetime or Unix seconds. Invalid durations or configuration (negative, NaN, out of range) raise ValueError.

cronoq.Scheduler

Scheduler(pools: Sequence[Pool], resources: Sequence[Resource] | None = None,
          emit_transitions: bool = False, *, shutdown_grace: float = 5.0,
          event_capacity: int = 1024, store: SqliteStore | None = None,
          codec: Codec | None = None)
ArgumentDescription
poolsAt least one Pool.
resourcesShared semaphores and token buckets.
emit_transitionsDeliver every status transition on events(), not only terminal ones.
shutdown_graceSeconds running attempts get when leaving async with.
event_capacityBuffer of each events() stream.
storeKeep state in a SQLite file so it survives restarts; see SqliteStore.
codecHow payloads and results are turned into bytes for the store: any object matching the cronoq.Codec protocol (encode(obj) -> bytes and decode(bytes) -> obj). Default: JsonCodec. Needs store.

A scheduler is bound to the event loop that starts it. If that loop closes without a shutdown, the scheduler stops and further calls raise SchedulerError; calls before start() raise RuntimeError.

MemberReturnsDescription
await start()Start on the running event loop. Executors run on this loop.
await submit(payload, **options)TaskHandleSubmit; options below. Waits for queue space with the wait overflow policy.
await enqueue(payload, **options)intSubmit without a handle; returns the id.
await submit_batch(tasks: Sequence[Task])list[TaskHandle]Atomic batch; tasks may depend on each other by key.
await cancel(target)boolCancel by id or key; False if already finished.
await status(target)str | NoneStatus name, or None if unknown.
await handle(target)TaskHandle | NoneAttach to an existing task.
await snapshot()SnapshotMetrics and quality signals.
await set_capacity(pool, n)Report capacity for externally managed pools.
await set_group_capacity(pool, labels, n)Report the slots of the pool's workers with exactly labels (Worker labels).
await add_recurring(name, cron, payload, **options)Cron schedule. Options: tz, overlap ("skip", "allow", "queue" or "queue_on_success"), tolerance (1.0), on_miss ("run_late"), priority, pool, kind, key (a prefix: each occurrence gets "<key>@<start in UTC>"), deadline_in (relative to each occurrence's start), duration_hint, timeout, retry, resources, tags, labels, tenant.
await remove_recurring(name)boolFalse if no such schedule.
events(on_lag="skip")EventStreamStatus events; see below.
await shutdown(grace=5.0)Stop admitting, let running attempts finish for grace seconds, then stop.
async with Scheduler(...) as sstart() on enter, shutdown(shutdown_grace) on exit.
started, pools, default_poolpropertiesWhether started; pool names; the pool used without pool= ("default", or the first).

Task options

Accepted by submit, enqueue and Task(...).

OptionTypeDescription
priorityPriority, "critical"/"high"/"normal"/"low", or intDefault normal.
pool, kindstrDefault: the default pool, "default".
keystrUnique key (deduplication, dependencies).
deadline_in / deadline_atseconds / timeDeadline.
start_in / start_atseconds / timeFixed start.
tolerancesecondsFixed-start tolerance (default 1; 0 allowed).
on_miss"run_late", "skip", "fail"Fixed-start miss policy.
duration_hint, timeoutsecondsExpected run time; per-attempt timeout.
retryRetryPolicyOverrides the pool's policy.
afterid/key or a list of themMust succeed first.
depslist[Dep]Dependencies with conditions.
resourcesdict[str, int]Units of named resources per attempt.
tagsdictLabels (values converted to str).
labelsdict[str, str]Worker labels the task requires; it runs only in a group of workers that has them.
tenantstrTenant, for pools with tenant_weights or fair_share (Fair sharing).

Strings must not contain NUL characters.

Status events

sched.events() returns an EventStream, an async iterator of StatusEvent (id, pool, key, status, attempt, at in Unix seconds, message).

async with sched.events() as events:
    async for e in events:
        print(e.id, e.status, e.message)
  • Each stream buffers up to event_capacity events. When the consumer falls behind, the oldest are dropped and counted in events.lagged. With on_lag="raise" the next iteration raises EventsLagged (.skipped); iterating again continues with the buffered events.
  • The stream ends (StopAsyncIteration) when the scheduler shuts down or the stream is closed (close(), await aclose(), or leaving async with). closed tells whether it ended.

cronoq.SqliteStore

SqliteStore(path, *, lease_ttl=10.0, lease_renew=2.0, lock_poll=0.5, retention=3600.0,
            full_sync=True)

Durable state in a local SQLite file, created with owner-only permissions. Submissions, cancellations and schedule changes return only once they are stored. After a restart, queued and interrupted tasks run again (an attempt that was running counts as used), recurring schedules resume, and results of finished tasks stay available through handle().

ArgumentDescription
pathDatabase file; keep it on a local filesystem.
lease_ttl, lease_renew, lock_pollSeveral processes may share the file: one leads and the others wait in start(). The leader renews its lease every lease_renew seconds; a standby takes over lease_ttl seconds after the leader stopped renewing (at once after a clean shutdown) and checks every lock_poll seconds.
retentionHow long finished tasks are kept in memory after a restart, for key deduplication.
full_syncSync to disk on every commit. Off is faster but a power loss can drop the latest commits.

With a store, payloads and results are encoded by the scheduler's codec:

  • cronoq.JsonCodec (the default) stores JSON. Payloads it cannot encode (bytes, sets, arbitrary objects) are refused at submit with TypeError, before anything is stored. Decoding JSON never runs code, so a tampered file cannot execute anything in your process.
  • Pass your own codec for other types, for example msgpack. A pickle codec works, but anyone who can write the file can then run code in your process.
  • A result the codec cannot encode is not stored (a warning is logged on the cronoq logger); the task still succeeds.

cronoq.Pool

Pool(name, executor, *, min=1, max=10, initial=None, autoscale=None, lease_ttl=None,
     max_pending=None, overflow="wait", retry=None, unknown_dependency="reject",
     await_timeout=None, default_duration=1.0, reservable_fraction=0.5,
     tenant_weights=None, fair_share=False)
ArgumentDescription
executorasync def run(ctx, payload), or a plain function (runs in the loop's default thread pool).
min, max, initialConcurrency bounds (initial defaults to min).
autoscaleTrue / None (default policy), False (fixed size: initial, or max when initial is not given), or an AutoscaleConfig.
tenant_weights, fair_shareShare the pool fairly between tenants: weights by tenant (others weigh 1), or equal shares with fair_share=True (Fair sharing).
lease_ttlAttempts that do not call ctx.heartbeat() within this many seconds are failed as retryable.
max_pending, overflowQueue bound and what happens when it is reached: "wait", "reject", "shed_lowest", "shed_oldest".
retryDefault RetryPolicy (3 attempts, exponential backoff).
unknown_dependency, await_timeout"reject" unknown dependency keys, or "await" them for up to await_timeout seconds.
default_durationDuration assumed for tasks without duration_hint.
reservable_fractionShare of max that fixed-start reservations may use, in [0, 1]; 0 is treated as the default 0.5.

A sync executor cannot be interrupted: on timeout or cancellation the attempt is aborted, but the function keeps running until it returns. Check ctx.is_cancelled() in long-running sync executors.

Other types

TypeDescription
PriorityCRITICAL, HIGH, NORMAL, LOW; ordered, hashable, .name, .value, int().
Codec, JsonCodecCodec is the protocol a store codec implements (encode(obj) -> bytes, decode(bytes) -> obj); JsonCodec is the default implementation.
Task(payload, **options)A task for submit_batch.
Dep(target, on="success")Dependency; on is "success", "failure" or "complete" ("on_" prefix accepted).
RetryPolicy(max_attempts=3, initial_backoff=1.0, multiplier=2.0, max_backoff=60.0, jitter=True)RetryPolicy.none() disables retries.
AutoscaleConfig(interval=1.0, target_utilization=0.75, drain_time=30.0, slo=None, reservation_horizon=60.0, stabilization_window=60.0, cooldown=30.0, max_step_up_abs=4, max_step_down=1, cost_per_worker_second=0.0)slo maps priority names to wait objectives in seconds.
Resource.semaphore(name, permits) / Resource.token_bucket(name, rate, burst)Shared resources.
TaskContextid, attempt, kind, key, pool, tags, group (labels of the worker group the attempt was assigned to), timeout, deadline (Unix seconds); is_cancelled(), heartbeat().
TaskHandleAwaitable; id, duplicate, await result(), done(), cancel().
Snapshotpools, live_tasks, running, pending, totals (dict), pool(name).
PoolSnapshotThe snapshot fields with times in seconds, plus pending.
StatusEvent, EventStreamSee status events.

cronoq.__version__ is the package version as a string.

Exceptions

All live in cronoq (and cronoq.errors), derive from CronoqError and can be pickled.

ExceptionRaised
Retryable(message="", retry_after=None)By executors and worker handlers: retry, not before retry_after seconds.
Permanent(message="")By executors and worker handlers: fail without retrying.
TaskFailedAwaiting a task that did not succeed. .status (terminal status, "shutdown", or "succeeded" when the output is no longer available), .message, .task_id.
SubmitErrorSubmission rejected. .code: unknown_pool, unknown_resource, resource_exceeds_capacity, reservation_conflict, cycle, unknown_dependency, full, overloaded, duplicate_key_in_batch, invalid.
SchedulerErrorScheduler stopped, misconfigured, or its store failed.
EventsLaggedAn events(on_lag="raise") stream fell behind; .skipped.

Any other exception from an executor is a retryable failure. See Errors and status codes.

cronoq.client

The gRPC client and remote worker for cronoq-server, speaking worker protocol version 3 (cronoq.client.PROTOCOL_VERSION). Both can be created outside a running loop; they connect on first use.

Client(target, *, token=None, tls=False, credentials=None, options=None)

target is host:port. tls=True uses system roots; pass grpc.ssl_channel_credentials(...) as credentials for a private CA or mTLS. Use it as async with Client(...) as c or call await c.close().

MethodReturnsDescription
await submit(payload=None, *, wait=False, wait_timeout=None, call_timeout=None, **options)Submitted(id, duplicate, result)Options as for task. With wait=True, result is set when the task finished.
await submit_batch(specs)list[Submitted]Atomic batch of specs built with task(...).
await cancel(ref)boolBy id or key.
await get(ref)TaskInfoRaises ClientError with code not_found for unknown tasks.
await list_tasks(*, pools=(), statuses=(), after=0, limit=0)TaskPage(tasks, next_after)One page of tasks, live and finished, in id order. statuses are names such as "running"; limit defaults to 100, capped by the server's limits.max_list. Pass next_after as after for the next page (None on the last one).
iter_tasks(*, pools=(), statuses=(), page_size=0)async iterator of TaskInfoEvery matching task, fetched one page at a time.
await wait(ref, timeout=None)ResultRaises TimeoutError if still running after timeout (or the server's max_wait_s).
watch(pools=None, *, ids=None, keys=None, all_transitions=False)async iterator of EventTerminal events by default; every transition with all_transitions=True.
await snapshot()SnapshotRequires a metrics or admin token.
await add_recurring(name, cron, payload=None, *, tz=None, overlap="skip", **options)Requires an admin token. payload may also be a prebuilt task(...) spec. overlap, key and deadline_in work as for the native scheduler.
await remove_recurring(name)boolRequires an admin token.
await list_workers()list[WorkerInfo]Connected remote workers. Requires a metrics or admin token.
await list_recurring()list[RecurringInfo]Recurring schedules, by name. Requires a metrics or admin token.

Result types (frozen dataclasses):

TypeFields
Submittedid, duplicate, result
Resultid, status (terminal), output (bytes or None), message, output_available; .ok, .json(), .text()
TaskInfoid, status, pool, key, kind, attempt, max_attempts, created_at, started_at, finished_at (Unix seconds or None), message, output_available, tenant, priority (name or None); .done
TaskPagetasks (tuple of TaskInfo), next_after (int or None), unavailable_shards (shards that could not be reached)
WorkerInfoid, name, pool, labels, slots, busy, connected_at (Unix seconds)
RecurringInfoname, cron, tz (empty for UTC), pool, kind, overlap, next_at (Unix seconds or None), last_task_id (or None)
Eventid, pool, key, status, attempt, at (Unix seconds), message
Snapshotpools, live_tasks, running, pending, totals (Totals), workers_connected, unavailable_shards; .pool(name)
PoolSnapshotname, capacity, busy, held, desired, pending_by_class, reservations, dispatched, succeeded, failed, priority_adherence, fixed_start_hit_rate, utilization, schedule_efficiency, wait_p50, wait_p90, wait_p99, cost, workers_connected, groups (tuple of WorkerGroup, unlabeled first); .pending
WorkerGrouplabels, capacity, busy: the connected workers of a pool that registered the same labels
Totalssubmitted, dispatched, succeeded, failed, dead_lettered, cancelled, skipped, missed, shed, retries, timeouts, lease_expiries, stale_completions (counters since the leader started)

Failed calls raise ClientError: .code is the lower-case gRPC status name and .retryable is true for unavailable and resource_exhausted. A watch stream ends with ClientError unavailable when the server stops serving and data_loss when the consumer fell behind: reconnect and reconcile with get or wait.

task(payload=None, **options)

Builds a TaskSpec for submit_batch and add_recurring. Options: pool, kind, priority, key, deadline_in / deadline_at, start_in / start_at, tolerance, on_miss, duration_hint, timeout, retry (a RetryPolicy or a dict), after, deps ((ref, "on_success") tuples or dicts), resources, tags, labels (worker labels the task requires), tenant, content_type.

Payloads are encoded by type:

PayloadBytes sentContent type
bytes, bytearray, memoryviewas-isapplication/octet-stream
strUTF-8text/plain; charset=utf-8
Noneemptynone
anything elseJSONapplication/json

content_type= overrides the content type. Worker outputs are encoded the same way (without an override). Result.json() returns None for an empty output.

Worker(target, pool, handler, *, slots=1, token=None, name="", tls=False, credentials=None, reconnect=True, backoff=(0.1, 5.0), grace=10.0, options=None, labels=None, insecure_redirect_http=False)

MemberDescription
await run()Serve until stop(), reconnecting with exponential backoff (backoff = initial, maximum seconds). Raises ClientError for errors retrying cannot fix (bad token, unknown pool).
await stop(grace=None)Stop taking leases (SetSlots(0)), let running ones finish for grace seconds, cancel the rest, disconnect. Safe to call more than once; a stopped worker can run again.
await wait_registered(timeout=None)Wait until registered.
runningLeases running now.
worker_id, lease_ttl, granted_slots, server_protocol, max_message_bytesFrom the server's Registered message.
last_shutdown, connectionsReason of the last server Shutdown; number of connections made.

handler(lease) is an async def receiving a Lease: id, task_id, attempt, payload, content_type, kind, key, tags, timeout, deadline (Unix seconds), pool, priority, lease_ttl, cancelled (an asyncio.Event), .json(), .text(). It returns the output, or raises:

RaiseEffect
cronoq.Retryable(msg, retry_after=s) or any other exceptionRetryable failure.
cronoq.Permanent(msg)Permanent failure.
cronoq.client.Reject(reason)Hand the lease back unrun (Nack); the server offers it to another worker of the pool with the same labels that has a free slot and has not already refused it. If there is none, the attempt fails as retryable and counts against the task's retries.
cronoq.client.CancelTask(msg)End the task as cancelled.

The worker heartbeats each running lease every lease_ttl / 3. When the server aborts an attempt (timeout, cancellation, lease expiry) the handler's task is cancelled and lease.cancelled is set. A handler that swallows CancelledError keeps running and may overlap with the retry of the same task. When the server announces Shutdown (restart or leadership change) the worker reconnects; attempts in flight are retried by the server. When another shard serves the pool, the worker connects to that shard's endpoint, keeping its credentials (insecure_redirect_http=True allows plain http:// to other hosts; see Sharding).

cronoq.compat

A synchronous, thread-safe API for code written against the ElasticPriorityScheduler prototype: ElasticPriorityScheduler(initial_workers=1, max_workers=10, scale_up_threshold=0.7, scale_down_threshold=0.9, worker_cost_per_second=0.001) with start(), stop(), submit(Task) -> bool, get_metrics() -> QualityMetrics, get_cost(), completed_count, completed_tasks, pending_tasks, and the types Task, Priority, ScheduledTask, QualityMetrics, ScalingConfig. See Migrating.

Rust API

The complete API documentation is on docs.rs. This page maps the crates and their main types, so you know where to look.

Cratedocs.rsUse it for
cronoqdocs.rs/cronoqEmbedding the scheduler in a tokio application. Start here.
cronoq-coredocs.rs/cronoq-coreThe sans-IO engine: custom drivers, simulations, deterministic tests.
cronoq-store-postgresdocs.rs/cronoq-store-postgresDurable state and leader election for Scheduler<Bytes, Bytes>.
cronoq-store-sqlitedocs.rs/cronoq-store-sqliteThe same, in a local SQLite file.
cronoq-protodocs.rs/cronoq-protoGenerated gRPC clients and servers (cronoq_proto::v1), PROTOCOL_VERSION.
cronoq-serverdocs.rs/cronoq-serverThe server binary, and worker::run_worker for Rust workers.

MSRV: Rust 1.94 (edition 2024) for every crate.

cronoq

Building a scheduler

Scheduler::builder() returns a Builder<P, O> (P = payload type, O = output type).

Builder methodDefaultDescription
pool(Pool)–Add a pool (at least one; the first is the default pool).
resource(ResourceConfig)–Add a shared semaphore or token bucket.
priority(PriorityConfig)built-inClass scores and aging.
urgency(UrgencyConfig)built-inDeadline urgency curve.
store(impl Store) / store_arc(Arc<dyn Store>)MemoryStorePersistence backend.
emit_transitions(bool)falseBroadcast every transition on events(), not only terminal ones.
queue_capacity(n)8192Capacity of the submission channel.
events_capacity(n)4096Broadcast buffer of events(); slow subscribers lose the oldest events.
max_store_outage(Duration)5 minHow long a store may report StoreError::Unavailable before the scheduler stops.
drop_grace(Duration)5 sGrace for running attempts when every handle is dropped without shutdown.
output_cache(n)1024Recent results kept for handles attached after a task finished: outputs, and the messages of tasks that did not succeed (0 disables).
configure(FnOnce(&mut CoreConfig))–Engine-wide settings: bounds, retention, metrics window, seed.
build().await–Start on the current tokio runtime (waits for leadership with a persistent store).
build_blocking()–Start a BlockingScheduler on its own runtime thread.

Pool<P, O>:

MethodDescription
Pool::new(name)Autoscaled pool (1 to 10 workers by default).
Pool::fixed(name, n)Fixed size, no autoscaling.
workers(min, initial, max)min is the lower bound on the autoscaler's desired capacity; initial is the capacity at start (at most max, and not raised to min); max is the upper bound.
executor(impl Executor), executor_arc(..), executor_fn(async fn)How attempts run.
scaler(impl Scaler), scaler_arc(..)Capacity backend (default SlotScaler: in-process slots).
autoscale(AutoscaleConfig), no_autoscale()Autoscaling policy.
configure(FnOnce(&mut PoolConfig))Every other pool setting (bounds, overflow, retry, lease TTL, max hold, tenant fairness, ...).

Scheduler<P, O>

A cheap-to-clone handle. Every method is async and returns Result<_, Error>, except events(), pools() and clock(), which are synchronous and return their value directly, and stopped(), which is async and returns ().

MethodReturnsDescription
submit(task)TaskHandle<O>Submit and get an awaitable handle. Waits for queue space with the Wait overflow policy.
enqueue(task)SubmittedSubmit without a handle (cheaper; id, duplicate).
enqueue_many(tasks)Vec<Result<Submitted, Error>>Independent submissions in one round trip.
submit_batch(tasks)Vec<TaskHandle<O>>Atomic batch; tasks may depend on each other by key.
handle(target)Option<TaskHandle<O>>Attach to an existing task by id or key.
cancel(target)boolfalse if the task already finished.
status(target)Option<TaskStatus>None for unknown tasks.
list_tasks(pools, statuses, after, limit)Vec<TaskSummary>Live tasks in id order, filtered by pool (any if empty) and status (any if empty), with ids above after. Finished tasks are listed by a durable store (StoreQueries::list_finished).
list_recurring()Vec<RecurringInfo>Recurring schedules with their next occurrence and latest task.
snapshot()SnapshotMetrics and quality signals.
set_capacity(pool, n)()Report capacity for pools with an ExternalScaler.
set_group_capacity(pool, labels, n)()Report the slots of the pool's workers with exactly labels (Worker labels).
add_recurring(Recurring) / remove_recurring(name)() / boolCron schedules.
events()broadcast::Receiver<StatusEvent>Status events.
pools(), clock()&[Arc<str>], &ClockPool names; the engine clock.
stopped()()Resolves when the scheduler stops for any reason.
shutdown(grace)()Stop admitting, let running attempts finish for grace, then stop.

With a persistent store, submit, enqueue, submit_batch, cancel, add_recurring and remove_recurring return only after the change is durable.

TaskHandle<O> is a Future<Output = Result<Arc<O>, TaskFailure>> with id(), is_duplicate() and cancel().

Tasks and schedules

Task<P>: Task::new(payload) (or from_arc), then priority, pool, kind, key, deadline_in / deadline_at, start_in / start_at, start_tolerance, on_miss, duration_hint, timeout, retry, no_retry, after(target), dep(Dep), resource(name, units), tag(key, value), label(name, value) (a required worker label), tenant(name) (fair sharing). Builder order does not matter. Strings must not contain NUL characters.

Recurring<P>: Recurring::new(name, cron, template), then tz, overlap(OverlapPolicy) (Skip, Allow, Queue, QueueOnSuccess), tolerance, on_miss. The template's deadline_in applies per occurrence and its key is a prefix (<key>@<start>); it may not set an absolute deadline, a fixed start or dependencies.

Executors

TypeDescription
Executor<P, O>Trait: execute(ctx, Arc<P>) -> BoxFuture<Result<O, TaskError>>, optional abort(lease).
FnExecutorWraps an async fn(TaskContext, Arc<P>); what Pool::executor_fn uses.
TaskContextid, lease, attempt, pool, kind, key, tags, deadline, timeout, group (labels of the worker group the attempt was assigned to); is_cancelled(), cancelled().await, cancellation_token(), heartbeat(), tag(key), traceparent(); TaskContext::for_test(id) for unit tests.
TaskErrorRetryable { message, retry_after }, Permanent { message }, Cancelled; constructors retryable, retry_after, permanent, from_err.

A panicking executor fails its attempt permanently (panic: ...); the scheduler and other attempts keep running.

Errors

TypeVariants
ErrorSubmit(SubmitError), Shutdown, Config(String), Store(StoreError), Internal(String).
TaskFailureEnded { status, message }, OutputUnavailable { id }, Shutdown; status().
StoreErrorFenced, Unavailable(String), Other(String); is_transient().
ScaleErrorExternal, Other(String).

See Errors and status codes.

Capacity and persistence

TypeDescription
ScalerTrait: turn a desired capacity into workers.
SlotScalerDefault: capacity is a number of in-process concurrent attempts.
ExternalScalerCapacity is managed elsewhere and reported with set_capacity.
Store<P, O>Trait: persists (default true; false means the engine produces no mutations), acquire_leadership, release_leadership, commit, recover, load_output.
MemoryStoreNo persistence (default).
TimeBaseWall-clock anchor of engine time; the runtime always uses Unix-epoch time (unix_nanos_at_zero = 0).

Blocking facade

BlockingScheduler<P, O> (from Builder::build_blocking) has the same operations without async: submit, enqueue, enqueue_many, submit_batch, task_handle, cancel, status, snapshot, set_capacity, add_recurring, remove_recurring, events, scheduler, shutdown(grace). BlockingHandle<O> has id(), cancel(), wait() and wait_timeout(d).

Re-exports and features

Engine types are re-exported from cronoq-core: AutoscaleConfig, Bounds, Dep, EdgeCond, MissPolicy, OverflowPolicy, OverlapPolicy, PoolConfig, PoolSnapshot, Priority, PriorityConfig, ResourceConfig, ResourceKind, RetryPolicy, Snapshot, StatusEvent, SubmitError, Submitted, TaskId, TaskRef, TaskStatus, UnknownDependency, UrgencyConfig, and more. The whole engine is cronoq::core.

FeatureDefaultEffect
otelonOpenTelemetry metrics and W3C trace-context propagation (metrics reference).
store-codecoffThe cronoq::codec module: conversions between engine types and the form durable stores keep, shared by the Postgres and SQLite stores. Needed only to write a store.

cronoq-core

Core<P> is a deterministic state machine with no I/O, threads or clocks. Every method takes the current Time, so tests and simulations run thousands of virtual hours per second.

MethodDescription
Core::new(CoreConfig)Create; CoreConfig::validate() reports problems without panicking.
submit, try_submit, submit_batchAdmission.
complete(now, lease, Outcome), heartbeat(now, lease)Attempt feedback.
cancel, status, resolve, live_tasks, list_tasks, totalsLookups and cancellation.
set_capacity(now, pool, n), set_group_capacity(now, pool, labels, n)Capacity changes, for the whole pool or for the workers with exactly labels.
add_recurring, remove_recurring, recurring_names, list_recurringCron schedules.
tick(now), next_wakeup()Timers.
poll_action(), drain_actions(), has_actions()Action::{Dispatch, Abort, ScaleTo, Status, Persist, SpaceAvailable} for the driver.
snapshot(now)Metrics.
recover(now, Recovered)Restore state loaded from a store.
check_invariants()Panics on internal inconsistency (tests and fuzzing).

CoreConfig fields: pools, resources, priority, urgency, global_bounds, retention, retention_max, persist, epoch_unix_nanos, id_epoch, id_shard (default None; when set, task ids carry the shard number in their top 8 bits), metrics_window (default 60 s), seed.

cronoq-store-postgres

ItemDescription
PgStore::connect(url) / connect_with(url, PgStoreConfig)Connect and migrate.
PgStoreConfigschema (default public), retention, lock_poll, keepalive, session_timeout, commit_timeout, max_connections.
migrate()Apply pending schema migrations (versioned in cronoq_schema_version).
get_task(target)Read a stored task (TaskRecord). Also available through the cronoq::StoreQueries trait, with prune, list_finished, is_leader and leadership_lost.
list_finished(pools, statuses, after, limit)Finished tasks in id order, filtered by pool and status (any if empty), with ids above after.
prune(older_than)Delete finished tasks older than the given age.
is_leader(), epoch(), leadership_lost().await, release_leadership().awaitLeadership state.

cronoq-store-sqlite

ItemDescription
SqliteStore::open(path) / open_with(path, SqliteStoreConfig)Open (creating if needed) and migrate.
SqliteStoreConfigretention, lease_ttl (10 s), lease_renew (2 s), lock_poll, busy_timeout, commit_timeout, full_sync (default on), max_connections.
get_task, prune, list_finished, is_leader(), epoch(), leadership_lost().await, release_leadership().awaitAs for the Postgres store.

See Durability and high availability.

cronoq-server::worker

use cronoq_server::worker::{WorkerOptions, run_worker};

let opts = WorkerOptions::new("http://127.0.0.1:7070", "default").slots(8).token(token);
run_worker(opts, |lease| async move { Ok(lease.payload) }, shutdown_signal).await?;
WorkerOptions fieldDefaultDescription
endpoint, poolrequiredServer URL (http:// or https://) and pool name.
slots1Concurrent leases requested (the server may grant fewer).
tokennoneBearer token with the worker role.
nameemptyShown in server logs.
tlsnoneWorkerTls { ca_pem, identity, domain } for TLS and mTLS. Needs an https:// endpoint; with http:// the worker returns an error.
labelsemptyWorker labels, as (name, value) pairs; add one with the builder method label(name, value). Tasks that require labels are leased only to workers that have them.
reconnecttrueReconnect with backoff when the connection drops or the server steps down.
insecure_redirect_httpfalseFollow a shard redirect to plain http:// on another host with the token. Redirects otherwise keep TLS and use plain http only on loopback; see Sharding.
initial_backoff, max_backoff200 ms, 10 sReconnect backoff bounds.
drain_timeout30 sHow long running handlers may finish after shutdown resolves.
max_message_bytes64 MiBLargest message accepted from the server.

The handler receives a LeaseInfo (lease, task_id, attempt, pool, payload, content_type, kind, key, tags, timeout, deadline, cancel) and returns Result<Bytes, TaskError>. run_worker enforces the granted slots, heartbeats each lease, cancels lease.cancel on Abort, turns a panicking handler into a retryable failure, and returns an error only for problems retrying cannot fix (bad credentials, unknown pool).

Go API

The Go SDK (github.com/synaptiai/cronoq/sdks/go, package cronoq) is a client for a cronoq server: it submits, inspects, waits for and watches tasks, and runs workers. It needs Go 1.26 or newer.

go get github.com/synaptiai/cronoq/sdks/go

The generated gRPC stubs are in the cronoqpb package, for calls the SDK does not wrap (such as AddRecurring or ListWorkers); Client.Conn() gives the connection to use with them.

Submitting and waiting

c, err := cronoq.Dial("http://127.0.0.1:7070", cronoq.Options{Token: os.Getenv("CRONOQ_TOKEN")})
if err != nil {
	return err
}
defer c.Close()

sub, err := c.Submit(ctx, cronoq.Task{Payload: []byte(`{"to":"ada"}`), Key: "welcome-ada", Priority: cronoq.High})
res, err := c.Wait(ctx, cronoq.ID(sub.ID), 30*time.Second)
if err == nil && res.OK() {
	fmt.Println(string(res.Output))
}

Dial(target, Options)

target is http://host:port, https://host:port or host:port (plain). The connection is opened on the first call.

OptionDefaultDescription
TokennoneBearer token sent with every call. A token over plain http:// to a host other than loopback logs a warning.
TLSsystem roots*tls.Config for https:// targets (CA, client certificate for mTLS, server name). It needs an https:// target.
MaxMessageBytes64 MiBLargest message received or sent.
DialOptionsnoneExtra grpc.DialOptions.
Loggerslog.Default()Receives warnings.

Client

MethodReturnsDescription
Submit(ctx, Task)SubmittedSubmit one task. Submitted.Duplicate is true when a task with the same key existed; its id is returned.
SubmitAndWait(ctx, Task, timeout)SubmittedSubmit and wait up to timeout (0: the server's limits.max_wait_s); Submitted.Result holds the result.
SubmitSpec(ctx, *cronoqpb.TaskSpec, wait, timeout)SubmittedSubmit a raw spec, for fields Task does not cover.
SubmitBatch(ctx, []Task)[]SubmittedSubmit atomically; tasks may depend on each other's keys.
Get(ctx, Ref)*TaskInfoA task's details.
Wait(ctx, Ref, timeout)*ResultWait for a task to finish.
Cancel(ctx, Ref)boolCancel a task that has not finished.
ListTasks(ctx, ListOptions)*TaskPageOne page of tasks by pool and status, in id order. Pass NextAfter as After for the next page.
Watch(ctx, WatchOptions)iter.Seq2[Event, error]Status events until ctx ends or the stream fails (for example when the server loses leadership: watch again).
Snapshot(ctx)*cronoqpb.SnapshotResponsePools, queues and totals (metrics role).

Ref names a task: cronoq.ID(id) or cronoq.Key(key).

ListOptions fieldDefaultDescription
Poolsevery pool the caller may usePools to list.
StatusesanyStatuses to list.
After0Only tasks with a larger id; pass the previous page's NextAfter.
Limit0 (= 100)Page size, capped at the server's limits.max_list.

TaskPage.NextAfter is 0 on the last page.

WatchOptions fieldDefaultDescription
Poolsevery pool the caller may usePools to watch.
IDs, KeysnoneOnly events for these tasks.
AllTransitionsfalseStream every status change; without it, only terminal statuses are streamed.

Task fields: Payload, ContentType, Kind, Pool, Key, Tenant, Priority (cronoq.Critical, High, Normal, Low), Timeout (per attempt), Deadline (relative), StartIn, After ([]Dep{{Ref: cronoq.Key("extract"), Cond: cronoq.OnSuccess}}), Resources, Tags, Labels (worker labels the task requires) and MaxAttempts (replaces the pool's retry policy with this many attempts and the server's default backoff).

TaskInfo, Result and Event carry a Status (cronoq.Ready, Running, Succeeded, ...; Status.Done() is true for terminal ones). TaskPage.UnavailableShards names shards that could not be reached (Sharding).

Errors

Failed calls return a *cronoq.Error with the gRPC Code and Message (Errors):

var e *cronoq.Error
if errors.As(err, &e) && e.Timeout() { /* the task is still running */ }

Retryable() is true for UNAVAILABLE (not the leader, shutting down) and RESOURCE_EXHAUSTED (queue full); NotFound() for unknown tasks, pools and dependencies.

Workers

err := cronoq.RunWorker(ctx, cronoq.WorkerOptions{
	Target: "http://127.0.0.1:7070",
	Pool:   "default",
	Slots:  8,
	Token:  os.Getenv("CRONOQ_WORKER_TOKEN"),
}, func(ctx context.Context, l *cronoq.Lease) ([]byte, error) {
	return process(ctx, l.Payload)
})

RunWorker registers for the pool and runs the handler for each lease, at most Slots at a time (or fewer if the server grants fewer). It heartbeats every running lease, and reconnects with backoff when the connection drops (right away when the server announces a leadership change). When the pool belongs to another shard, it connects to that shard's endpoint; see below. When ctx ends, it stops taking leases and lets running handlers finish for up to DrainTimeout, then cancels the rest and returns nil. A cancelled handler that returns within 5 more seconds still has its outcome reported; after that its outcome is dropped (the server retries the task). It returns an error only for failures that retrying cannot fix, such as a rejected token or an unknown pool.

OptionDefaultDescription
Target, PoolrequiredServer and pool.
Slots1Leases run at once.
TokennoneBearer token with the worker role.
NameemptyShown in server logs and worker listings.
LabelsnoneWorker labels (Worker labels).
TLSsystem roots*tls.Config for https:// targets. A ServerName here applies to Target only.
NoReconnectfalseReturn when the connection fails instead of reconnecting.
InitialBackoff, MaxBackoff200 ms, 10 sReconnect delay bounds.
DrainTimeout30 sHow long running handlers may finish after ctx ends.
InsecureRedirectHTTPfalseFollow a shard redirect to plain http:// on another host with the token. For trusted networks only.
MaxMessageBytes64 MiBLargest message sent or received.
DialOptionsnoneExtra grpc.DialOptions.
Loggerslog.Default()Receives the worker's log messages.

Handlers

The handler gets a *Lease: ID, TaskID, Attempt, Payload, ContentType, Kind, Key, Pool, Priority, Tags, Timeout and Deadline. Its context ends when the server aborts the attempt (timeout, cancellation, lease expiry), when the connection to the server ends, or when the drain timeout passes. After an abort the outcome is not reported; after the drain timeout it is reported if the handler returns within 5 seconds. It returns the output, or an error:

ReturnEffect
output, nilThe task succeeds. An output larger than the server accepts fails it permanently.
cronoq.Permanent(err)The task fails without retrying.
cronoq.RetryAfter(err, d)Retryable failure, retried no earlier than d.
cronoq.Reject(reason)Hand the lease back unrun; the server offers it to another worker.
cronoq.ErrCancelTaskThe task ends as cancelled.
any other error, or a panicRetryable failure.

(Lease.TaskID, Lease.Attempt) is a fencing token: attempt numbers never repeat for a task, also across failovers. Record the pair with the handler's side effect and let only the newest attempt write (Exactly-once side effects).

Shards

A worker that connects to another shard's server is refused with the endpoint of its pool's shard, and RunWorker connects there (up to cronoq.MaxRedirects times in a row). It never follows a redirect from https:// to http://, sends its token over plain http:// only to loopback unless InsecureRedirectHTTP is set, and refuses endpoints with userinfo or a path. When that shard cannot be reached, it asks Target again. See Sharding.

TypeScript API

The TypeScript SDK (npm package @synaptiai/cronoq, in sdks/typescript) is a client for a cronoq server, built on Connect-ES. It runs in two places:

ImportTransportWhat works
@synaptiai/cronoq/nodegRPC over HTTP/2 (Node 22 or newer)The client and workers.
@synaptiai/cronoq/webgRPC-Web from a browserThe client: unary calls and watch. Workers need a bidirectional stream, which gRPC-Web cannot carry.

The package is published to GitHub Packages, whose npm registry needs a GitHub token to install any package, public ones included. Create a personal access token (classic) with the read:packages scope, put it in GITHUB_TOKEN, and point the @synaptiai scope at the registry:

echo '@synaptiai:registry=https://npm.pkg.github.com' >> .npmrc
echo '//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}' >> .npmrc
npm install @synaptiai/cronoq

Each GitHub release also carries the package tarball, which installs without a token:

npm install https://github.com/synaptiai/cronoq/releases/download/v0.2.1/synaptiai-cronoq-0.2.1.tgz

Submitting and waiting

import { connect, text } from "@synaptiai/cronoq/node";

const c = connect("http://127.0.0.1:7070", { token: process.env.CRONOQ_TOKEN });
const sub = await c.submit({ payload: { to: "ada" }, key: "welcome-ada", priority: "high" });
const res = await c.wait(sub.id, 30);
if (res.status === "succeeded") console.log(text(res.output));

Task ids are bigints: they use all 64 bits, which a number cannot hold. A task reference is an id (bigint) or a key (string). Times are in seconds.

connect(endpoint, options) (Node)

endpoint is http://host:port or https://host:port. The connection opens on the first call.

OptionDefaultDescription
tokennoneBearer token sent with every call. A token over plain http:// to a host other than loopback logs a warning.
tlsNode's roots{ ca, cert, key, servername } (PEM) for https:// endpoints; cert and key for mTLS. It needs an https:// endpoint.
maxMessageBytes64 MiBLargest message sent or received.
loggerconsoleReceives warnings.

connectWeb(baseUrl, { token }) (browser)

baseUrl is the server's gRPC listener, for example https://cronoq.example.com:7070. The server must list the page's origin in [server.grpc_web] (gRPC-Web). A token in a page is visible to whoever uses the page: give browsers their own token, limited to the pools and role they need.

Client

MethodResolves toDescription
submit(task)SubmittedSubmit one task. duplicate is true when a task with the same key existed; its id is returned.
submitAndWait(task, waitTimeout?)SubmittedSubmit and wait up to waitTimeout seconds (default: the server's limits.max_wait_s); result holds the result.
submitBatch(tasks)Submitted[]Submit atomically; tasks may depend on each other's keys. Accepts Tasks or raw pb.TaskSpecs.
get(ref)TaskInfoA task's details.
wait(ref, timeout?)ResultWait for a task to finish.
cancel(ref)booleanCancel a task that has not finished.
listTasks({ pools, statuses, after, limit })TaskPageOne page of tasks, in id order. Pass nextAfter as after for the next page.
watch({ pools, ids, keys, allTransitions }, { signal })AsyncGenerator<Event>Status events until signal aborts or the stream fails (for example when the server loses leadership: watch again).
snapshot()pb.SnapshotResponsePools, queues and totals (metrics role).
listTasks filterDefaultDescription
poolsevery pool the caller may usePools to list.
statusesanyStatuses to list.
after0nOnly tasks with a larger id; pass the previous page's nextAfter.
limit0 (= 100)Page size, capped at the server's limits.max_list.
watch filterDefaultDescription
poolsevery pool the caller may usePools to watch.
ids, keysnoneOnly events for these tasks.
allTransitionsfalseStream every status change; without it, only terminal statuses are streamed.

Every method except watch also takes { signal, timeout } call options; watch takes only { signal }. client.tasks and client.admin are the generated Connect clients, for calls the class does not wrap (such as addRecurring or listWorkers); pb exports the generated messages.

Task fields: payload, contentType, kind, pool, key, tenant, priority ("critical", "high", "normal", "low"), timeout (per attempt), deadline (relative), startIn, after (references, or { ref, cond } with cond "on_success", "on_complete" or "on_failure"), resources, tags, labels (worker labels the task requires) and maxAttempts (replaces the pool's retry policy with this many attempts and the server's default backoff).

Payloads and outputs: a Uint8Array is sent as is, a string as UTF-8 text, null or undefined as empty, and anything else as JSON. text(bytes) and json(bytes) decode them.

TaskInfo, Result and Event carry a status ("ready", "running", "succeeded", ...; isDone(status) is true for terminal ones). TaskPage.unavailableShards names shards that could not be reached (Sharding).

Errors

Failed calls reject with a CronoqError: code is the gRPC status name in lower case ("not_found", "unavailable", "cancelled", ...), and metadata holds the response metadata (Errors). retryable is true for unavailable (not the leader, shutting down) and resource_exhausted (queue full); timeout for a wait that ended while the task was still running; notFound for unknown tasks, pools and dependencies. In browsers, only the metadata the server exposes to CORS requests is visible.

Workers (Node)

import { runWorker } from "@synaptiai/cronoq/node";

const stop = new AbortController();
process.on("SIGTERM", () => stop.abort());
await runWorker(
  { target: "http://127.0.0.1:7070", pool: "default", slots: 8, token: process.env.CRONOQ_WORKER_TOKEN, signal: stop.signal },
  async (lease) => handle(lease.json()),
);

runWorker registers for the pool and runs the handler for each lease, at most slots at a time (or fewer if the server grants fewer). It heartbeats every running lease, and reconnects with backoff when the connection drops (right away when the server announces a leadership change). When the pool belongs to another shard, it connects to that shard's endpoint; see below. When signal aborts, it stops taking leases and lets running handlers finish for up to drainTimeout, then aborts the rest and resolves. An aborted handler that returns within 5 more seconds still has its outcome reported; after that its outcome is dropped (the server retries the task). It rejects only for failures that retrying cannot fix, such as a rejected token or an unknown pool.

OptionDefaultDescription
target, poolrequiredServer and pool.
slots1Leases run at once.
tokennoneBearer token with the worker role.
nameemptyShown in server logs and worker listings.
labelsnoneWorker labels (Worker labels).
tlsNode's rootsAs for connect; servername applies to target only. TLS settings with an http:// target are refused.
signalnoneStops the worker.
noReconnectfalseReject when the connection fails instead of reconnecting.
initialBackoff, maxBackoff0.2 s, 10 sReconnect delay bounds.
drainTimeout30 sHow long running handlers may finish after signal aborts.
insecureRedirectHttpfalseFollow a shard redirect to plain http:// on another host with the token. For trusted networks only.
maxMessageBytes64 MiBLargest message sent or received.
onRegisterednoneCalled after each registration with the worker id, granted slots and endpoint.
loggerconsoleReceives the worker's log messages.

Handlers

The handler gets a Lease: id, taskId, attempt, payload, contentType, kind, key, pool, priority, tags, timeout, deadline, signal, and text() and json(). signal aborts when the server aborts the attempt (timeout, cancellation, lease expiry), when the connection to the server ends, or when the drain timeout passes. After an abort by the server the outcome is not reported; after the drain timeout it is reported if the handler returns within 5 seconds. The handler returns the output, or throws:

ThrowEffect
nothingThe task succeeds. An output larger than the server accepts, or a return value that cannot be serialised to JSON, fails it permanently.
new Permanent(msg)The task fails without retrying.
new Retryable(msg, retryAfter)Retryable failure, retried no earlier than retryAfter seconds.
new Reject(reason)Hand the lease back unrun; the server offers it to another worker.
new CancelTask(msg)The task ends as cancelled.
anything elseRetryable failure.

(lease.taskId, lease.attempt) is a fencing token: attempt numbers never repeat for a task, also across failovers. Record the pair with the handler's side effect and let only the newest attempt write (Exactly-once side effects).

Shards

A worker that connects to another shard's server is refused with the endpoint of its pool's shard, and runWorker connects there (up to MAX_REDIRECTS times in a row). It never follows a redirect from https:// to http://, sends its token over plain http:// only to loopback unless insecureRedirectHttp is set, and refuses endpoints with userinfo or a path. When that shard cannot be reached, it asks target again. See Sharding.

Metrics

cronoq reports three views of the same state:

  • Prometheus text at /metrics (cronoq-server).
  • OpenTelemetry instruments and tracing spans (the cronoq crate's default otel feature).
  • Snapshots (Scheduler::snapshot(), await sched.snapshot(), GET /v1/snapshot, Admin.Snapshot), described in Metrics and quality signals.

Window-based values cover the engine's sliding metrics window, kept in 12 buckets. The window is the last 60 seconds by default; the Rust crate sets it with CoreConfig::metrics_window (through Builder::configure), and cronoq-server and the Python package always use 60 seconds.

Prometheus (cronoq-server)

Served at GET /metrics on server.http_addr (requires the metrics or admin role) and, without authentication, on server.metrics_addr when it is set. Content type text/plain; version=0.0.4; charset=utf-8. Values are computed from a snapshot at scrape time. A standby server answers 503: scrape every instance, and the leader's series are the live ones.

Label values are escaped per the exposition format (\\, \", \n).

Per pool (label pool)

MetricTypeDescription
cronoq_pool_workersgaugeCurrent capacity: the slots of connected workers.
cronoq_pool_desired_workersgaugeAutoscaler target capacity. Feed it to KEDA or an HPA.
cronoq_pool_busy_workersgaugeSlots running an attempt.
cronoq_pool_held_workersgaugeSlots held idle for an upcoming fixed-start task.
cronoq_pool_workers_connectedgaugeConnected worker streams.
cronoq_pool_pendinggaugeTasks waiting to run, by priority (extra label class: critical, high, normal, low).
cronoq_pool_reservationsgaugeFixed-start reservations not yet started.
cronoq_pool_priority_adherencegaugeWindowed fraction of dispatches with no strictly higher class waiting (0–1).
cronoq_pool_fixed_start_hit_rategaugeWindowed fraction of fixed starts that began within tolerance (0–1).
cronoq_pool_utilizationgaugeWindowed busy slot-seconds ÷ capacity slot-seconds (0–1).
cronoq_pool_schedule_efficiencygaugeWindowed completed work seconds ÷ capacity slot-seconds (0–1).
cronoq_pool_wait_secondsgaugeWindowed time from submission to first start, by quantile (0.5, 0.9, 0.99).
cronoq_pool_cost_totalcounterWorker cost since the leader started (cost_per_worker_second × worker-seconds).
cronoq_pool_dispatched_windowgaugeAttempts dispatched within the current metrics window (default 60 s). It is a windowed count that can go down; for a lifetime rate use rate(cronoq_attempts_dispatched_total[5m]).

Server-wide

MetricTypeLabelsDescription
cronoq_workers_connectedgaugeConnected worker streams, all pools.
cronoq_live_tasksgaugeTasks not yet finished.
cronoq_running_tasksgaugeAttempts running.
cronoq_pending_tasksgaugeTasks waiting to run.
cronoq_tasks_totalcounterstatusTasks that reached succeeded, failed, dead_lettered, cancelled, skipped, missed or shed.
cronoq_tasks_submitted_totalcounterTasks admitted.
cronoq_attempts_dispatched_totalcounterAttempts dispatched.
cronoq_retries_totalcounterRetries scheduled.
cronoq_timeouts_totalcounterAttempts that hit their timeout.
cronoq_lease_expiries_totalcounterLeases that expired without a heartbeat.
cronoq_stale_completions_totalcounterCompletions and failures reported for leases that were no longer valid (late, duplicate, or from a previous leader); ignored.

Counters start at zero when a server becomes leader; rate() and increase() handle the reset on failover.

Example alerts

groups:
  - name: cronoq
    rules:
      - alert: CronoqQueueWaitHigh
        expr: cronoq_pool_wait_seconds{quantile="0.99"} > 30
        for: 10m
      - alert: CronoqUnderProvisioned
        expr: cronoq_pool_desired_workers > cronoq_pool_workers
        for: 15m
      - alert: CronoqFixedStartsMissed
        expr: cronoq_pool_fixed_start_hit_rate < 0.99
        for: 15m
      - alert: CronoqDeadLetters
        expr: increase(cronoq_tasks_total{status="dead_lettered"}[10m]) > 0
      - alert: CronoqNoWorkers
        expr: sum by (pool) (cronoq_pool_pending) > 0 and on (pool) cronoq_pool_workers_connected == 0
        for: 5m

OpenTelemetry (cronoq crate)

With the otel feature (on by default), the runtime records into the global meter provider under the meter name cronoq, so it reports into whatever pipeline the application installed. Without a provider the instruments are no-ops.

InstrumentKindAttributesDescription
cronoq.tasks.submittedcounterpoolTasks admitted.
cronoq.tasks.finishedcounterpool, statusTasks reaching a terminal status.
cronoq.attemptscounterpool, kind, outcome (ok / error)Finished attempts.
cronoq.attempt.durationhistogram (s)pool, kind, outcomeAttempt run time.
cronoq.pool.workersobservable gaugepoolCurrent capacity.
cronoq.pool.desired_workersobservable gaugepoolAutoscaler target.
cronoq.pool.busy_workersobservable gaugepoolBusy slots.
cronoq.pool.held_workersobservable gaugepoolHeld slots.
cronoq.pool.pendingobservable gaugepool, classPending tasks by class.
cronoq.pool.costobservable gaugepoolCost since start.

Observable gauges read values the runtime refreshes once per second.

Traces

Each attempt runs inside a tracing span named cronoq.task with the fields task.id, task.pool, task.kind and task.attempt. When a task is submitted inside a span and an OpenTelemetry text-map propagator is installed, the submitter's W3C trace context is stored in the task's traceparent tag; with tracing-opentelemetry installed, each attempt's span becomes a child of the submitting trace, across retries. Executors can read it with ctx.traceparent() to propagate it further.

Snapshot

All snapshot fields are described in Metrics and quality signals and, for the server's JSON form, in the HTTP reference.

Errors and status codes

This page maps every error across the Rust API, the Python API and the server's gRPC and HTTP APIs, and says what a caller should do about it.

Submission errors

SubmitErrorPython SubmitError.codegRPCHTTPCauseWhat to do
UnknownPool(name)unknown_poolNOT_FOUND404No pool with that name.Fix the pool name.
UnknownResource(name)unknown_resourceNOT_FOUND404No resource with that name.Fix the resource name.
ResourceExceedsCapacity(name)resource_exceeds_capacityINVALID_ARGUMENT400Requested units exceed the resource's permits or burst, so it could never run.Request fewer units.
ReservationConflictreservation_conflictFAILED_PRECONDITION409Fixed-start reservations would exceed the pool's reservable capacity at that time.Pick another start time, raise reservable_fraction or max.
CyclecycleINVALID_ARGUMENT400The dependencies form a cycle.Fix the graph.
UnknownDependency(ref)unknown_dependencyNOT_FOUND404Dependency on an unknown id, or an unknown key when the pool rejects unknown dependencies.Submit the parent first, or set await_unknown_dependencies_s.
FullfullRESOURCE_EXHAUSTED429Queue bounds reached with the wait overflow policy. In-process callers wait for space instead of seeing this.Retry with backoff.
OverloadedoverloadedRESOURCE_EXHAUSTED429Bounds reached with reject, or shedding found nothing lower to evict.Retry with backoff, or shed load upstream.
DuplicateKeyInBatch(key)duplicate_key_in_batchINVALID_ARGUMENT400A batch defines the same new key twice.Deduplicate the batch.
Invalid(reason)invalidINVALID_ARGUMENT400Malformed spec: zero resource units, max_attempts = 0, NUL in a string, ...Fix the request.

The server also returns INVALID_ARGUMENT / 400 for requests that fail its own checks ([limits], unknown JSON fields, both relative and absolute times) and PERMISSION_DENIED / 403 for pools or tenants outside the credentials' scope.

Task outcomes

Awaiting a task (TaskHandle in Rust, await handle in Python, Wait or GET .../result on the server) ends with one of:

StatusRustPythonMeaning
succeededOk(Arc<O>)the return valueThe executor returned an output.
succeeded (output gone)Err(TaskFailure::OutputUnavailable { id })TaskFailed(status="succeeded")Succeeded, but the output is no longer cached or stored (for example a duplicate submission long after the original finished). Server: output_available = false.
failedErr(Ended { status: Failed, .. })TaskFailed(status="failed")Permanent error, or a fixed start missed with on_miss = fail.
dead_letteredEnded { status: DeadLettered, .. }"dead_lettered"Retryable failures used up every attempt.
cancelledEnded { status: Cancelled, .. }"cancelled"Cancelled by a caller.
skippedEnded { status: Skipped, .. }"skipped"A dependency edge can no longer be met, an unknown dependency was awaited too long, or a recurring occurrence overlapped the previous one.
missedEnded { status: Missed, .. }"missed"Fixed start missed with on_miss = skip.
shedEnded { status: Shed, .. }"shed"Evicted by the shed_lowest / shed_oldest overflow policy.
–Err(TaskFailure::Shutdown)"shutdown"The scheduler stopped before the task finished. Server: UNAVAILABLE / 503, wait again on the new leader.

message carries the last error message (or the status name).

Executor errors

What an executor returns decides whether the attempt is retried:

Rust TaskErrorPythonRemote worker (Fail)Effect
Retryable { message, retry_after }raise Retryable(msg, retry_after=s), or any other exceptionpermanent = false, retry_after_sRetried per the retry policy, not before retry_after.
Permanent { message }raise Permanent(msg)permanent = trueFails the task now (failed).
CancelledCancelledError caused by cronoqCancelledThe attempt was aborted; the engine already decided what happens next.
panic in a Rust executor–panic in a run_worker handler: retryableIn-process: permanent failure (Failed). The scheduler keeps running.

Timeouts (timeout) and lease expiries (no heartbeat within lease_ttl) are retryable failures too.

Scheduler errors

Rust ErrorPythonMeaning
Submit(SubmitError)SubmitErrorSee above.
ShutdownSchedulerErrorThe scheduler has stopped or is stopping.
Config(String)ValueError (at construction) or SchedulerErrorInvalid configuration: no pools, missing executor, duplicate pool, min > max, bad cron or time zone, recurring template with one-shot fields.
Store(StoreError)SchedulerErrorPersistence failed fatally (see below).
Internal(String)SchedulerErrorInternal failure, for example the scheduler task panicked.

Python raises RuntimeError when a scheduler is used before start(), and SchedulerError when its event loop was closed without a shutdown.

Store errors

StoreErrorDriver behaviour
Unavailable(String)Transient (connection lost, timeout, database restarting). Retried with capped backoff for up to max_store_outage (default 5 min) while the engine keeps running; replies and dispatches that need durability wait. After that the scheduler stops with Error::Store.
Other(String)Retried a few times (5), then the scheduler stops.
FencedAnother instance took leadership. The scheduler stops immediately and writes nothing more.

The server reports store failures as UNAVAILABLE / 503 and logs the details; it does not return database messages to clients.

Client errors (Python cronoq.client)

ExceptionWhen
ClientErrorA gRPC call failed. .code is the lower-case status name (unauthenticated, permission_denied, not_found, resource_exhausted, failed_precondition, invalid_argument, unavailable, data_loss, ...).
TimeoutErrorClient.wait(timeout=...) or submit(..., call_timeout=...) ran out of time.

Server status codes

gRPCHTTPRetry?Cause
INVALID_ARGUMENT400noMalformed request or spec.
UNAUTHENTICATED401noMissing or unknown credentials.
PERMISSION_DENIED403noRole, pool scope, tenant or worker labels do not allow it, or authentication is off (without --insecure-no-auth) and the request does not name a loopback host.
NOT_FOUND404noUnknown task, pool, resource or dependency.
ALREADY_EXISTS409noThe key is already used by a task in a pool outside the credentials' scope.
FAILED_PRECONDITION409after changing the requestFixed-start reservation conflict; a task or pool served by another shard that this server cannot forward to (the response names the shard); a key that names tasks in several shards (use the id); a worker with an older protocol version.
–413, 415noBody too large, wrong content type.
RESOURCE_EXHAUSTED429yes, with backoffQueue bounds reached.
INTERNAL500maybeUnexpected server error.
UNAVAILABLE503yes, with backoffNot the leader, shutting down, store unavailable.
DEADLINE_EXCEEDED504wait againA wait timed out while the task is still running; over HTTP, also a request that exceeded its time limit.
CANCELLED–maybeA gRPC call ran longer than limits.max_wait_s + limits.request_timeout_s.
DATA_LOSS–reconnectA Watch stream fell behind.

Submissions with a key are idempotent while the original task is live, and after it finished for as long as the server keeps its key (limits.key_retention_s, default 3600 s, and at most limits.key_retention_max finished tasks). Within that window, retrying a submit after UNAVAILABLE or a lost connection does not create a second task: the retry returns the original id with duplicate = true.

Architecture

 ┌──────────────── cronoq (Rust API) · cronoq (Python) · cronoq-server (gRPC/HTTP) ───────────────┐
 │                                                                                               │
 │  Scheduler handle ──commands (bounded mpsc, batched)──▶ ┌──────────────── driver task ───────┐  │
 │  TaskHandle ◀──────oneshot results / broadcast events── │  owns   cronoq-core::Core (sans-IO)│  │
 │                                                         │  sleep_until(core.next_wakeup())   │  │
 │  executors ──feedback (completions, heartbeats)───────▶ │  executes Actions:                 │  │
 │                                                         │   Dispatch → spawn executor        │  │
 │                                                         │   Abort    → cancel attempt        │  │
 │                                                         │   ScaleTo  → Scaler                │  │
 │                                                         │   Persist  → Store (group commit)  │  │
 │                                                         └────────────────────────────────────┘  │
 └───────────────────────────────────────────────────────────────────────────────────────────────┘

Crates

CrateResponsibility
cronoq-coreThe scheduling engine. No clock, threads, I/O or unsafe.
cronoqTokio driver, public API, executors, scalers, Store trait, telemetry, blocking facade.
cronoq-store-postgresDurable Store with leader election and fencing.
cronoq-store-sqliteDurable Store in a local file, with a lease for leader election and fencing.
cronoq-protoProtobuf definitions (vendored protoc, no system dependency).
cronoq-servergRPC and HTTP services, remote executor, auth, metrics, worker client.
cronoq-pyPyO3 bindings (abi3), compat shim and gRPC client.
cronoq-cliThe cronoq command-line client for the server, with a live top view.
cronoq-operatorKubernetes operator that scales worker Deployments to their pool's desired capacity.
cronoq-store-testsConformance tests every durable store must pass.

A sans-IO engine

Core<P> is a deterministic state machine. Each call receives the current time; the engine updates its state and queues actions for the driver (dispatch this task, abort that lease, request N workers, persist these mutations, publish this status). It never reads a clock or performs I/O.

Consequences:

  • Determinism. The same inputs produce the same outputs. Randomness (retry jitter) comes from a seeded generator.
  • Testability. The test suite drives the engine under virtual time: hours of simulated workload run in milliseconds.
  • Portability. The same engine powers the Rust runtime, the Python bindings and the server; a different runtime (another async executor, a simulator) only needs a new driver.

Data structures

StructurePurposeCost
Slab of task entriesTask storage; queues hold 8-byte keysO(1) insert/remove
Per-class FIFO lane (sorted deque by sequence)Ready tasks without deadlines, in pools without fairnessO(1) push/pop; O(n) reinsertion for retries (binary search, then insert into the deque)
Per-class deadline queue (B-tree by latest start time)Ready tasks with deadlinesO(log n)
Per-class fair-share queue (B-tree by start tag)Ready tasks without deadlines, in pools with fairnessO(log n)
Due queue and pending holdsFixed-start tasks at or near their startO(log n)
Reservation calendar (B-tree by start)Admission of fixed startsO(k log k) for k overlapping
Resource waiter queuesTasks parked on a resource, FIFOO(1) amortized
Label waiter queues (one FIFO per label constraint)Tasks waiting for a free slot on a worker with the labels they requireO(log c) for c constraints, then O(1)
Dependency counters + reverse edgesDAG resolutionO(children) per completion
Timer heapRetries, holds, starts, misses, timeouts, leases, refills, autoscaling, cronO(log n), lazy invalidation
Retention tableTerminal statuses for late dependents and dedupO(1)
Window ring + log-linear histogramsMetricsO(1) per event

Dispatch compares the heads of at most eight queues (for each of the four classes: the deadline queue and either the FIFO lane or, in pools with fairness, the fair-share queue), computing their effective scores lazily. Every task in a class ages at the same rate, so in a FIFO lane the oldest task is always the best of its lane. The deadline queue's head is the task with the earliest latest start time, and the fair-share queue's head is the task with the lowest start tag. No periodic rescoring is needed.

Driver loop

The driver task waits on five sources (executor feedback, commands, handle cancellations, the store commit in flight and the engine's next timer), applies them to the engine, then executes the resulting actions. With a durable store, the mutations queued since the last commit are written in one transaction while the loop keeps running. Replies, dispatches and status updates that depend on those mutations are held until that commit succeeds, so acknowledgements imply durability.

Server

The server wraps a Scheduler<Bytes, Bytes> whose pools all use a remote executor: a dispatch becomes a Lease message to a connected worker with a free slot, and the attempt's future resolves when that worker reports back, disconnects, or the engine aborts the attempt. Pool capacity is the sum of connected workers' slots.

Testing strategy

LayerApproach
EngineUnit tests per feature; property tests for data structures; randomized deterministic simulation: hundreds of seeded workloads mixing microtasks, heavy-tailed jobs, deadlines, fixed starts, DAGs, resources, failures, cancellations and capacity changes, with internal invariants checked after every step and end-to-end properties (dependency order, FIFO within class, every task terminates) checked at the end.
RuntimeTests under tokio's paused clock; OpenTelemetry export test.
StoreOne conformance suite run against every store (throwaway PostgreSQL servers, temporary SQLite files); fencing, pg_terminate_backend failover, lease expiry and takeover.
ServerReal gRPC/HTTP listeners with real workers; active-passive failover test.
Pythonpytest suites for the bindings, the compat shim and the client against a real server, run against built wheels on Linux, macOS and Windows.
Gogo test -race for the SDK against a real server, with the generated stubs checked against the protocol.
TypeScriptnode:test suites for the SDK against a real server on Node 22 and 24, and the gRPC-Web client in headless Chromium; the generated code is checked against the protocol.
KubernetesThe Helm chart rendered with several value sets, each configuration checked by cronoq-server --check; on a kind cluster, failover between two replicas on PostgreSQL, a rolling upgrade, a two-shard release, and the operator scaling a worker Deployment.
PerformanceCriterion benchmarks with throughput gates in CI.

FAQ

Is execution exactly-once?

Execution is at-least-once: an attempt can run again if its worker crashes, misses heartbeats, or the leader fails over mid-attempt. Side effects can still be exactly-once: record the task id and attempt number (a fencing token) with each effect and let only the newest attempt write. See Exactly-once side effects. Task keys make submission idempotent.

Does a deadline cancel the task when it passes?

No. Deadlines raise urgency; they never cancel. Use timeout to bound an attempt, or cancel the task yourself.

What is the difference between a deadline and a fixed start?

A deadline says when work should be finished and lets cronoq reorder work to meet it. A fixed start says when work should begin; cronoq reserves capacity and holds a worker for it.

Can low-priority work starve?

Not behind normal traffic: aging lets a waiting low task overtake fresh normal work after about 45 seconds with the default settings. It can wait behind sustained high or critical load, by design. For stronger mixing, raise age_cap; it is configurable only from the Rust library (Builder::priority or Builder::configure), and the server and the Python package use the defaults.

How many tasks can the engine hold?

Memory per pending task is a few hundred bytes plus its payload. Millions of pending tasks are practical; use max_pending to bound them.

Can I use cronoq without tokio?

From Rust, use BlockingScheduler (it owns a private tokio runtime), or drive cronoq-core yourself. From Python, the bindings manage their own runtime.

Do I need Postgres?

No. Without a store, state is in memory. For durability on one host, use the SQLite store (a local file, with standby processes on the same host). Use Postgres to run replicas on several hosts.

How do workers find the leader in an HA setup?

Through a load balancer or Kubernetes Service that routes only to ready endpoints. Only the leader reports /readyz = 200.

Can tasks pass data to their dependents?

Not directly. Put references (object-store paths, database ids) in payloads, or read the parent's output with Wait / TaskHandle in the producer and submit the child afterwards.

How do I limit tokens per minute rather than requests?

Create a token bucket whose units are, for example, thousands of tokens, and have each task request ceil(estimated_tokens / 1000) units.

Why are my server tasks not running?

A server pool's capacity is its connected workers' slots. Check cronoq_workers_connected and cronoq_pool_workers{pool=...}; then check that the task's resources exist and are not exhausted.

Is there a UI?

There is no web UI. cronoq top gives a live view in the terminal; for the rest, use /v1/snapshot, the Admin service, Prometheus/Grafana and status events.

What happens when the database is unavailable?

The scheduler keeps running in memory and retries the commit with backoff for up to max_store_outage (5 minutes by default). Meanwhile, calls that need durability (submissions, cancellations, schedule changes) wait, and no new attempts start. If the outage lasts longer, the scheduler stops and those callers get a store error (UNAVAILABLE / 503 on the server); another replica takes over once the database is back. See Durability and HA.

Can several teams share one server?

Within one trust domain, yes: give each team tokens scoped to its pools (pools = [...]), so it can only submit into, serve, watch and look up its own pools. Keys and resources are shared across pools, so teams that must not learn about each other's work need separate servers. See Security hardening.

How do I upgrade a cluster with a durable store?

The new version applies its schema migrations when it connects, and an older build refuses to start against a newer schema, so upgrades roll forward only. When a release adds no migration, replace the standbys first and the leader last; accepted work survives the leader change and workers reconnect on their own. When the changelog lists a migration, follow its upgrade notes (or stop all replicas, then start the new version).

Glossary

Aging: the increase of a waiting task's score over time, preventing starvation.

Attempt: one execution of a task. A task has one or more attempts.

Backlog: tasks ready to run but waiting for a slot.

Class: a priority level: critical, high, normal or low.

Deadline: a soft time by which a task should be done; it raises urgency.

Dead letter: terminal status for a task whose retryable failures used every attempt.

Driver: the async task that owns the engine, executes its actions and feeds back results.

Edge condition: when a dependency is satisfied: on success, on failure or on completion.

Effective score: base score of the class plus aging plus deadline urgency; the highest dispatches first.

Engine / core: the sans-IO state machine (cronoq-core) that makes all scheduling decisions.

Epoch: a counter incremented by each new leader; used for fencing and embedded in task ids.

Executor: the code that runs an attempt in-process.

Fencing: rejecting writes from a leader whose epoch is no longer current.

Fixed start: a time at which a task should begin, backed by a reservation.

Hold: a worker slot kept free for a fixed-start task about to begin.

Key: an optional unique name of a task, used for deduplication and references.

Latest start time (LST): deadline minus estimated duration.

Lease: the identity of one attempt; remote workers heartbeat it to keep it alive.

Lease TTL: how long a lease survives without a heartbeat.

Miss policy: what happens to a fixed-start task that could not start within its tolerance.

Parked: a ready task waiting for a resource, outside the dispatch queue.

Pending: submitted but not running (counted against queue bounds).

Pool: a group of worker slots with one executor, one autoscaling policy and its own bounds.

Reservation: a booked interval of pool capacity for a fixed-start task.

Resource: a shared limit (semaphore or token bucket) that tasks request units of.

Retention window: how long terminal statuses are remembered.

Shed: evicted under overload by a shedding overflow policy.

Slot: capacity to run one attempt at a time.

Snapshot: per-pool state, quality metrics over the sliding window, and counters since start.

Tolerance: how far from its fixed start a task may begin and still count as on time.

Worker: a process connected to cronoq-server that runs leased attempts.

Changelog

All notable changes to this project are documented here. The format follows Keep a Changelog, and the project adheres to Semantic Versioning. Before 1.0, minor versions may contain breaking changes; they are called out under Changed.

Unreleased

0.2.1 - 2026-10-27

A documentation and release-tooling release. The code that runs is the same as in 0.2.0.

Fixed

  • The README, the Python package's README (its PyPI page) and the book now say exactly what the code does:
    • aging lets long-waiting background work overtake ordinary work, but never High or Critical work;
    • fixed starts and cron runs reserve capacity ahead of their start, and a run that cannot get it is reported as a conflict;
    • a token's tenant applies to what it submits, and does not hide other tenants' tasks;
    • workers use attempt numbers as fencing tokens, and the server fences reports by lease;
    • client calls reach the right shard through any server, and workers are redirected to it;
    • KEDA reads the desired-capacity metric; there is no KEDA scaler;
    • OpenTelemetry metrics and trace propagation are in the Rust library only;
    • resources are shared within a shard, and SQLite standbys run on the same host;
    • choosing the next task looks only at queue heads, and re-queuing a retried task into a very long queue moves the entries behind it.
  • The Rust crate's documentation no longer calls aging starvation-free.
  • Every quickstart in the README and the book runs as written: the Python client and worker snippets use asyncio.run, and the server quickstarts use the published container image.
  • The README has an install section for every package.
  • The release workflow publishes the TypeScript package by file path. In 0.2.0, npm read the path as a GitHub repository, and the package was published afterwards with the new manual Finish release workflow.

Project

  • The Go SDK's sdks/go/v0.2.0 tag points at the commit before the 0.2.0 release tag; its code is the same. From 0.2.1, the Go SDK tag and the release tag point at the same commit.
  • The TypeScript tests no longer race for ports or leave a log file open per server start.

0.2.0 - 2026-10-26

First public release. No 0.1.0 was published.

Added

Scheduling engine (cronoq-core)

  • Sans-IO, deterministic engine: every input carries the current time, so the whole scheduler runs under virtual time in tests and simulations. Engine time is Unix time in nanoseconds.
  • Four priority classes (critical, high, normal, low), FIFO within a class, and aging so that no class starves. Dispatch compares the heads of each class's queues and never rescans the backlog.
  • Deadlines with a monotonic urgency curve and earliest-deadline-first ordering within a class.
  • Fixed-start tasks: capacity reservations at admission (ReservationConflict when a pool's reservable share is full), worker holds with a lead time learned from the pool's run times (re-evaluated as the start approaches), zero-tolerance starts, and miss policies (RunLate, Skip, Fail).
  • Cron schedules with seconds, time zones and daylight-saving handling, whole-second occurrences, overlap policies (Skip, Allow, and Queue / QueueOnSuccess to run occurrences one after another), deadlines relative to each occurrence, per-occurrence keys (<key>@<start>), and at most one late run after downtime.
  • Dependencies by id or key with on_success, on_failure and on_complete edges, awaiting unknown keys with a timeout, cycle detection, atomic batches and idempotent keys.
  • Leases with heartbeats, per-attempt timeouts, retries with exponential backoff, jitter and retry_after, dead-lettering and cancellation.
  • What effectively-once side effects need: attempt numbers that never repeat for a task (the fencing token, also across failovers), lease ids that carry the leader epoch, stale reports ignored and counted (stale_completions), and key deduplication of finished tasks within the retention window.
  • Shared semaphores and token buckets, with tasks waiting for a resource parked without holding a worker.
  • Weighted fair sharing between tenants (PoolConfig::fairness, TaskSpec::tenant): within a priority class, tenants with waiting work get dispatches in proportion to their weights, with no banked credit for idle tenants (start-time fair queuing). Server pools take tenant_weights / fair_share, and tokens can pin a tenant.
  • Worker labels: a pool's workers form groups by label set, a task can require labels and runs only in a matching group, waiting without blocking the pool while none is free; tasks without requirements prefer the least specialized group.
  • Bounded queues per pool, per class and globally, with Wait, Reject, ShedLowest and ShedOldest overflow policies. Shedding considers parked tasks, never sheds fixed-start tasks, and only happens for submissions that pass every other admission check.
  • Queue-pressure autoscaling per pool (arrival rate, backlog, wait-time objectives, upcoming reservations) with stabilization, cooldown, step limits and cost tracking. An idle pool returns to its minimum: demand below 1% of a slot counts as none.
  • Windowed quality metrics: utilization, schedule efficiency, wait and run percentiles, priority adherence and fixed-start hit rate.
  • Recovery from a durable store, including dependencies on parents that left retention and the recurring overlap guard. CoreConfig::validate reports configuration errors up front.

Runtime (cronoq)

  • Tokio driver with an async API (Scheduler, TaskHandle, submit, enqueue, enqueue_many, submit_batch, cancel, status, snapshot, events) and a blocking facade (BlockingScheduler). Blocking handles wait on their own thread, without the runtime's timer, so waiting while the scheduler shuts down cannot panic.
  • Executors from closures or the Executor trait, with cancellation tokens, heartbeats and TaskContext::for_test for unit tests. Executor panics are isolated.
  • Pluggable scalers (in-process slots, external) and stores.
  • CoreConfig::id_shard puts a shard number in the top 8 bits of task ids (TaskId::shard), and a sharded scheduler refuses a store that holds another shard's tasks.
  • Listing: Scheduler::list_tasks pages through live tasks by pool and status, Scheduler::list_recurring shows schedules with their next occurrence, and durable stores list finished tasks (StoreQueries::list_finished; TaskRecord carries the tenant).
  • Durable acknowledgements: with a persistent store, submissions, cancellations and schedule changes return only after they are committed, and work is dispatched only once durable.
  • Store error handling: transient outages are ridden out for up to max_store_outage (default 5 minutes), other errors are retried, fencing stops the scheduler immediately.
  • Graceful shutdown with a grace period, drop_grace for dropped schedulers, a bounded output cache with TaskFailure::OutputUnavailable, and a configurable status-event buffer.
  • OpenTelemetry metrics and W3C trace propagation (otel feature, on by default).

Postgres store (cronoq-store-postgres)

  • Durable state with group commit, advisory-lock leader election, epoch fencing, session liveness checks with a configurable timeout, commit timeouts, output storage, lookups and pruning.
  • Versioned migrations (cronoq_schema_version), a configurable schema per cluster, an id high-water mark so ids are never reused after pruning, NUL-safe text storage, and quarantine of undecodable rows instead of failed recoveries.

SQLite store (cronoq-store-sqlite)

  • Durable state in one local file, with no database server: the same tables, migrations and recovery as the Postgres store, checked by the same conformance tests.
  • Leader election through a renewed lease, so standby processes on the same host take over when the leader stops; every commit checks the fencing epoch inside its write transaction.
  • WAL journal synced on every commit by default; the file is created with owner-only permissions.

Server (cronoq-server)

  • gRPC (Tasks, Workers, Admin) and HTTP/JSON APIs. HTTP returns ids as strings, has separate routes for ids and keys, and rejects unknown fields.
  • Remote workers over bidirectional streams, worker protocol version 3: granted slots, lease metadata, Nack hand-backs, Cancelled reports, Shutdown notices for restarts and leadership changes, and worker labels. Workers that speak an older version are refused.
  • Standbys close client connections about a second after accepting them (and a leader that steps down closes its own), so clients behind a load balancer that sends connections to every replica reconnect until they reach the leader. Connections stay open during a graceful shutdown.
  • Worker tokens and certificate mappings can restrict which labels a worker may claim (labels = ["name=value", ...]).
  • Watch with pool, id and key filters and optional intermediate transitions; richer Get results; waits bounded by limits.max_wait_s.
  • Sharding: [[shards]] split the pools between shards, each with its own leader, standbys and store (a Postgres schema or SQLite file per shard, from one shared configuration), started with --shard. Task ids carry the shard's number. Work for another shard is refused with its name and endpoint; dependencies and batches across shards are refused. Each resource belongs to one shard ([[resources]] shard), so shards cannot exceed a shared limit together. Any server forwards calls for another shard to it with the caller's token (re-checked there; never over plain HTTP to another host unless insecure_peer_http), and listings, Watch and snapshots cover every shard, naming unreachable shards in unavailable_shards. The Rust, Python, Go and TypeScript workers follow a refusal to their pool's shard, never from TLS to plain HTTP, and with a token over plain HTTP only to loopback unless insecure_redirect_http is set.
  • gRPC-Web for browser clients on the gRPC listener, off unless [server.grpc_web] lists the allowed origins: unary calls and Watch, the same authentication, CORS only for those origins, and in loopback-only mode only loopback hosts.
  • Listing: ListTasks / GET /v1/tasks pages through live and finished tasks by pool and status (pages capped by limits.max_list), and ListWorkers / GET /v1/workers and ListRecurring / GET /v1/recurring show connected workers and schedules. Each lists only the pools the caller's credentials allow.
  • limits.key_retention_s and key_retention_max set how long finished keys deduplicate submissions; the same window bounds what a new leader recovers.
  • Webhook and external scalers, Prometheus metrics, health and readiness endpoints, and active-passive HA with Postgres or SQLite.
  • Rust worker client (worker::run_worker) with slot enforcement, heartbeats, panic isolation, reconnect with backoff, TLS/mTLS and graceful drain.
  • --check to validate configuration, --print-proto to print the API.
  • cronoq_server::run and run_with_listeners, which serves on listeners the caller has already bound, to embed the server in a Rust program.

Command-line tool (cronoq-cli)

  • cronoq submits, inspects, cancels and waits for tasks, lists tasks, workers and schedules, manages schedules, streams status events, and prints JSON for scripts with -o json.
  • cronoq top shows pools, workers, schedules, live tasks and recent status events, refreshed every second; panes the token's role cannot read say which role they need.
  • The token comes from CRONOQ_TOKEN or --token-file, never the command line; TLS options are refused with an http:// server. Control characters in text from the server are shown as escapes, so other clients' task keys or messages cannot drive the operator's terminal.

Python (cronoq on PyPI)

  • Asyncio-native bindings for the engine with Python payloads (kept as Python objects in memory, encoded only for a durable store), sync and async executors, typed stubs and picklable exceptions.
  • Status events (Scheduler.events()) with a bounded buffer, lag reporting and on_lag="raise".
  • cronoq.compat.ElasticPriorityScheduler, a synchronous drop-in for code written against the original API.
  • Durable state with Scheduler(..., store=SqliteStore(path)): payloads and results are encoded with a codec (JSON by default, which never runs code when read back); several processes can share the file, with one leading and the others standing by.
  • cronoq.client: gRPC client and remote worker speaking worker protocol version 3, with worker labels, and listing of tasks (list_tasks, iter_tasks), workers and schedules.
  • Wheels for CPython 3.14+ (abi3) on Linux, macOS and Windows (x86_64 and arm64).

Go SDK (sdks/go)

  • Dial returns a client that submits (one task, atomic batches, or submit-and-wait), gets, waits for, cancels, lists and watches tasks, and reads snapshots; errors carry the gRPC code.
  • RunWorker serves a pool with slots, heartbeats, reconnects with backoff, graceful draining, aborts that end the handler's context, panics as retryable failures, and the attempt number as a fencing token. It follows its pool to the right shard under the same rules as the Rust and Python workers.
  • The generated gRPC stubs ship as cronoqpb. Go 1.26 or newer; tested against a real server in CI.

TypeScript SDK (sdks/typescript)

  • Published to GitHub Packages as @synaptiai/cronoq; each GitHub release also carries the package tarball.
  • One client for Node (@synaptiai/cronoq/node, gRPC over HTTP/2) and browsers (@synaptiai/cronoq/web, gRPC-Web): submit (one task, atomic batches, or submit-and-wait), get, wait for, cancel, list and watch tasks, and read snapshots. Task ids are bigints; errors carry the gRPC code and metadata.
  • runWorker (Node) serves a pool with slots, heartbeats, reconnects with backoff, draining when its signal aborts, aborts that abort the handler's signal, and the attempt number as a fencing token. It follows its pool to the right shard under the same rules as the other workers.
  • Built on Connect-ES with code generated by buf; tested against a real server on Node 22 and 24, and in headless Chromium over gRPC-Web, in CI.

Kubernetes (deploy/helm)

  • A Helm chart for the server: standbys, a PostgreSQL or memory store, tokens from an existing Secret, optional TLS, mTLS and gRPC-Web, disruption budgets, an optional ServiceMonitor, and shards (one Deployment and Service per shard from one shared configuration, with each shard's endpoint filled in). Readiness uses /healthz, so rolling updates proceed while standbys answer UNAVAILABLE. Shards need TLS or an explicit insecurePeerHttp.
  • cronoq-operator (an image and a chart) scales worker Deployments to their pool's desired capacity: a CronoqWorkerScaler names a configured server, a pool, a Deployment, the slots per replica and replica bounds, and the operator sets ceil(desired / slots) replicas through the scale subresource, holding them while the pool cannot be read. Server tokens stay in the operator's configuration; its ClusterRole covers only the scalers and deployments/scale.
  • Tested in CI: every rendered configuration passes cronoq-server --check, and on a kind cluster two replicas on PostgreSQL fail over, helm upgrade --wait completes, a two-shard release serves both shards through the main Service, and the operator scales a worker Deployment up for a backlog and back to zero.

Project

  • Documentation book with concepts, use cases, guides, a benefits and decision guide, and complete configuration, HTTP, gRPC, metrics, Python, Rust, Go, TypeScript and error references.
  • Examples, a distroless container image, CI with deterministic simulation, fuzzing and benchmark gates, wheel tests on every platform a wheel is built for, and a release pipeline for crates.io, PyPI, GitHub Packages (the TypeScript SDK, with a provenance attestation), GitHub releases, GHCR images and OCI Helm charts.

Security

  • Roles client, worker, metrics and admin for bearer tokens and for client certificates mapped by DNS name ([[auth.certs]]), optionally limited to pools. Tasks of other pools are reported as not found.
  • Tokens must be at least 16 characters and unique; they are stored as SHA-256 digests, compared in constant time and never logged.
  • Listeners bind to 127.0.0.1 by default; the server refuses to listen elsewhere without credentials unless --insecure-no-auth is given. Without credentials the HTTP API answers only requests for a loopback host, which stops DNS-rebinding web pages.
  • Pool-scoped clients cannot reach tasks of other pools through batch submissions that reuse their keys, and lookups by key act on the task that was authorized.
  • Recurring templates are checked against the configured pools and resources when added, so a bad template cannot crash the scheduler when it fires or after a restart.
  • Task keys, kinds and schedule names are hashed with a randomly seeded hasher, so crafted keys cannot degrade the engine.
  • The PostgreSQL store refuses existing cronoq tables owned by another role, and store.schema puts the server's tables in a dedicated schema.
  • Workers refuse TLS settings with an http:// endpoint instead of connecting in plaintext, and mark their bearer token as sensitive.
  • TLS on both the gRPC and HTTP listeners, with optional mTLS (client_ca).
  • /metrics and /v1/snapshot require the metrics role; an optional unauthenticated metrics_addr listener serves scrapers on an internal network.
  • [limits] bound batch sizes, queue lengths, message and body sizes, strings and collections, waits, request time, concurrent connections and streams, and worker slots; slow TLS handshakes and headers time out.
  • Database errors are logged rather than returned to clients; messages from workers are sanitized; the server warns about non-local database URLs without sslmode=verify-full or verify-ca.