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
| Capability | What you get |
|---|---|
| Priorities with aging | Four classes (critical, high, normal, low), FIFO within a class, and aging so that a stream of normal work cannot starve low-priority work forever. |
| Deadlines | Tasks with a deadline gain urgency smoothly as their latest start time approaches, and are ordered earliest-deadline-first within their class. |
| Fixed starts and cron | Work 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. |
| Dependencies | DAGs by task id or key, with on_success, on_failure and on_complete edges. Batches are admitted atomically. |
| Reliability | Leases with heartbeats, per-attempt timeouts, retries with exponential backoff and retry_after, dead-lettering, cancellation, idempotent keys. |
| Shared resources | Semaphores and token buckets shared across pools: API rate limits, GPU slots, database connections. |
| Elasticity | Each pool computes how many workers it needs from arrival rate, backlog, wait-time SLOs and upcoming reservations, and asks a pluggable scaler for them. |
| Backpressure | Bounded queues that make submitters wait, reject, or shed lower-priority work. |
| Durability and HA | Postgres and SQLite stores with leader election and fencing; standby servers take over and resume every accepted task. |
| Observability | OpenTelemetry metrics and trace propagation, Prometheus /metrics, and windowed quality metrics such as priority adherence and fixed-start hit rate. |
Three ways to use it
- Rust library (
cronoqcrate): embed the scheduler in a tokio application. Payloads are plain Rust values; nothing is serialized. See the Rust quickstart. - Python package (
pip install cronoq): the same engine with asyncio bindings. Payloads are Python objects. See the Python quickstart. - 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
- New to cronoq? Read Why cronoq and Benefits at a glance, then the quickstart for your language.
- Evaluating it for a problem? The use cases show complete setups and the concrete benefit each feature brings.
- Designing a system on it? The concepts chapters explain every scheduling rule precisely, including formulas and defaults.
- Operating the server? See deployment, observability and the configuration reference.
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:
| Symptom | Root cause | What 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.
| cronoq | Plain queue (SQS, Redis lists) | Celery / Sidekiq / RQ | Kubernetes Jobs / CronJobs | Airflow / Dagster | Temporal | |
|---|---|---|---|---|---|---|
| Priorities with aging | ✅ | ❌ | Partial (separate queues) | ❌ | Partial (pools, weights) | ❌ |
| Deadlines / EDF | ✅ | ❌ | ❌ | ❌ | SLA alerts only | Timeouts only |
| Fixed start with capacity reservation | ✅ | ❌ | ETA only (no reservation) | Start time only | Schedule only | Timers |
| Shared rate limits across workers | ✅ | ❌ | Per worker | ❌ | Pools | Activity task-queue rate limits |
| Queue-pressure autoscaling | ✅ built in | External | External | HPA/KEDA | External | External |
| 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 core | Broker-bound | Broker-bound | Slow (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
| Benefit | What changes for you | How to see it | Built on |
|---|---|---|---|
| Important work is never stuck behind bulk work | User-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 starves | Low-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 SLAs | Work 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 time | Capacity 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 storms | Rate 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 fleets | Pools 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 code | Dependencies 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 end | Retries 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 overload | Bounded 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 failover | With 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 measure | Every 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 overhead | About 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 with | Then add |
|---|---|---|
| An async Rust service calling rate-limited APIs | cronoq crate, one pool and a token bucket | Deadlines on interactive calls, otel metrics |
| A Python service mixing user requests with background jobs | cronoq for Python, priorities | Status events for notifications, autoscaling objectives |
| GPU or model workers on several machines | cronoq-server with a pool per worker type | KEDA on cronoq_pool_desired_workers, Postgres for HA |
| Cron jobs that start late | Fixed starts and cron | Miss policies and fixed_start_hit_rate alerts |
| A hand-rolled DAG runner | Keys and dependencies | Atomic batches and failure handlers |
| A queue that falls over during incidents | Bounded pools with shed_lowest | Alerts on shed and wait percentiles |
Match your symptom
| Symptom | Read |
|---|---|
| "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
- 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.
- Classify work: priorities for user-facing versus background tasks, deadlines where an SLA exists, keys on everything that may be retried.
- Protect dependencies: a token bucket per external API, a semaphore per scarce resource.
- Observe: export metrics and alert on wait percentiles, dead letters and fixed-start hit rate.
- Scale out when workers outgrow one process: run
cronoq-server, move executors into workers, add Postgres and a standby, and drive the worker fleet fromcronoq_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:
Scheduler::builder()creates a scheduler for payload typeStringand output typeusize.Pool::new("default")is an autoscaled pool of worker slots. In-process, a slot is simply a concurrently running task, soworkers(1, 2, 8)means "start with 2 concurrent tasks, scale between 1 and 8 based on queue pressure".executor_fnturns an async closure into anExecutor. It receives aTaskContext(id, attempt, cancellation, heartbeat) and the payload.submitreturns aTaskHandlethat resolves withResult<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:
| Return | Effect |
|---|---|
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. |
| panic | Treated 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
| Example | Shows |
|---|---|
cargo run -p cronoq --example llm_agents | Rate limits, priorities, deadlines, 429 retries |
cargo run -p cronoq --example dag_pipeline | Fan-out/fan-in with failure and cleanup handlers |
cargo run --release -p cronoq --example firehose -- 1000000 | Microtask throughput |
cargo run -p cronoq --example prototype_demo | Mixed 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 betweenminandmax. Passautoscale=Falsefor a fixed size.- Executors are
async deffunctions 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(...), orawait sched.start()andawait sched.shutdown(), inside that loop. - Leaving
async withwaits up toshutdown_graceseconds (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
CancelledErrorkeeps 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. Passcodec=(an object withencodeanddecode) 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
- Using the HTTP API for dependencies, batches, scheduled work and errors.
- Writing executors and workers for worker design.
- Deploying the server for Kubernetes, KEDA autoscaling and HA with Postgres.
- Securing the server before exposing it beyond a trusted network.
- Server configuration for every option.
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 case | Typical problem | Key cronoq features |
|---|---|---|
| LLM and agent workloads | Rate limits, 429 storms, unpredictable latency, expensive workers | Token buckets, retry_after, priorities, deadlines, autoscaling |
| Interactive and batch work on shared capacity | User requests stuck behind batch jobs; batch jobs starved at peak | Priority classes, aging, per-class wait SLOs |
| Deadline-driven work | SLAs missed although capacity existed earlier | Deadline urgency, EDF ordering, overdue escalation |
| Scheduled jobs with guaranteed capacity | Cron jobs start late because workers are busy | Fixed starts, reservations, worker holds, miss policies, cron |
| Pipelines and DAGs | Hand-rolled dependency tracking; downstream steps run on bad input | Keys, edge conditions, atomic batches, failure handlers |
| GPUs and other scarce resources | Oversubscribed GPUs, connection pools or licences | Semaphores shared across pools; no head-of-line blocking |
| High-throughput microtasks | Scheduler overhead dominates millisecond tasks | O(1) dispatch, bulk enqueue, ~1M decisions/s per core |
| Polyglot worker fleets | Workers in several languages and machines | gRPC worker protocol, leases, heartbeats, HTTP API |
| Cost-aware autoscaling | Over-provisioned at night, saturated at noon | Queue-pressure autoscaling, stabilization, cost metrics, KEDA |
| Migrating from ElasticPriorityScheduler | Existing code on the original Python scheduler | cronoq.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-Afterheader; - 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'sRetry-After, combined with exponential backoff and jitter from the retry policy. - Users first. Chat turns are
Highand carry a deadline, so they overtake queued evaluation work. Low-priority work still progresses: aging lets it overtake a continuous stream ofNormalwork. - Capacity that follows demand. The pool scales on arrival rate and backlog, and the
per-class SLO adds capacity when predicted queue wait for
Highwork exceeds 2 seconds. - Bounded attempts. Per-attempt timeouts abort hung calls; tasks that keep failing end as
DeadLetteredinstead of retrying forever. - Cost visibility.
cost_per_worker_secondmakes 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
afteredges (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
criticaltask goes first. highalways beatsnormalandlow, however long they have waited.- A
lowtask that has waited about 45 seconds overtakes freshly submittednormalwork, sonormaltraffic 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
normaltraffic, and you can tune the aging rate and caps per class. - Measurable fairness.
priority_adherencein 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
minworkers and interactive work in another; resources can still be shared between pools. - Different aging policy: configure
PriorityConfig(Rust) per class, for example a higherage_capforlowto let it overtakehighafter 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
kindcomplete. - Visibility.
TaskContext::deadline(Rust) gives executors the deadline so they can choose a cheaper code path when time is short.
Related
- 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
- Admission. The task reserves an interval
[start − tolerance, start + duration + tolerance]in its pool. Up to half of the pool'smax_workers(at least one) may be reserved at any moment; if the new reservation would exceed that, the submission fails immediately withReservationConflict. You learn about an impossible schedule when you create it, not when it runs late. - 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.
- 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.
- 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_ratemetric counts it as on time if it started within the tolerance. - 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(statusMissed), orFail.
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
| Edge | The child runs when the parent... | Otherwise the child becomes |
|---|---|---|
after / Dep::on_success | succeeded | Skipped |
Dep::on_failure | failed or was dead-lettered | Skipped |
Dep::on_complete | reached 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_failureandon_completeedges. - 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. enqueueandenqueue_manyskip 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_manyover many singleenqueuecalls; each call is a channel round trip. - Size
queue_capacityto your burst size. - Disable the
otelfeature 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
- The worker opens
Workers/Workand sendsRegister{pool, slots, protocol_version}. - The server answers
Registered{worker_id, lease_ttl_s, slots}. The pool's capacity grows by the grantedslots. - The server sends
Leasemessages, at mostslotsat a time. - For each lease the worker sends
Heartbeatwell withinlease_ttl_s(the bundled clients send one every third of the TTL), thenComplete{output},Fail{message, permanent, retry_after_s}, orNackto hand the lease to another worker. - The server may send
Abort{lease}(timeout, cancellation, lease expiry). The worker stops that attempt. - When the stream closes, its in-flight leases are retried elsewhere and capacity shrinks.
When the server restarts or leadership moves, it sends
Shutdownand 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
SlotScalerapplies 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_secondsandcost, 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
| Area | Original | cronoq |
|---|---|---|
| Fixed-start conflicts | Any 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 tasks | max(now, fixed_start), even if dispatched early. | The actual dispatch time. |
| Dependencies | Satisfied 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 urgency | Non-monotonic (an overdue task could rank below an almost-due one). | Monotonic; overdue tasks always rank highest within their class. |
| Scaling trigger | schedule_quality thresholds. | Queue pressure; scale_up_threshold / scale_down_threshold are accepted and ignored. |
schedule_quality | Divides by the worker count at call time. | Total work ÷ worker-seconds actually provisioned. |
| Dispatch | Polls every 100 ms; one thread per task. | Event-driven; no polling. |
Step 2: move to the native API (recommended)
The native API gives you everything the shim hides: real executors instead of sleeps, results, retries, timeouts, cancellation, resources and per-pool configuration.
| Original | Native 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
| Field | Default | Meaning |
|---|---|---|
payload | required | The 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. |
priority | Normal | Critical, High, Normal or Low. See Priorities. |
key | none | Unique name: deduplicates submissions and can be referenced by dependents. |
deadline | none | Soft "done by" time. See Deadlines. |
fixed_start | none | Start time, tolerance and miss policy. See Fixed starts. |
duration_hint | learned | Expected run time; used for deadlines and reservations. |
timeout | none | Maximum duration of each attempt. |
retry | pool default | Retry policy. See Reliability. |
deps | none | Dependencies with edge conditions. See Dependencies. |
resources | none | Units of named resources needed while running. See Resources. |
tags | none | Free-form key/value labels passed to the executor (e.g. traceparent). |
labels | none | Worker labels the task requires; it runs only on workers that carry them. See Worker labels. |
tenant | none | Tenant 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)
| Status | Terminal | Meaning |
|---|---|---|
Blocked | Waiting for dependencies. | |
Scheduled | Waiting for its fixed start time. | |
Ready | Eligible and queued (or waiting for a resource). | |
Running | An attempt is executing. | |
Retrying | Waiting 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:
submitreturns aTaskHandle<O>; awaiting it yieldsOk(Arc<O>)on success orErr(TaskFailure)with the terminal status and message. - Python: awaiting a
TaskHandlereturns the executor's return value or raisescronoq.TaskFailed. - Server:
Wait/GET /v1/tasks/{id}/resultreturn 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
| Class | Base score |
|---|---|
Critical | 10,000 |
High | 1,000 |
Normal (default) | 100 |
Low | 10 |
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
Lowtask reaches10 + 800 = 810after 400 seconds. Against a continuous stream of freshNormalwork (score about100), it wins once10 + 2w > 100, that is after about 45 seconds of waiting. Normalcan reach900andHighcan reach1800. Neither crosses the next class's base score (1000and10000), so freshHighwork always beats agedNormalwork, andCriticalalways 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
| Setting | Default | Where |
|---|---|---|
| Base scores | 10000 / 1000 / 100 / 10 | PriorityConfig::base |
| Aging rate | 2.0 points/s | PriorityConfig::aging_rate |
| Age cap | 800 | PriorityConfig::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
Lowtask near its deadline (10 + 5000) overtakes freshHighwork (1000) but notCriticalwork (10000).
Choosing values
- Give a
duration_hintwhen you know it; otherwise the first tasks of akinduse the pool's learned typical run time (ordefault_durationif 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
| Setting | Default | Where |
|---|---|---|
Maximum urgency U | 5000 | UrgencyConfig::max |
| Minimum horizon | 5 s | UrgencyConfig::window |
| Overdue rate | 100 /s | UrgencyConfig::overdue_rate |
| Overdue cap | 3000 | UrgencyConfig::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:
| Policy | Effect |
|---|---|
RunLate (default) | Run as soon as a slot is free, ahead of all regular work. |
Skip | End with status Missed. |
Fail | End 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_oldestoverflow policies; their capacity was promised at admission. - Recurring schedules create fixed-start tasks; see Recurring schedules.
Configuration
| Setting | Default | Where |
|---|---|---|
| Tolerance | 1 s | per task |
| Miss policy | RunLate | per task |
reservable_fraction | 0.5 | per pool |
max_hold | 5 min | per pool (PoolConfig) |
default_duration | 1 s | per 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:
| Transition | Daily 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 + toleranceahead (capped by the pool'smax_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_attemptsmust 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; therecurring_invalidcounter 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_conflictscounter records it.
Overlap
| Policy | Effect |
|---|---|
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"). |
Allow | Occurrences 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 option | Effect 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
missedand 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:
| Condition | Rust | Python | Satisfied when the parent... |
|---|---|---|---|
| on success (default) | .after(x) / Dep::on_success(x) | after=[x] / Dep(x) | succeeded |
| on failure | Dep::on_failure(x) | Dep(x, on="failure") | failed or was dead-lettered |
| on complete | Dep::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:
| Setting | Effect |
|---|---|
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:
| Outcome | Rust | Python | Next |
|---|---|---|---|
| Success | Ok(output) | return value | Succeeded |
| Retryable failure | TaskError::retryable(msg) | raise cronoq.Retryable(msg) or any other exception | retry, or DeadLettered when attempts are exhausted |
| Retryable with delay | TaskError::retry_after(msg, d) | raise cronoq.Retryable(msg, retry_after=s) | retry no earlier than d |
| Permanent failure | TaskError::permanent(msg) | raise cronoq.Permanent(msg) | Failed |
| Cancelled | TaskError::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(metriccronoq_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::retentionin 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.
| Kind | Rust | Python | Server config | Behaviour |
|---|---|---|---|---|
| Semaphore | ResourceConfig::semaphore("gpu", 4) | Resource.semaphore("gpu", 4) | kind = "semaphore", permits | At most permits units held at once; units return at the end of each attempt. |
| Token bucket | ResourceConfig::token_bucket("api", 50.0, 100) | Resource.token_bucket("api", rate=50.0, burst=100) | kind = "token_bucket", rate, burst | Units 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
- 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).
- 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.
- 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.
- A request larger than the resource's capacity (
permitsorburst) is rejected at submission withResourceExceedsCapacity. Requests for unknown resources fail withUnknownResource.
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::labelin 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)(Pythonset_group_capacity).set_capacitysets 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
| Task | Runs in |
|---|---|
| No labels | Any 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 labels | Only 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 / min | 1 | 0 |
max_workers / max | 10 | 10 |
initial_workers / none | 1 | 0 (capacity comes from workers) |
| Autoscaling | on | on |
| Lease TTL | none | 30 s |
| Overflow policy | Wait | Reject |
| Retry policy | 3 attempts, 1 s ×2 up to 60 s, jitter | same |
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
SlotScalersets capacity to whatever the autoscaler asks for, immediately. - Server: capacity is the sum of
slotsof 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) orset_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 mostmax_step_down(1) percooldown(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
| Field | Default | Meaning |
|---|---|---|
interval | 1 s | Evaluation period. |
target_utilization | 0.75 | Steady-state busy fraction to aim for. |
drain_time | 30 s | Time within which a backlog should be cleared. |
slo | none | Per-class maximum predicted queue wait. |
reservation_horizon | 60 s | Reservations starting within this add capacity. |
stabilization_window | 60 s | Scale-down looks at the maximum desired value over this window. |
cooldown | 30 s | Minimum time between scale-down steps. |
max_step_up_ratio / max_step_up_abs | 1.0 / 4 | Largest scale-up step. |
max_step_down | 1 | Largest scale-down step. |
ewma_alpha | 0.3 | Smoothing of the arrival rate. |
cost_per_worker_second | 0 | For 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:
| Bound | Rust | Python | Server |
|---|---|---|---|
| Per pool | PoolConfig::bounds.max_pending | Pool(max_pending=...) | [[pools]] max_pending (default limits.max_pending_per_pool, 100 000) |
| Per pool and class | PoolConfig::bounds.max_pending_per_class | ||
| Global | CoreConfig::global_bounds | limits.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:
| Policy | Effect | Default for |
|---|---|---|
Wait | The submit call waits until space frees up (FIFO per pool). | library |
Reject | The submit fails with Overloaded (HTTP 429, gRPC RESOURCE_EXHAUSTED). | server |
ShedLowest | Evict the newest queued task of the lowest class strictly below the incoming one (status Shed); reject if there is none. | |
ShedOldest | Evict 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 incronoq_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:
- Snapshots: per-pool quality metrics over a sliding window (60 s by default), returned by
Scheduler::snapshot()(Rust),await sched.snapshot()(Python),GET /v1/snapshotandAdmin/Snapshot. - Exported metrics: OpenTelemetry instruments (library,
otelfeature) and Prometheus text at/metrics(server). See the metrics reference.
Pool snapshot fields
| Field | Meaning |
|---|---|
capacity | Current worker slots. |
busy | Slots running an attempt. |
held | Slots held for upcoming fixed-start tasks. |
desired | Autoscaler target. |
pending_by_class | Pending tasks per class (critical, high, normal, low). |
ready | Tasks queued for dispatch now. |
reservations | Fixed-start reservations booked. |
dispatched, succeeded, failed, retried | Counts within the window. |
priority_adherence | Fraction of dispatches made with no strictly higher class waiting. |
fixed_start_hit_rate | Fraction of fixed-start tasks started within their tolerance. |
utilization | Busy slot-seconds ÷ capacity slot-seconds. |
schedule_efficiency | Completed work seconds ÷ capacity slot-seconds. |
wait_mean, wait_p50/p90/p99 | Queue wait of first attempts (submission to dispatch). |
run_p50, run_p99 | Run time of successful attempts. |
worker_seconds, cost | Since 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
| Signal | Healthy | Investigate when |
|---|---|---|
wait_p99 | Below your latency objective | Rising while utilization is high: capacity-bound; while low: resource-bound (check rate limits). |
utilization | 0.5–0.85 | Near 1.0 with growing backlog (scale out); very low with desired at min (over-provisioned). |
priority_adherence | Close to 1.0 | Drops: aging or deadlines override priorities often; review classes or caps. |
fixed_start_hit_rate | 1.0 | Below 1.0: reservable capacity too small, or no workers connected around start times. |
desired vs capacity | Equal (in-process) or converging (server) | Persistently desired > capacity in server mode: your scaler is not adding workers. |
dead_lettered rate | Near zero | Spikes 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:
| Store | Use it for |
|---|---|
cronoq-store-postgres | Replicas on several hosts sharing a PostgreSQL database. |
cronoq-store-sqlite | One 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:
| Operation | Returns after |
|---|---|
submit, enqueue, submit_batch (gRPC Submit, HTTP POST /v1/tasks) | The task is stored. |
cancel | The cancellation is stored. |
add_recurring, remove_recurring | The 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:
- becomes leader (see below) and obtains a new fencing epoch;
- 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;
- 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:
| Error | Behaviour |
|---|---|
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. |
Other | Retried with backoff; the scheduler stops on the 5th such failure since the last successful commit. |
Fenced | Another 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 reportGET /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
Fencedand it stops. - The leader checks its lock session every
keepalive(1 s) and steps down if the session is unresponsive forsession_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 reportGET /readyz= 503. - The leader renews the lease every
lease_renew(2 s); it lastslease_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
Fencedand 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,
publicby default (PgStoreConfig::schemawhen you embed the store,store.schemain 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_versionand 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
| Property | Guarantee |
|---|---|
| Acknowledged submissions, cancellations, schedule changes | Durable. |
| Execution | At least once; a task may run again if its worker or the leader fails mid-attempt. |
| Order after failover | Submission order and priorities are preserved. |
| Split brain | Prevented by fencing: only the current epoch can commit. |
| Outputs | Persisted for successful tasks; Wait returns them after a failover. |
| Ids | Unique 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_cpuwith 6 calls per second incpu,api_gpuwith 4 ingpu.[limits], such asmax_pending_total, apply to each shard separately. - Its own store. Postgres keeps each shard in its own schema (
store.schemamust contain{shard}and defaults tocronoq_{shard}); SQLite in its own file (store.pathmust 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
numberin 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,GetandWaitby key still find a task that one reachable shard holds and name the missing shards (cronoq-unavailable-shardsmetadata;unavailable_shardsover HTTP), butCancelby key is refused withUNAVAILABLE, 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 inunavailable_shards. AWatchends when any shard's stream ends; reconnect. Prometheus/metricsstays 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
Watchstreams only that shard's events. - Plain
http://to another host. Tokens are not sent unencrypted across the network: calls for a shard whose endpoint ishttp://on another host are refused with its endpoint, unlessserver.insecure_peer_http = true(trusted networks only). Loopback endpoints andhttps://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://tohttp://, and send their token over plainhttp://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
permanentfor errors a retry cannot fix (validation, 4xx), andretry_afterwhen the remote side says when to come back. - Do not block the runtime. Use
tokio::task::spawn_blockingfor CPU-heavy or blocking work. - Check
ctx.deadlineto 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
slotshandlers 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.cancelledwhen 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=Trueonly on trusted networks where shards use plainhttp://; - on
stop(), stops taking leases, lets running ones finish for up tograceseconds, 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.
- Open the stream with metadata
authorization: Bearer <worker token>(or a client certificate). - Send
register{pool, slots, worker_name, protocol_version: 3, labels}first. If the stream fails withFAILED_PRECONDITIONandcronoq-shard-endpointmetadata, another shard serves the pool: connect there instead, without moving fromhttps://tohttp://. - Receive
registered{worker_id, lease_ttl_s, slots, protocol_version, max_message_bytes}; run at most the grantedslotsleases at once. - For each
lease:- run the task (
payload,content_type,kind,key,tags,timeout_s,deadline_unix_ns); - send
heartbeat{lease}well withinlease_ttl_swhile it runs; - finish with exactly one of
complete{lease, output},fail{lease, message, permanent, retry_after_s},nack{lease, reason}(give it back) orcancelled{lease}.
- run the task (
- On
abort{lease, reason}, stop that attempt; its slot is already free. - Send
set_slots{slots}to change concurrency;0drains the worker. - 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"}
payloadis any JSON value (sent to workers asapplication/json); usepayload_b64for binary data.keymakes 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": "..."}:
| Status | When | Retry? |
|---|---|---|
| 400 | Invalid JSON, unknown field or enum value, invalid duration, cycle | no |
| 401 | Missing or invalid token | no |
| 403 | Token lacks the role, or the pool is outside its scope | no |
| 404 | Unknown task, pool, resource or dependency | no |
| 409 | Fixed-start reservation conflict | after changing the start |
| 413 / 415 | Body too large / missing content-type: application/json | no |
| 429 | Queue full | yes, with backoff |
| 503 | Not the leader, shutting down, or store unavailable | yes, with backoff |
| 504 | A wait ended before the task finished | ask 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
| Setting | Flag | Environment | Default |
|---|---|---|---|
| Server | --server URL | CRONOQ_SERVER | http://127.0.0.1:7070 |
| Token | --token-file PATH | CRONOQ_TOKEN | none |
| CA bundle | --tls-ca PATH | system roots | |
| Client certificate (mTLS) | --tls-cert PATH --tls-key PATH | none | |
| Server name to verify | --tls-domain NAME | the 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:
| Code | Meaning |
|---|---|
| 0 | Success. |
| 1 | wait or submit --wait: the task finished without succeeding (the status and message go to standard error). |
| 2 | The 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
| Value | Default | Description |
|---|---|---|
replicas | 2 | Server pods (per shard). One leads; the others stand by and take over. |
auth.existingSecret | required | Secret with keys client, worker, metrics and admin, each at least 16 characters. auth.insecureNoAuth: true runs without tokens, on trusted networks only. |
store.kind | postgres | postgres, or memory with replicas: 1 (each memory-store pod would lead on its own, so the chart refuses more). |
store.postgres.urlSecret | required | {name, key} of the connection URL. schema (default cronoq) holds the tables. |
config | one pool | TOML for [[pools]], [[resources]], [limits] and the rest. Start it with a table header. |
shards | none | [{name, number, pools}]; see below. |
tls.secretName | none | A 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.allowedOrigins | none | Browser origins for gRPC-Web. |
podDisruptionBudget | maxUnavailable: 1 | Per server group. |
serviceMonitor.enabled | false | A 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 usehttps://and the servers verify each other withca.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.
| Value | Default | Description |
|---|---|---|
servers | required | [{name, url, tokenSecret: {name, key}, caSecret: {name, key}}]. The Secrets are in the operator's namespace and are mounted into its pod. |
intervalSeconds | 15 | How 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.
-
/healthzfor liveness; for readiness/healthzwith rolling updates (standbys answerUNAVAILABLE, which clients retry) or/readyz(only the leader) withstrategy: Recreate. -
Prometheus scraping
/metricswith a metrics token or onmetrics_addr; alerts on dead letters, wait times and missing workers. -
max_pendingandoverflowset on pools fed by untrusted producers. -
sslmode=verify-fullin the database URL. - Workers drain on shutdown, with a termination grace period to match.
-
cronoq-server --checkin 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
| Alert | Expression (Prometheus) | Why |
|---|---|---|
| Dead letters | increase(cronoq_tasks_total{status="dead_lettered"}[10m]) > 0 | Tasks failing every retry. |
| Queue wait | cronoq_pool_wait_seconds{quantile="0.99"} > 30 | Users or SLAs waiting. |
| Under-capacity | cronoq_pool_desired_workers > cronoq_pool_workers for 15 m | The scaler is not delivering workers. |
| No workers | sum by (pool) (cronoq_pool_pending) > 0 and on (pool) cronoq_pool_workers_connected == 0 for 5 m | Work is queued and nothing can run it. |
| Fixed starts late | cronoq_pool_fixed_start_hit_rate < 0.95 | Scheduled jobs starting late. |
| Shedding | increase(cronoq_tasks_total{status="shed"}[5m]) > 0 | Overload is dropping work. |
| Lease expiries | increase(cronoq_lease_expiries_total[10m]) > 0 | Workers are dying or not heartbeating. |
| No leader | max(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
| Role | Can | Cannot |
|---|---|---|
client | Submit, batch, cancel, get, wait, watch (Tasks, /v1/tasks*) | Serve pools, read metrics, manage schedules |
worker | Open Workers/Work streams and receive leases of its pools; report outcomes for its own leases | Submit or read tasks |
metrics | Admin.Snapshot, Admin.ListWorkers, Admin.ListRecurring; /v1/snapshot, /v1/workers, /v1/recurring, /metrics | Anything that touches tasks |
admin | Everything, including Admin.AddRecurring / RemoveRecurring | – |
| no credentials | /healthz, /readyz, and /metrics on metrics_addr | Anything 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
-
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 islocalhost,*.localhost,127.0.0.1or[::1], which stops web pages that rebind their name to127.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"] -
Keep tokens out of files. Use
token_envand 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. -
Encrypt traffic. Configure
[server.tls]withcertandkey; 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 anhttps://endpoint; they refuse anhttp://one instead of connecting in plaintext. -
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.
-
Keep metrics internal.
/metricsonhttp_addrrequires the metrics role. If your scraper cannot send a token, setmetrics_addrand bind it to an internal interface: that listener has no TLS and no authentication. -
Bind deliberately. The defaults are
127.0.0.1:7070and127.0.0.1:7071. Listen on0.0.0.0only behind a firewall, security group or network policy. -
Bound everything. Keep the
[limits]defaults or lower them, and setmax_pendingandoverflowon pools fed by less trusted producers, so one client cannot exhaust memory.max_slots_per_workercaps how many leases a single worker can hold. -
Protect the database. Read the URL from
url_env(defaultDATABASE_URL), usesslmode=verify-fullfor 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 keeppublicon a database created before PostgreSQL 15, runREVOKE 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. -
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. -
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. -
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 (oftenmetrics), 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
| Path | Typical 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; individualsubmitcalls each wait for a reply. - Skip handles you do not need.
enqueueis cheaper thansubmit(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 = falseto remove OpenTelemetry instrumentation. - Keep executors async. A blocking executor holds a tokio worker thread; move CPU-heavy work to
spawn_blockingor 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_maxfor very high-rate workloads that do not need them.
Tuning the server
- Give workers enough
slotsthat 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
| Flag | Description |
|---|---|
-c, --config <path> | TOML file (env CRONOQ_CONFIG). |
--check | Validate the configuration and exit (configuration is valid). |
--insecure-no-auth | Allow running without auth on non-loopback addresses (same as server.insecure_no_auth). Trusted networks only. |
--print-proto | Print cronoq/v1/cronoq.proto and exit. |
--shard NAME | The 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]
| Key | Type | Default | Description |
|---|---|---|---|
grpc_addr | socket address | "127.0.0.1:7070" | gRPC listener (Tasks, Workers, Admin). Port 0 picks a free port. |
http_addr | socket address | "127.0.0.1:7071" | HTTP listener (/v1/*, /metrics, /healthz, /readyz). |
metrics_addr | socket address, optional | unset | Extra 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_auth | bool | false | Allow 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_s | float ≥ 0 | 10 | On shutdown (or loss of leadership), running attempts may finish for this long before they are aborted. |
shard | string, optional | unset | With [[shards]], the shard this server serves. Usually set per deployment with --shard or CRONOQ_SHARD, so all shards share one file. |
insecure_peer_http | bool | false | Forward 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.
| Key | Type | Description |
|---|---|---|
cert | path | PEM certificate chain. |
key | path | PEM private key. |
client_ca | path, optional | Require 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).
| Key | Type | Description |
|---|---|---|
ca | path, optional | PEM CA bundle for the other shards' certificates (default: the system roots). |
cert, key | paths, optional | Client 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.
| Key | Type | Description |
|---|---|---|
allowed_origins | list of 1–64 strings | Origins 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:
| Role | May |
|---|---|
client | Submit, inspect, wait for, cancel and watch tasks (Tasks service, /v1/tasks*). |
worker | Open the Workers.Work stream and serve leases. |
metrics | Read snapshots, workers, recurring schedules and metrics (Admin.Snapshot, Admin.ListWorkers, Admin.ListRecurring, /v1/snapshot, /v1/workers, /v1/recurring, /metrics). |
admin | Everything, 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
| Key | Type | Description |
|---|---|---|
token | string | The bearer token. Prefer token_env. |
token_env | string | Read the token from this environment variable (it must be set at startup). |
role | client | worker | metrics | admin | What the token may do. |
pools | list of pool names, optional | Restrict 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. |
tenant | string, optional | Tenant 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. |
labels | list of "name=value", optional | Labels 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
| Key | Type | Description |
|---|---|---|
dns_name | string | DNS SAN to match exactly. |
role | role | Role granted to that certificate. |
pools | list, optional | Pool allow-list, as for tokens. |
labels | list, optional | Worker-label allow-list, as for tokens. |
tenant | string, optional | Tenant 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
| Key | Type | Default | Description |
|---|---|---|---|
kind | memory | postgres | sqlite | memory | memory 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). |
url | string | – | 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_env | string | DATABASE_URL | postgres only. Read the URL from this environment variable. Set url or url_env, not both. |
keep_finished_s | float ≥ 60 | 604800 (7 days) | Finished tasks older than this are pruned hourly (in batches; tasks that live tasks depend on are kept). |
path | path | – | sqlite only, required: the database file (created if missing, owner-only permissions). Keep it on a local filesystem. |
schema | string | public | postgres 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.
| Key | Type | Default | Description |
|---|---|---|---|
max_tasks_per_batch | int ≥ 1 | 1000 | Tasks per SubmitBatch / POST /v1/tasks:batch. |
max_pending_total | int ≥ 1 | 1000000 | Pending (not yet running) tasks across all pools. |
max_pending_per_pool | int ≥ 1 | 100000 | Default pending bound per pool (override with pools.max_pending). |
max_message_bytes | int ≥ 4096 | 16777216 (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_bytes | int ≥ 4096 | 16777216 | Largest HTTP request body. |
max_key_len | int ≥ 1 | 256 | Longest task key. |
max_kind_len | int ≥ 1 | 128 | Longest task kind. |
max_tags | int | 32 | Most tags per task. |
max_tag_len | int ≥ 1 | 1024 | Longest tag key or value. |
max_deps | int | 256 | Most dependencies per task. |
max_resources | int | 16 | Most resource entries per task. |
max_wait_s | float ≥ 0 | 300 | Longest 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_s | float ≥ 0.1 | 30 | Request 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_requests | int ≥ 1 | 4096 | On the gRPC listener, requests processed concurrently (excess requests queue). On the HTTP listener, open connections; further connections are refused. |
max_concurrent_streams | int ≥ 1 | 1024 | HTTP/2 streams per connection. |
max_slots_per_worker | int ≥ 1 | 1024 | Slots a single worker may register (also capped by the pool's max). |
keepalive_interval_s | float ≥ 0.1 | 20 | HTTP/2 keepalive ping interval; detects half-open workers and clients. |
keepalive_timeout_s | float ≥ 0.1 | 20 | HTTP/2 keepalive ping timeout. |
key_retention_s | float ≥ 0 | 3600 | How 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_max | int | 1000000 | Most finished tasks kept for key deduplication (bounds memory). |
max_list | int, 1–100000 | 1000 | Most 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 }
| Key | Type | Default | Description |
|---|---|---|---|
name | string | required | 1–64 characters from [A-Za-z0-9_.:-], unique. |
min | int ≤ max | 0 | Lower bound on the autoscaler's desired capacity: the desired capacity never drops below it. Capacity itself still comes only from connected workers. |
max | int ≥ 1 | 10 | Autoscaler upper bound, basis for reservation capacity, and a cap on slots per worker. |
autoscale | bool | true | Compute a desired capacity from queue pressure (exported as cronoq_pool_desired_workers and sent to the scaler). |
scaler | table | { kind = "external" } | How the desired capacity is communicated, see below. |
lease_ttl_s | float ≥ 0.1 | 30 | Workers must heartbeat each lease within this many seconds, or the attempt is treated as lost and retried. |
max_pending | int ≥ 1 | limits.max_pending_per_pool | Pending-task bound for this pool. |
overflow | wait | reject | shed_lowest | shed_oldest | reject | Behaviour when the bound is reached (see Backpressure). reject answers RESOURCE_EXHAUSTED / HTTP 429. |
retry | table | engine default (3 attempts, 1 s ×2 up to 60 s, jitter) | Default retry policy for the pool, see below. |
default_duration_s | float ≥ 0 | 1 | Duration estimate before any run time has been observed. |
reservable_fraction | float in (0, 1] | 0.5 | Share of max that overlapping fixed-start reservations may occupy (at least one slot). |
await_unknown_dependencies_s | float ≥ 0 | unset | Allow dependencies on keys that do not exist yet; such tasks wait up to this long, then end skipped. Unset: unknown keys are rejected. |
target_utilization | float in (0, 1] | 0.75 | Autoscaler utilization target. |
drain_time_s | float > 0 | 30 | Autoscaler: clear the backlog within this time. |
cooldown_s | float ≥ 0 | 30 | Autoscaler: minimum time between scale-down steps. |
cost_per_worker_second | float ≥ 0 | 0 | Cost per slot-second, for cronoq_pool_cost_total. |
max_slots_per_worker | int ≥ 1 | limits.max_slots_per_worker | Slots one worker may register in this pool (never above max). Workers asking for more are granted the cap. |
fair_share | bool | false | Share the pool fairly between tenants with equal weights (implied by tenant_weights). |
tenant_weights | table of tenant = weight | {} | Share the pool fairly between tenants with these weights (each finite and > 0; others weigh 1). See Fair sharing. |
scaler
| Form | Description |
|---|---|
{ 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
| Key | Type | Default | Description |
|---|---|---|---|
max_attempts | int ≥ 1 | required | Attempts including the first. |
initial_backoff_s | float ≥ 0 | 1 | First backoff. |
multiplier | float in [1, 1000] | 2 | Backoff growth factor. |
max_backoff_s | float ≥ 0 | 60 | Backoff ceiling. |
jitter | bool | true | Full 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
| Key | Kinds | Description |
|---|---|---|
kind | – | semaphore or token_bucket. |
name | both | 1–64 characters from [A-Za-z0-9_.:-], unique. |
permits | semaphore | Units held concurrently (≥ 1); released when each attempt ends. |
rate | token_bucket | Refill per second (> 0). |
burst | token_bucket | Bucket size (≥ 1). |
shard | both | With [[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.
| Key | Type | Description |
|---|---|---|
name | string | 1–32 lower-case letters, digits and _, starting with a letter. |
number | int, 0–255 | Unique; carried in the top 8 bits of the shard's task ids. Do not change it once the shard has tasks. |
pools | list of pool names | The pools this shard serves. Every [[pools]] entry belongs to exactly one shard. |
endpoint | URL | http:// 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.
| Key | Type | Default | Description |
|---|---|---|---|
cache_entries | int | 10000 | Most outputs kept in memory (0 disables the cache). |
cache_bytes | int | 67108864 (64 MiB) | Most output bytes kept in memory. Must be positive while the cache is enabled. |
info_entries | int | 100000 | Most 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
minabove itsmax; [[auth.tokens]]entries without exactly one oftoken/token_env, duplicate literaltokenvalues, pool allow-lists naming unknown pools;[[auth.certs]]withoutserver.tls.client_ca;server.grpc_web.allowed_originsthat is empty, has more than 64 entries, or holds anything other than exact origins;- both
store.urlandstore.url_env; [[shards]]with duplicate names, numbers or endpoints, unknown pools, pools in no shard or in two, a missing or unknownserver.shard, a store namespace without{shard}, or a resource without a knownshard.
Startup also rejects the following, which --check does not test because they depend on the
environment:
- a
token_envvariable 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_authis 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]]. Theadminrole 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-authis given. In that mode, unless--insecure-no-authis given, a request whoseHostis not a loopback name or address (localhost,*.localhost, any127.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
tenantsubmit 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 mostlimits.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(POSTandGET) and to every path ending in/resultgetslimits.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
/v1route and/metricswith 503not the leader (standby or shutting down). Put the servers behind a load balancer that routes on/readyz.
Routes
| Method and path | Role | Description |
|---|---|---|
POST /v1/tasks | client | Submit one task; optionally wait for its result. |
GET /v1/tasks | client | List tasks, live and finished, one page at a time. |
POST /v1/tasks:batch | client | Submit up to max_tasks_per_batch tasks atomically. |
GET /v1/tasks/{id} | client | Task details by numeric id. |
DELETE /v1/tasks/{id} | client | Cancel a task by id. |
GET /v1/tasks/{id}/result | client | Wait for a task's result by id. |
GET /v1/tasks/by-key/{key} | client | Task details by key. |
DELETE /v1/tasks/by-key/{key} | client | Cancel a task by key. |
GET /v1/tasks/by-key/{key}/result | client | Wait for a task's result by key. |
GET /v1/snapshot | metrics | Per-pool state, quality signals and totals. |
GET /v1/workers | metrics | Connected remote workers. |
GET /v1/recurring | metrics | Recurring schedules. |
GET /metrics | metrics | Prometheus text exposition (metrics reference). |
GET /healthz | none | 200 ok while the process runs (liveness). |
GET /readyz | none | 200 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.
| Field | Type | Default | Description |
|---|---|---|---|
payload | any JSON | – | Payload, sent to the worker as JSON bytes with content type application/json. |
payload_b64 | string | – | Binary payload, standard base64 (content type application/octet-stream). Mutually exclusive with payload. |
content_type | string | from the payload | Overrides the content type passed to the worker. |
pool | string | "default" | Target pool. Must exist (404) and be allowed for the caller (403). |
kind | string | "default" | Free-form label passed to the worker (at most max_kind_len). |
priority | "critical", "high", "normal", "low" | "normal" | Priority class. |
key | string | – | 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_ns | number / integer | – | Deadline, relative or absolute. Raises urgency as it approaches. |
start_in_s / start_unix_ns | number / integer | – | Fixed start time, relative or absolute (fixed starts). |
tolerance_s | number | 1 | How 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_s | number | pool's default_duration_s | Expected run time, used for reservations and hold leads. |
timeout_s | number | none | Per-attempt timeout; the worker's lease is aborted when it expires. |
retry | object | pool's [pools.retry] | {"max_attempts": n, "initial_backoff_s", "multiplier", "max_backoff_s", "jitter"}. max_attempts is required; jitter defaults to true. |
after | array | [] | Tasks that must succeed first: strings are keys; numbers or {"id": "..."} are ids. |
deps | array | [] | {"key": "..."} or {"id": "..."}, with optional "cond": "on_success" (default), "on_complete", "on_failure". |
resources | object | {} | {"<resource>": units} acquired for each attempt. At most max_resources entries. |
tags | object | {} | 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. |
tenant | string | – | Tenant, for pools that share capacity fairly (Fair sharing). |
labels | object | {} | Worker labels the task requires: it runs only on workers that registered with all of them (Worker labels). Same limits as tags. |
wait | bool | false | POST /v1/tasks only: respond when the task has finished. Rejected in batches. |
wait_timeout_s | number | limits.max_wait_s | Upper 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"}
| Field | Present | Description |
|---|---|---|
id | always | Task id (string). |
status | always | See statuses. |
pool | always | Pool name (empty if unknown). |
attempt | always | Current or last attempt number (0 before the first dispatch). |
output_available | always | true when the task succeeded and its output is still retained. |
key, kind | when set | Task key and kind. |
tenant | when set | Tenant the task was submitted for. |
priority | when known | "critical", "high", "normal" or "low". |
max_attempts | when known | Attempt budget from the task's or the pool's retry policy. |
created_unix_ns, started_unix_ns, finished_unix_ns | when known | Lifecycle timestamps (strings). |
message | when set | Last 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 parameter | Default | Description |
|---|---|---|
pool | every pool the caller may use | Comma-separated pool names. Unknown pools return 404, pools the caller may not use 403. |
status | any | Comma-separated statuses. |
after | – | Only tasks with a larger id: pass the previous page's next_after. |
limit | 100 | Page 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"}
| Field | Present | Description |
|---|---|---|
id | always | Task id (string). |
status | always | A terminal status. |
output_available | always | false for a succeeded task whose output is no longer retained. |
output_b64 | output available | The output bytes, base64. |
output | output is valid JSON | The output decoded as JSON. |
message | when set | Why 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}}
| Field | Description |
|---|---|
live_tasks, running, pending | Tasks not yet finished; of those, running and waiting. |
workers_connected | Remote worker streams across all pools. |
unavailable_shards | Shards that could not be reached; their part is missing from the response. |
pools[].capacity | Worker slots (sum of the slots of connected workers). |
pools[].busy, held, desired | Busy slots; slots held idle for an upcoming fixed start; the autoscaler's target. |
pools[].pending, pending_by_class | Waiting tasks, total and by class (critical, high, normal, low). |
pools[].reservations | Fixed-start reservations not yet started. |
pools[].dispatched, succeeded, failed | Attempts 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_efficiency | Quality signals in [0, 1] over the metrics window. |
pools[].wait_p50_s, wait_p90_s, wait_p99_s | Time from submission to first start, over the metrics window. |
pools[].cost | Cost since start (cost_per_worker_second × worker-seconds). |
pools[].groups | Worker groups (workers with the same labels), unlabeled first: labels, slots and busy slots. |
totals | Lifetime counters of this leader since it started. |
Field meanings: Metrics and quality signals.
Statuses
| Status | Terminal | Meaning |
|---|---|---|
blocked | no | Waiting for dependencies. |
scheduled | no | Waiting for its fixed start time. |
ready | no | Eligible and queued for a worker. |
running | no | An attempt is running on a worker. |
retrying | no | An attempt failed; waiting for the retry backoff to elapse. |
succeeded | yes | Finished successfully. |
failed | yes | Failed permanently, or missed a fixed start with on_miss = "fail". |
dead_lettered | yes | Retryable failures used up every attempt. |
cancelled | yes | Cancelled by a caller. |
skipped | yes | A dependency edge can no longer be met, an unknown dependency was awaited too long, or a recurring run overlapped the previous one. |
missed | yes | Missed its fixed start with on_miss = "skip". |
shed | yes | Evicted 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.
| Status | gRPC equivalent | Cause |
|---|---|---|
| 400 | INVALID_ARGUMENT | Malformed JSON, unknown field or enum value, invalid duration, oversize string, dependency cycle, invalid id. |
| 401 | UNAUTHENTICATED | Missing, malformed or unknown credentials. |
| 403 | PERMISSION_DENIED | The 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. |
| 404 | NOT_FOUND | Unknown task, pool, resource or dependency, or unknown route. |
| 405 | – | Method not allowed on this route. |
| 409 | FAILED_PRECONDITION, ALREADY_EXISTS | Fixed-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. |
| 429 | RESOURCE_EXHAUSTED | Pool (max_pending) or server (max_pending_total) queue full. Retry with backoff. |
| 500 | INTERNAL | Unexpected server error. |
| 503 | UNAVAILABLE | Not the leader, shutting down, or the store is unavailable. Retry, preferably against the leader. |
| 504 | DEADLINE_EXCEEDED | A 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);adminpasses every check. A token or certificate scoped topools = [...]may only submit into, watch and serve those pools; tasks in other pools are reported asNOT_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/readyzsends 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 withCANCELLED.SubmitwithwaitandWaitalso stop waiting afterlimits.max_wait_s(or the shorter timeout the call asks for).
Tasks (role: client)
| RPC | Request → Response | Description |
|---|---|---|
Submit | SubmitRequest → SubmitResponse | Submit one task; with wait, block until it finishes. |
SubmitBatch | SubmitBatchRequest → SubmitBatchResponse | Submit up to max_tasks_per_batch tasks atomically. |
Cancel | CancelRequest{task} → CancelResponse{cancelled} | Cancel by id or key; false if the task already finished. |
Get | GetRequest{task} → TaskInfo | Task details; NOT_FOUND for unknown tasks. |
ListTasks | ListTasksRequest → ListTasksResponse{tasks, next_after} | One page of tasks, live and finished, in id order. |
Wait | WaitRequest{task, timeout_s} → TaskResult | Wait for the task to finish. |
Watch | WatchRequest → stream StatusEvent | Live status events. |
TaskSpec
| Field | Type | Description |
|---|---|---|
payload | bytes | Passed to the worker unchanged. |
content_type | string | Passed to the worker with the payload. |
kind | string | Label passed to the worker; default "default". |
pool | string | Target pool; default "default". |
priority | Priority | Default PRIORITY_NORMAL. |
key | string | Optional unique key (deduplication, dependencies). |
deadline_in_s / deadline_unix_ns | optional | Deadline. |
start_in_s / start_unix_ns | optional | Fixed start time. |
tolerance_s | optional double | Fixed-start tolerance; default 1 s, 0 allowed. |
on_miss | MissPolicy | What a missed fixed start does. |
duration_hint_s | optional double | Expected run time; default the pool's default_duration_s. |
timeout_s | optional double | Per-attempt timeout. |
retry | RetryPolicy | {max_attempts, initial_backoff_s, multiplier, max_backoff_s, jitter}; unset = the pool's policy. |
deps | repeated Dep | {target: TaskRef, cond: EdgeCond}. |
resources | map<string, uint32> | Units of each named resource per attempt. |
tags | map<string, string> | Labels passed to the worker. |
labels | map<string, string> | Worker labels the task requires (Worker labels). |
tenant | string | Tenant, 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
| Field | Description |
|---|---|
id, status | Authoritative. |
pool, key, kind, tenant | As submitted. |
priority | PRIORITY_UNSPECIFIED if unknown. |
attempt | Current or last attempt (0 before the first dispatch). |
max_attempts | Attempt budget; 0 if unknown. |
created_unix_ns, started_unix_ns, finished_unix_ns | Lifecycle timestamps, when known. |
message | Last error or status message. |
output_available | True 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:
| Field | Description |
|---|---|
pools | Pools to watch; empty = all pools the caller may see. Unknown pools are NOT_FOUND; pools outside a scoped token are PERMISSION_DENIED. |
ids, keys | Optional task filters (at most max_tasks_per_batch together). |
all_transitions | Deliver 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)
| RPC | Stream | Description |
|---|---|---|
Work | stream WorkerMessage ↔ stream ServerMessage | One bidirectional stream per worker (or per pool a worker serves). |
Messages
| Direction | Message | Meaning |
|---|---|---|
| worker → server | Register{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 → worker | Registered{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 → worker | Lease{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 → server | Heartbeat{lease} | Keep a lease alive. |
| worker → server | Complete{lease, output, content_type} | Success; output becomes the task's result. |
| worker → server | Fail{lease, message, permanent, retry_after_s} | Failure: permanent, or retryable with an optional minimum delay. |
| worker → server | Nack{lease, reason} | The worker cannot take this lease (for example it is draining). |
| worker → server | Cancelled{lease} | The handler stopped because the lease was aborted or cancelled. |
| worker → server | SetSlots{slots} | Change concurrency; 0 drains (no new leases). Capped like Register.slots. |
| server → worker | Abort{lease, reason} | Stop this attempt (timeout, cancellation, lease expiry). |
| server → worker | Shutdown{reason, not_leader} | The server is going away; reconnect with backoff. |
Protocol rules
- Register first. A stream whose first message is not
Registerfails withINVALID_ARGUMENT; one that sends nothing for 10 s fails withDEADLINE_EXCEEDED. A secondRegisteron the same stream ends it. Unknown pools areNOT_FOUND. - 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
slotsvalue fromRegistered, not the one you requested. - Heartbeat each running lease more often than
lease_ttl_s(everylease_ttl_s / 3is a good rule). A lease without a heartbeat forlease_ttl_sexpires: the attempt fails as retryable and the worker receivesAbort. - Report exactly one outcome per lease:
Complete,Fail,NackorCancelled. Reports for leases the worker does not hold (already aborted, or unknown) are ignored. Nackoffers 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.Abortmeans the server has already given up the attempt and freed the slot. Stop the handler; sendingCancelledafterwards is allowed and ignored.- 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.
- Disconnects fail running leases as retryable (
worker disconnected); they are retried according to the task's retry policy. - Shutdown. On a graceful stop the server first gives running attempts
server.shutdown_grace_sto finish (workers keep reporting outcomes), then sendsShutdown{not_leader: false}. When it loses leadership it sendsShutdown{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. - Messages in both directions are limited to
max_message_bytesfromRegistered.
The Rust (cronoq_server::worker::run_worker) and Python (cronoq.client.Worker) workers
implement these rules; see Writing executors and workers.
Admin
| RPC | Role | Request → Response |
|---|---|---|
Snapshot | metrics | Empty → SnapshotResponse |
AddRecurring | admin | RecurringRequest{name, cron, tz, template, overlap} → Empty |
RemoveRecurring | admin | RemoveRecurringRequest{name} → RemoveRecurringResponse{removed} |
ListWorkers | metrics | Empty → ListWorkersResponse{workers} |
ListRecurring | metrics | Empty → 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
Watchwork.Workers.Workis 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_originsare answered forPOSTwith the headersauthorization,content-type,x-grpc-web,x-user-agentandgrpc-timeout; the response headersgrpc-status,grpc-messageandgrpc-status-details-binare 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 getsPERMISSION_DENIED, as on the HTTP API. With--insecure-no-auththis 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,Adminincluded: do not enable gRPC-Web without credentials outside a trusted network.
Status codes
| Code | Cause |
|---|---|
INVALID_ARGUMENT | Invalid spec, duration, reference, cron expression or time zone; dependency cycle; oversize string or collection. |
NOT_FOUND | Unknown task, pool, resource or dependency. |
ALREADY_EXISTS | The key is already used by a task in a pool outside the credentials' scope. |
FAILED_PRECONDITION | Fixed-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_EXHAUSTED | Queue bounds reached (max_pending, max_pending_total); retry later. |
UNAUTHENTICATED | Missing, malformed or unknown credentials. |
PERMISSION_DENIED | The 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_EXCEEDED | A wait timed out while the task is still running, or no Register within 10 s. |
CANCELLED | The call ran longer than limits.max_wait_s + limits.request_timeout_s, or the client cancelled it. |
UNAVAILABLE | Not the leader, shutting down, or the store is unavailable. Retry with backoff. |
DATA_LOSS | A Watch stream fell behind and events were dropped. |
UNIMPLEMENTED | A plain gRPC request over HTTP/1.1 (with gRPC-Web enabled, HTTP/1.1 carries only gRPC-Web). |
INTERNAL | Unexpected server error. |
Enumerations
| Enum | Values (zero value first) |
|---|---|
Priority | PRIORITY_UNSPECIFIED (= normal), PRIORITY_CRITICAL, PRIORITY_HIGH, PRIORITY_NORMAL, PRIORITY_LOW |
TaskStatus | TASK_STATUS_UNSPECIFIED, BLOCKED, SCHEDULED, READY, RUNNING, RETRYING, SUCCEEDED, FAILED, DEAD_LETTERED, CANCELLED, SKIPPED, MISSED, SHED (prefixed TASK_STATUS_) |
EdgeCond | EDGE_COND_UNSPECIFIED (= on success), EDGE_COND_ON_SUCCESS, EDGE_COND_ON_COMPLETE, EDGE_COND_ON_FAILURE |
MissPolicy | MISS_POLICY_UNSPECIFIED (= run late), MISS_POLICY_RUN_LATE, MISS_POLICY_SKIP, MISS_POLICY_FAIL |
Overlap | OVERLAP_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)
| Argument | Description |
|---|---|
pools | At least one Pool. |
resources | Shared semaphores and token buckets. |
emit_transitions | Deliver every status transition on events(), not only terminal ones. |
shutdown_grace | Seconds running attempts get when leaving async with. |
event_capacity | Buffer of each events() stream. |
store | Keep state in a SQLite file so it survives restarts; see SqliteStore. |
codec | How 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.
| Member | Returns | Description |
|---|---|---|
await start() | Start on the running event loop. Executors run on this loop. | |
await submit(payload, **options) | TaskHandle | Submit; options below. Waits for queue space with the wait overflow policy. |
await enqueue(payload, **options) | int | Submit 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) | bool | Cancel by id or key; False if already finished. |
await status(target) | str | None | Status name, or None if unknown. |
await handle(target) | TaskHandle | None | Attach to an existing task. |
await snapshot() | Snapshot | Metrics 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) | bool | False if no such schedule. |
events(on_lag="skip") | EventStream | Status events; see below. |
await shutdown(grace=5.0) | Stop admitting, let running attempts finish for grace seconds, then stop. | |
async with Scheduler(...) as s | start() on enter, shutdown(shutdown_grace) on exit. | |
started, pools, default_pool | properties | Whether started; pool names; the pool used without pool= ("default", or the first). |
Task options
Accepted by submit, enqueue and Task(...).
| Option | Type | Description |
|---|---|---|
priority | Priority, "critical"/"high"/"normal"/"low", or int | Default normal. |
pool, kind | str | Default: the default pool, "default". |
key | str | Unique key (deduplication, dependencies). |
deadline_in / deadline_at | seconds / time | Deadline. |
start_in / start_at | seconds / time | Fixed start. |
tolerance | seconds | Fixed-start tolerance (default 1; 0 allowed). |
on_miss | "run_late", "skip", "fail" | Fixed-start miss policy. |
duration_hint, timeout | seconds | Expected run time; per-attempt timeout. |
retry | RetryPolicy | Overrides the pool's policy. |
after | id/key or a list of them | Must succeed first. |
deps | list[Dep] | Dependencies with conditions. |
resources | dict[str, int] | Units of named resources per attempt. |
tags | dict | Labels (values converted to str). |
labels | dict[str, str] | Worker labels the task requires; it runs only in a group of workers that has them. |
tenant | str | Tenant, 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_capacityevents. When the consumer falls behind, the oldest are dropped and counted inevents.lagged. Withon_lag="raise"the next iteration raisesEventsLagged(.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 leavingasync with).closedtells 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().
| Argument | Description |
|---|---|
path | Database file; keep it on a local filesystem. |
lease_ttl, lease_renew, lock_poll | Several 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. |
retention | How long finished tasks are kept in memory after a restart, for key deduplication. |
full_sync | Sync 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 withTypeError, 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
cronoqlogger); 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)
| Argument | Description |
|---|---|
executor | async def run(ctx, payload), or a plain function (runs in the loop's default thread pool). |
min, max, initial | Concurrency bounds (initial defaults to min). |
autoscale | True / None (default policy), False (fixed size: initial, or max when initial is not given), or an AutoscaleConfig. |
tenant_weights, fair_share | Share the pool fairly between tenants: weights by tenant (others weigh 1), or equal shares with fair_share=True (Fair sharing). |
lease_ttl | Attempts that do not call ctx.heartbeat() within this many seconds are failed as retryable. |
max_pending, overflow | Queue bound and what happens when it is reached: "wait", "reject", "shed_lowest", "shed_oldest". |
retry | Default RetryPolicy (3 attempts, exponential backoff). |
unknown_dependency, await_timeout | "reject" unknown dependency keys, or "await" them for up to await_timeout seconds. |
default_duration | Duration assumed for tasks without duration_hint. |
reservable_fraction | Share 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
| Type | Description |
|---|---|
Priority | CRITICAL, HIGH, NORMAL, LOW; ordered, hashable, .name, .value, int(). |
Codec, JsonCodec | Codec 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. |
TaskContext | id, attempt, kind, key, pool, tags, group (labels of the worker group the attempt was assigned to), timeout, deadline (Unix seconds); is_cancelled(), heartbeat(). |
TaskHandle | Awaitable; id, duplicate, await result(), done(), cancel(). |
Snapshot | pools, live_tasks, running, pending, totals (dict), pool(name). |
PoolSnapshot | The snapshot fields with times in seconds, plus pending. |
StatusEvent, EventStream | See 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.
| Exception | Raised |
|---|---|
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. |
TaskFailed | Awaiting a task that did not succeed. .status (terminal status, "shutdown", or "succeeded" when the output is no longer available), .message, .task_id. |
SubmitError | Submission rejected. .code: unknown_pool, unknown_resource, resource_exceeds_capacity, reservation_conflict, cycle, unknown_dependency, full, overloaded, duplicate_key_in_batch, invalid. |
SchedulerError | Scheduler stopped, misconfigured, or its store failed. |
EventsLagged | An 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().
| Method | Returns | Description |
|---|---|---|
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) | bool | By id or key. |
await get(ref) | TaskInfo | Raises 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 TaskInfo | Every matching task, fetched one page at a time. |
await wait(ref, timeout=None) | Result | Raises 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 Event | Terminal events by default; every transition with all_transitions=True. |
await snapshot() | Snapshot | Requires 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) | bool | Requires 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):
| Type | Fields |
|---|---|
Submitted | id, duplicate, result |
Result | id, status (terminal), output (bytes or None), message, output_available; .ok, .json(), .text() |
TaskInfo | id, 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 |
TaskPage | tasks (tuple of TaskInfo), next_after (int or None), unavailable_shards (shards that could not be reached) |
WorkerInfo | id, name, pool, labels, slots, busy, connected_at (Unix seconds) |
RecurringInfo | name, cron, tz (empty for UTC), pool, kind, overlap, next_at (Unix seconds or None), last_task_id (or None) |
Event | id, pool, key, status, attempt, at (Unix seconds), message |
Snapshot | pools, live_tasks, running, pending, totals (Totals), workers_connected, unavailable_shards; .pool(name) |
PoolSnapshot | name, 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 |
WorkerGroup | labels, capacity, busy: the connected workers of a pool that registered the same labels |
Totals | submitted, 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:
| Payload | Bytes sent | Content type |
|---|---|---|
bytes, bytearray, memoryview | as-is | application/octet-stream |
str | UTF-8 | text/plain; charset=utf-8 |
None | empty | none |
| anything else | JSON | application/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)
| Member | Description |
|---|---|
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. |
running | Leases running now. |
worker_id, lease_ttl, granted_slots, server_protocol, max_message_bytes | From the server's Registered message. |
last_shutdown, connections | Reason 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:
| Raise | Effect |
|---|---|
cronoq.Retryable(msg, retry_after=s) or any other exception | Retryable 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.
| Crate | docs.rs | Use it for |
|---|---|---|
cronoq | docs.rs/cronoq | Embedding the scheduler in a tokio application. Start here. |
cronoq-core | docs.rs/cronoq-core | The sans-IO engine: custom drivers, simulations, deterministic tests. |
cronoq-store-postgres | docs.rs/cronoq-store-postgres | Durable state and leader election for Scheduler<Bytes, Bytes>. |
cronoq-store-sqlite | docs.rs/cronoq-store-sqlite | The same, in a local SQLite file. |
cronoq-proto | docs.rs/cronoq-proto | Generated gRPC clients and servers (cronoq_proto::v1), PROTOCOL_VERSION. |
cronoq-server | docs.rs/cronoq-server | The 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 method | Default | Description |
|---|---|---|
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-in | Class scores and aging. |
urgency(UrgencyConfig) | built-in | Deadline urgency curve. |
store(impl Store) / store_arc(Arc<dyn Store>) | MemoryStore | Persistence backend. |
emit_transitions(bool) | false | Broadcast every transition on events(), not only terminal ones. |
queue_capacity(n) | 8192 | Capacity of the submission channel. |
events_capacity(n) | 4096 | Broadcast buffer of events(); slow subscribers lose the oldest events. |
max_store_outage(Duration) | 5 min | How long a store may report StoreError::Unavailable before the scheduler stops. |
drop_grace(Duration) | 5 s | Grace for running attempts when every handle is dropped without shutdown. |
output_cache(n) | 1024 | Recent 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>:
| Method | Description |
|---|---|
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 ().
| Method | Returns | Description |
|---|---|---|
submit(task) | TaskHandle<O> | Submit and get an awaitable handle. Waits for queue space with the Wait overflow policy. |
enqueue(task) | Submitted | Submit 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) | bool | false 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() | Snapshot | Metrics 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) | () / bool | Cron schedules. |
events() | broadcast::Receiver<StatusEvent> | Status events. |
pools(), clock() | &[Arc<str>], &Clock | Pool 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
| Type | Description |
|---|---|
Executor<P, O> | Trait: execute(ctx, Arc<P>) -> BoxFuture<Result<O, TaskError>>, optional abort(lease). |
FnExecutor | Wraps an async fn(TaskContext, Arc<P>); what Pool::executor_fn uses. |
TaskContext | id, 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. |
TaskError | Retryable { 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
| Type | Variants |
|---|---|
Error | Submit(SubmitError), Shutdown, Config(String), Store(StoreError), Internal(String). |
TaskFailure | Ended { status, message }, OutputUnavailable { id }, Shutdown; status(). |
StoreError | Fenced, Unavailable(String), Other(String); is_transient(). |
ScaleError | External, Other(String). |
Capacity and persistence
| Type | Description |
|---|---|
Scaler | Trait: turn a desired capacity into workers. |
SlotScaler | Default: capacity is a number of in-process concurrent attempts. |
ExternalScaler | Capacity 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. |
MemoryStore | No persistence (default). |
TimeBase | Wall-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.
| Feature | Default | Effect |
|---|---|---|
otel | on | OpenTelemetry metrics and W3C trace-context propagation (metrics reference). |
store-codec | off | The 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.
| Method | Description |
|---|---|
Core::new(CoreConfig) | Create; CoreConfig::validate() reports problems without panicking. |
submit, try_submit, submit_batch | Admission. |
complete(now, lease, Outcome), heartbeat(now, lease) | Attempt feedback. |
cancel, status, resolve, live_tasks, list_tasks, totals | Lookups 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_recurring | Cron 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
| Item | Description |
|---|---|
PgStore::connect(url) / connect_with(url, PgStoreConfig) | Connect and migrate. |
PgStoreConfig | schema (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().await | Leadership state. |
cronoq-store-sqlite
| Item | Description |
|---|---|
SqliteStore::open(path) / open_with(path, SqliteStoreConfig) | Open (creating if needed) and migrate. |
SqliteStoreConfig | retention, 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().await | As 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 field | Default | Description |
|---|---|---|
endpoint, pool | required | Server URL (http:// or https://) and pool name. |
slots | 1 | Concurrent leases requested (the server may grant fewer). |
token | none | Bearer token with the worker role. |
name | empty | Shown in server logs. |
tls | none | WorkerTls { ca_pem, identity, domain } for TLS and mTLS. Needs an https:// endpoint; with http:// the worker returns an error. |
labels | empty | Worker 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. |
reconnect | true | Reconnect with backoff when the connection drops or the server steps down. |
insecure_redirect_http | false | Follow 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_backoff | 200 ms, 10 s | Reconnect backoff bounds. |
drain_timeout | 30 s | How long running handlers may finish after shutdown resolves. |
max_message_bytes | 64 MiB | Largest 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.
| Option | Default | Description |
|---|---|---|
Token | none | Bearer token sent with every call. A token over plain http:// to a host other than loopback logs a warning. |
TLS | system roots | *tls.Config for https:// targets (CA, client certificate for mTLS, server name). It needs an https:// target. |
MaxMessageBytes | 64 MiB | Largest message received or sent. |
DialOptions | none | Extra grpc.DialOptions. |
Logger | slog.Default() | Receives warnings. |
Client
| Method | Returns | Description |
|---|---|---|
Submit(ctx, Task) | Submitted | Submit one task. Submitted.Duplicate is true when a task with the same key existed; its id is returned. |
SubmitAndWait(ctx, Task, timeout) | Submitted | Submit and wait up to timeout (0: the server's limits.max_wait_s); Submitted.Result holds the result. |
SubmitSpec(ctx, *cronoqpb.TaskSpec, wait, timeout) | Submitted | Submit a raw spec, for fields Task does not cover. |
SubmitBatch(ctx, []Task) | []Submitted | Submit atomically; tasks may depend on each other's keys. |
Get(ctx, Ref) | *TaskInfo | A task's details. |
Wait(ctx, Ref, timeout) | *Result | Wait for a task to finish. |
Cancel(ctx, Ref) | bool | Cancel a task that has not finished. |
ListTasks(ctx, ListOptions) | *TaskPage | One 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.SnapshotResponse | Pools, queues and totals (metrics role). |
Ref names a task: cronoq.ID(id) or cronoq.Key(key).
ListOptions field | Default | Description |
|---|---|---|
Pools | every pool the caller may use | Pools to list. |
Statuses | any | Statuses to list. |
After | 0 | Only tasks with a larger id; pass the previous page's NextAfter. |
Limit | 0 (= 100) | Page size, capped at the server's limits.max_list. |
TaskPage.NextAfter is 0 on the last page.
WatchOptions field | Default | Description |
|---|---|---|
Pools | every pool the caller may use | Pools to watch. |
IDs, Keys | none | Only events for these tasks. |
AllTransitions | false | Stream 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.
| Option | Default | Description |
|---|---|---|
Target, Pool | required | Server and pool. |
Slots | 1 | Leases run at once. |
Token | none | Bearer token with the worker role. |
Name | empty | Shown in server logs and worker listings. |
Labels | none | Worker labels (Worker labels). |
TLS | system roots | *tls.Config for https:// targets. A ServerName here applies to Target only. |
NoReconnect | false | Return when the connection fails instead of reconnecting. |
InitialBackoff, MaxBackoff | 200 ms, 10 s | Reconnect delay bounds. |
DrainTimeout | 30 s | How long running handlers may finish after ctx ends. |
InsecureRedirectHTTP | false | Follow a shard redirect to plain http:// on another host with the token. For trusted networks only. |
MaxMessageBytes | 64 MiB | Largest message sent or received. |
DialOptions | none | Extra grpc.DialOptions. |
Logger | slog.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:
| Return | Effect |
|---|---|
output, nil | The 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.ErrCancelTask | The task ends as cancelled. |
| any other error, or a panic | Retryable 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:
| Import | Transport | What works |
|---|---|---|
@synaptiai/cronoq/node | gRPC over HTTP/2 (Node 22 or newer) | The client and workers. |
@synaptiai/cronoq/web | gRPC-Web from a browser | The 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.
| Option | Default | Description |
|---|---|---|
token | none | Bearer token sent with every call. A token over plain http:// to a host other than loopback logs a warning. |
tls | Node's roots | { ca, cert, key, servername } (PEM) for https:// endpoints; cert and key for mTLS. It needs an https:// endpoint. |
maxMessageBytes | 64 MiB | Largest message sent or received. |
logger | console | Receives 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
| Method | Resolves to | Description |
|---|---|---|
submit(task) | Submitted | Submit one task. duplicate is true when a task with the same key existed; its id is returned. |
submitAndWait(task, waitTimeout?) | Submitted | Submit 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) | TaskInfo | A task's details. |
wait(ref, timeout?) | Result | Wait for a task to finish. |
cancel(ref) | boolean | Cancel a task that has not finished. |
listTasks({ pools, statuses, after, limit }) | TaskPage | One 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.SnapshotResponse | Pools, queues and totals (metrics role). |
listTasks filter | Default | Description |
|---|---|---|
pools | every pool the caller may use | Pools to list. |
statuses | any | Statuses to list. |
after | 0n | Only tasks with a larger id; pass the previous page's nextAfter. |
limit | 0 (= 100) | Page size, capped at the server's limits.max_list. |
watch filter | Default | Description |
|---|---|---|
pools | every pool the caller may use | Pools to watch. |
ids, keys | none | Only events for these tasks. |
allTransitions | false | Stream 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.
| Option | Default | Description |
|---|---|---|
target, pool | required | Server and pool. |
slots | 1 | Leases run at once. |
token | none | Bearer token with the worker role. |
name | empty | Shown in server logs and worker listings. |
labels | none | Worker labels (Worker labels). |
tls | Node's roots | As for connect; servername applies to target only. TLS settings with an http:// target are refused. |
signal | none | Stops the worker. |
noReconnect | false | Reject when the connection fails instead of reconnecting. |
initialBackoff, maxBackoff | 0.2 s, 10 s | Reconnect delay bounds. |
drainTimeout | 30 s | How long running handlers may finish after signal aborts. |
insecureRedirectHttp | false | Follow a shard redirect to plain http:// on another host with the token. For trusted networks only. |
maxMessageBytes | 64 MiB | Largest message sent or received. |
onRegistered | none | Called after each registration with the worker id, granted slots and endpoint. |
logger | console | Receives 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:
| Throw | Effect |
|---|---|
| nothing | The 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 else | Retryable 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
tracingspans (thecronoqcrate's defaultotelfeature). - 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)
| Metric | Type | Description |
|---|---|---|
cronoq_pool_workers | gauge | Current capacity: the slots of connected workers. |
cronoq_pool_desired_workers | gauge | Autoscaler target capacity. Feed it to KEDA or an HPA. |
cronoq_pool_busy_workers | gauge | Slots running an attempt. |
cronoq_pool_held_workers | gauge | Slots held idle for an upcoming fixed-start task. |
cronoq_pool_workers_connected | gauge | Connected worker streams. |
cronoq_pool_pending | gauge | Tasks waiting to run, by priority (extra label class: critical, high, normal, low). |
cronoq_pool_reservations | gauge | Fixed-start reservations not yet started. |
cronoq_pool_priority_adherence | gauge | Windowed fraction of dispatches with no strictly higher class waiting (0–1). |
cronoq_pool_fixed_start_hit_rate | gauge | Windowed fraction of fixed starts that began within tolerance (0–1). |
cronoq_pool_utilization | gauge | Windowed busy slot-seconds ÷ capacity slot-seconds (0–1). |
cronoq_pool_schedule_efficiency | gauge | Windowed completed work seconds ÷ capacity slot-seconds (0–1). |
cronoq_pool_wait_seconds | gauge | Windowed time from submission to first start, by quantile (0.5, 0.9, 0.99). |
cronoq_pool_cost_total | counter | Worker cost since the leader started (cost_per_worker_second × worker-seconds). |
cronoq_pool_dispatched_window | gauge | Attempts 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
| Metric | Type | Labels | Description |
|---|---|---|---|
cronoq_workers_connected | gauge | Connected worker streams, all pools. | |
cronoq_live_tasks | gauge | Tasks not yet finished. | |
cronoq_running_tasks | gauge | Attempts running. | |
cronoq_pending_tasks | gauge | Tasks waiting to run. | |
cronoq_tasks_total | counter | status | Tasks that reached succeeded, failed, dead_lettered, cancelled, skipped, missed or shed. |
cronoq_tasks_submitted_total | counter | Tasks admitted. | |
cronoq_attempts_dispatched_total | counter | Attempts dispatched. | |
cronoq_retries_total | counter | Retries scheduled. | |
cronoq_timeouts_total | counter | Attempts that hit their timeout. | |
cronoq_lease_expiries_total | counter | Leases that expired without a heartbeat. | |
cronoq_stale_completions_total | counter | Completions 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.
| Instrument | Kind | Attributes | Description |
|---|---|---|---|
cronoq.tasks.submitted | counter | pool | Tasks admitted. |
cronoq.tasks.finished | counter | pool, status | Tasks reaching a terminal status. |
cronoq.attempts | counter | pool, kind, outcome (ok / error) | Finished attempts. |
cronoq.attempt.duration | histogram (s) | pool, kind, outcome | Attempt run time. |
cronoq.pool.workers | observable gauge | pool | Current capacity. |
cronoq.pool.desired_workers | observable gauge | pool | Autoscaler target. |
cronoq.pool.busy_workers | observable gauge | pool | Busy slots. |
cronoq.pool.held_workers | observable gauge | pool | Held slots. |
cronoq.pool.pending | observable gauge | pool, class | Pending tasks by class. |
cronoq.pool.cost | observable gauge | pool | Cost 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
SubmitError | Python SubmitError.code | gRPC | HTTP | Cause | What to do |
|---|---|---|---|---|---|
UnknownPool(name) | unknown_pool | NOT_FOUND | 404 | No pool with that name. | Fix the pool name. |
UnknownResource(name) | unknown_resource | NOT_FOUND | 404 | No resource with that name. | Fix the resource name. |
ResourceExceedsCapacity(name) | resource_exceeds_capacity | INVALID_ARGUMENT | 400 | Requested units exceed the resource's permits or burst, so it could never run. | Request fewer units. |
ReservationConflict | reservation_conflict | FAILED_PRECONDITION | 409 | Fixed-start reservations would exceed the pool's reservable capacity at that time. | Pick another start time, raise reservable_fraction or max. |
Cycle | cycle | INVALID_ARGUMENT | 400 | The dependencies form a cycle. | Fix the graph. |
UnknownDependency(ref) | unknown_dependency | NOT_FOUND | 404 | Dependency on an unknown id, or an unknown key when the pool rejects unknown dependencies. | Submit the parent first, or set await_unknown_dependencies_s. |
Full | full | RESOURCE_EXHAUSTED | 429 | Queue bounds reached with the wait overflow policy. In-process callers wait for space instead of seeing this. | Retry with backoff. |
Overloaded | overloaded | RESOURCE_EXHAUSTED | 429 | Bounds reached with reject, or shedding found nothing lower to evict. | Retry with backoff, or shed load upstream. |
DuplicateKeyInBatch(key) | duplicate_key_in_batch | INVALID_ARGUMENT | 400 | A batch defines the same new key twice. | Deduplicate the batch. |
Invalid(reason) | invalid | INVALID_ARGUMENT | 400 | Malformed 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:
| Status | Rust | Python | Meaning |
|---|---|---|---|
succeeded | Ok(Arc<O>) | the return value | The 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. |
failed | Err(Ended { status: Failed, .. }) | TaskFailed(status="failed") | Permanent error, or a fixed start missed with on_miss = fail. |
dead_lettered | Ended { status: DeadLettered, .. } | "dead_lettered" | Retryable failures used up every attempt. |
cancelled | Ended { status: Cancelled, .. } | "cancelled" | Cancelled by a caller. |
skipped | Ended { 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. |
missed | Ended { status: Missed, .. } | "missed" | Fixed start missed with on_miss = skip. |
shed | Ended { 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 TaskError | Python | Remote worker (Fail) | Effect |
|---|---|---|---|
Retryable { message, retry_after } | raise Retryable(msg, retry_after=s), or any other exception | permanent = false, retry_after_s | Retried per the retry policy, not before retry_after. |
Permanent { message } | raise Permanent(msg) | permanent = true | Fails the task now (failed). |
Cancelled | CancelledError caused by cronoq | Cancelled | The attempt was aborted; the engine already decided what happens next. |
| panic in a Rust executor | – | panic in a run_worker handler: retryable | In-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 Error | Python | Meaning |
|---|---|---|
Submit(SubmitError) | SubmitError | See above. |
Shutdown | SchedulerError | The scheduler has stopped or is stopping. |
Config(String) | ValueError (at construction) or SchedulerError | Invalid configuration: no pools, missing executor, duplicate pool, min > max, bad cron or time zone, recurring template with one-shot fields. |
Store(StoreError) | SchedulerError | Persistence failed fatally (see below). |
Internal(String) | SchedulerError | Internal 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
StoreError | Driver 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. |
Fenced | Another 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)
| Exception | When |
|---|---|
ClientError | A gRPC call failed. .code is the lower-case status name (unauthenticated, permission_denied, not_found, resource_exhausted, failed_precondition, invalid_argument, unavailable, data_loss, ...). |
TimeoutError | Client.wait(timeout=...) or submit(..., call_timeout=...) ran out of time. |
Server status codes
| gRPC | HTTP | Retry? | Cause |
|---|---|---|---|
INVALID_ARGUMENT | 400 | no | Malformed request or spec. |
UNAUTHENTICATED | 401 | no | Missing or unknown credentials. |
PERMISSION_DENIED | 403 | no | Role, 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_FOUND | 404 | no | Unknown task, pool, resource or dependency. |
ALREADY_EXISTS | 409 | no | The key is already used by a task in a pool outside the credentials' scope. |
FAILED_PRECONDITION | 409 | after changing the request | Fixed-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, 415 | no | Body too large, wrong content type. |
RESOURCE_EXHAUSTED | 429 | yes, with backoff | Queue bounds reached. |
INTERNAL | 500 | maybe | Unexpected server error. |
UNAVAILABLE | 503 | yes, with backoff | Not the leader, shutting down, store unavailable. |
DEADLINE_EXCEEDED | 504 | wait again | A wait timed out while the task is still running; over HTTP, also a request that exceeded its time limit. |
CANCELLED | – | maybe | A gRPC call ran longer than limits.max_wait_s + limits.request_timeout_s. |
DATA_LOSS | – | reconnect | A 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
| Crate | Responsibility |
|---|---|
cronoq-core | The scheduling engine. No clock, threads, I/O or unsafe. |
cronoq | Tokio driver, public API, executors, scalers, Store trait, telemetry, blocking facade. |
cronoq-store-postgres | Durable Store with leader election and fencing. |
cronoq-store-sqlite | Durable Store in a local file, with a lease for leader election and fencing. |
cronoq-proto | Protobuf definitions (vendored protoc, no system dependency). |
cronoq-server | gRPC and HTTP services, remote executor, auth, metrics, worker client. |
cronoq-py | PyO3 bindings (abi3), compat shim and gRPC client. |
cronoq-cli | The cronoq command-line client for the server, with a live top view. |
cronoq-operator | Kubernetes operator that scales worker Deployments to their pool's desired capacity. |
cronoq-store-tests | Conformance 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
| Structure | Purpose | Cost |
|---|---|---|
| Slab of task entries | Task storage; queues hold 8-byte keys | O(1) insert/remove |
| Per-class FIFO lane (sorted deque by sequence) | Ready tasks without deadlines, in pools without fairness | O(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 deadlines | O(log n) |
| Per-class fair-share queue (B-tree by start tag) | Ready tasks without deadlines, in pools with fairness | O(log n) |
| Due queue and pending holds | Fixed-start tasks at or near their start | O(log n) |
| Reservation calendar (B-tree by start) | Admission of fixed starts | O(k log k) for k overlapping |
| Resource waiter queues | Tasks parked on a resource, FIFO | O(1) amortized |
| Label waiter queues (one FIFO per label constraint) | Tasks waiting for a free slot on a worker with the labels they require | O(log c) for c constraints, then O(1) |
| Dependency counters + reverse edges | DAG resolution | O(children) per completion |
| Timer heap | Retries, holds, starts, misses, timeouts, leases, refills, autoscaling, cron | O(log n), lazy invalidation |
| Retention table | Terminal statuses for late dependents and dedup | O(1) |
| Window ring + log-linear histograms | Metrics | O(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
| Layer | Approach |
|---|---|
| Engine | Unit 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. |
| Runtime | Tests under tokio's paused clock; OpenTelemetry export test. |
| Store | One conformance suite run against every store (throwaway PostgreSQL servers, temporary SQLite files); fencing, pg_terminate_backend failover, lease expiry and takeover. |
| Server | Real gRPC/HTTP listeners with real workers; active-passive failover test. |
| Python | pytest suites for the bindings, the compat shim and the client against a real server, run against built wheels on Linux, macOS and Windows. |
| Go | go test -race for the SDK against a real server, with the generated stubs checked against the protocol. |
| TypeScript | node: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. |
| Kubernetes | The 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. |
| Performance | Criterion 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
HighorCriticalwork; - 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.
- aging lets long-waiting background work overtake ordinary work, but never
- 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.0tag 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 (
ReservationConflictwhen 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, andQueue/QueueOnSuccessto 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_failureandon_completeedges, 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 taketenant_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,ShedLowestandShedOldestoverflow 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::validatereports 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
Executortrait, with cancellation tokens, heartbeats andTaskContext::for_testfor unit tests. Executor panics are isolated. - Pluggable scalers (in-process slots, external) and stores.
CoreConfig::id_shardputs 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_taskspages through live tasks by pool and status,Scheduler::list_recurringshows schedules with their next occurrence, and durable stores list finished tasks (StoreQueries::list_finished;TaskRecordcarries 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_gracefor dropped schedulers, a bounded output cache withTaskFailure::OutputUnavailable, and a configurable status-event buffer. - OpenTelemetry metrics and W3C trace propagation (
otelfeature, 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,
Nackhand-backs,Cancelledreports,Shutdownnotices 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", ...]). Watchwith pool, id and key filters and optional intermediate transitions; richerGetresults; waits bounded bylimits.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 unlessinsecure_peer_http), and listings,Watchand snapshots cover every shard, naming unreachable shards inunavailable_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 unlessinsecure_redirect_httpis set. - gRPC-Web for browser clients on the gRPC listener, off unless
[server.grpc_web]lists the allowed origins: unary calls andWatch, the same authentication, CORS only for those origins, and in loopback-only mode only loopback hosts. - Listing:
ListTasks/GET /v1/taskspages through live and finished tasks by pool and status (pages capped bylimits.max_list), andListWorkers/GET /v1/workersandListRecurring/GET /v1/recurringshow connected workers and schedules. Each lists only the pools the caller's credentials allow. limits.key_retention_sandkey_retention_maxset 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. --checkto validate configuration,--print-prototo print the API.cronoq_server::runandrun_with_listeners, which serves on listeners the caller has already bound, to embed the server in a Rust program.
Command-line tool (cronoq-cli)
cronoqsubmits, 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 topshows 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_TOKENor--token-file, never the command line; TLS options are refused with anhttp://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 andon_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)
Dialreturns 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.RunWorkerserves 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 arebigints; 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 answerUNAVAILABLE. Shards need TLS or an explicitinsecurePeerHttp. cronoq-operator(an image and a chart) scales worker Deployments to their pool's desired capacity: aCronoqWorkerScalernames a configured server, a pool, a Deployment, the slots per replica and replica bounds, and the operator setsceil(desired / slots)replicas through thescalesubresource, holding them while the pool cannot be read. Server tokens stay in the operator's configuration; its ClusterRole covers only the scalers anddeployments/scale.- Tested in CI: every rendered configuration passes
cronoq-server --check, and on a kind cluster two replicas on PostgreSQL fail over,helm upgrade --waitcompletes, 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,metricsandadminfor 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.1by default; the server refuses to listen elsewhere without credentials unless--insecure-no-authis 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.schemaputs 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). /metricsand/v1/snapshotrequire the metrics role; an optional unauthenticatedmetrics_addrlistener 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-fullorverify-ca.