Rust client for QueueFlow, a PostgreSQL-native distributed job queue and workflow engine.
- Typed end-to-end: request/response models mirror the server's OpenAPI 3.1 spec.
- Ergonomic: one method per endpoint on
QueueFlow,wait_for_job/wait_for_workflowpollers, and a workflow builder with local DAG validation: steps, per-step queues,whenconditions, fan-out, inline sub-workflows and a whole-workflow deadline. - Worker runtime:
run_workerleases jobs, runs your async handlers concurrently, heartbeats them, and reports outcomes, with the server's retry policy and dead-letter queue doing the rest. - Typed errors: every non-2xx response is a
FacadeError::Httpcarrying the status and body. - Async (tokio + reqwest).
- Thin facade over a generated core: the facade (
src/facade.rs) adds what codegen cannot; the generatedapis::*functions andmodels::*types stay available for everything else.
The crate is queueflow-sdk; the bare queueflow crate name belongs to the server and CLI binary.
[dependencies]
queueflow-sdk = "0.5"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
serde_json = "1"use std::collections::HashMap;
use queueflow_sdk::{QueueFlow, WaitOptions, WorkflowBuilder};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let qf = QueueFlow::new("http://localhost:8000", "dev");
// Enqueue a job and wait for the result.
let payload = HashMap::from([("hello".to_owned(), serde_json::json!("world"))]);
let id = qf.create_job("echo", Some(payload)).await?;
let done = qf.wait_for_job(&id, WaitOptions::default()).await?;
println!("job {} finished as {} with {:?}", done.id, done.status, done.result);
// completed, Some({"hello": "world", "echoed": true})
// Declare and run a DAG workflow. `build()` validates it locally (duplicate
// names, dangling deps, cycles) before anything goes over the wire.
let dag = WorkflowBuilder::new("etl")
.step("extract", "echo", &[])
.step("transform", "echo", &["extract"])
.step("load", "echo", &["transform"]);
let wf_id = qf.create_workflow(&dag).await?;
let finished = qf.wait_for_workflow(&wf_id, WaitOptions::default()).await?;
println!("workflow {} finished as {}", finished.id, finished.status);
Ok(())
}echo is a handler built into the server; it returns the payload plus "echoed": true.
use std::time::Duration;
use queueflow_sdk::{QueueFlow, QueueFlowOptions};
let qf = QueueFlow::new(base_url, token);
// or, with a separate worker token and a custom timeout:
let qf = QueueFlow::with_options(
QueueFlowOptions::new("http://localhost:8000", "dev") // tenant token (required)
.worker_token("worker-secret") // for lease/heartbeat/complete/fail and run_worker
.timeout(Duration::from_secs(30)), // per-request HTTP timeout (default 30s)
)?;QueueFlow is Clone (cheap: it wraps two reqwest::Clients). The per-request timeout applies
to every call except the lease long-poll, which is bounded by wait_secs + 35s instead.
| Field | Description |
|---|---|
qf.config |
The generated Configuration with the tenant token; pass &qf.config to any apis::* function. |
qf.worker_config |
The generated Configuration with the worker token and no client-level timeout. |
Json is HashMap<String, serde_json::Value>, the type of every payload, result, context and
metadata.
| Method | Description |
|---|---|
create_job(task, payload: Option<Json>) -> String |
Enqueue with the server defaults; returns the job id. |
create_job_with_options(task, CreateJobOptions) -> String |
Enqueue with config, idempotency key and run_at; returns the job id. |
get_job(id) -> models::Job |
Fetch a job. |
list_jobs(&ListOptions) -> models::ListJobsResponse |
List jobs (status, queue, task_name, limit, offset, order_by, include_total, cursor, created_after, created_before). |
cancel_job(id) |
Cancel a job. |
cancel_jobs(&CancelJobsFilter) -> u64 |
Cancel every non-terminal job matching the filter, up to limit; returns how many were cancelled. |
wait_for_job(id, WaitOptions) -> models::Job |
Poll until completed / failed / cancelled. |
CreateJobOptions is {payload, queue, priority, max_retries, timeout_secs, retry_backoff, retry_delay_secs, retry_max_delay_secs, jitter_factor, idempotency_key, run_at}, all Options
with Default; unset fields are not sent, so the server defaults apply. run_at is an RFC 3339
string. The same idempotency_key returns the original job id.
WaitOptions is {timeout, interval} with a Default of 60s and 500ms
(WaitOptions::with_timeout(d) keeps the default interval). Past the deadline the call returns
FacadeError::Timeout.
List responses carry next_cursor when there are more pages; pass it back as cursor for keyset
pagination (cheaper than deep offset).
CancelJobsFilter is {queue, task_name, status, created_after, created_before, limit}, all
Options with Default; unset fields are not sent. status narrows to pending, retrying or
running (a terminal status is a 400). The server cancels at most limit jobs per call (default
100), so a return value below the limit means the matching set is exhausted; jobs that finish
concurrently are left as they ended.
use queueflow_sdk::CancelJobsFilter;
let n = qf.cancel_jobs(&CancelJobsFilter {
queue: Some("billing".to_owned()),
status: Some("pending".to_owned()),
..CancelJobsFilter::default()
}).await?;
println!("cancelled {} pending billing jobs", n);| Method | Description |
|---|---|
create_workflow(&WorkflowBuilder) -> String |
Validate the DAG locally, create it, return the workflow id. |
get_workflow(id) / list_workflows(&ListOptions) / cancel_workflow(id) |
Fetch / list / cancel (cancelling also cancels fan-out children and child workflows). |
retry_workflow(id) |
Re-run a failed, partially_failed or cancelled workflow from where it stopped; a running or completed one is a 409. |
workflow_diagram(id) -> models::WorkflowDiagramResponse |
Mermaid (graph TD) diagram of the DAG. |
workflow_steps(id) -> Vec<models::WorkflowStepState> |
Live state of every step in declaration order: status, job_id, error, started_at, completed_at, child_workflow_id, fan_out counts. |
wait_for_workflow(id, WaitOptions) -> models::Workflow |
Poll until a terminal workflow state. |
The models::Workflow record carries the step definitions, the accumulated context (step
results keyed by step name), error_message, timeout_secs and deadline_at, retry_count,
and parent_workflow_id / parent_step_name on a child spawned by a sub-workflow step. The
models::WorkflowStepState entries from workflow_steps carry the error behind a failed,
skipped (condition not met) or cancelled step, the fan_out {total, completed, failed}
counts of a fan-out step and the child_workflow_id of a sub-workflow step.
retry_workflow puts every step that did not complete (failed, cancelled, or skipped by a
failure policy) back to pending and schedules it again as its dependencies allow; completed and
condition-skipped steps keep their results. The workflow becomes running, error_message
clears, retry_count increments and the deadline, if any, is re-armed from timeout_secs. Fix
the cause first: a retry with the same inputs fails the same way.
use queueflow_sdk::models::{JobConfig, OnFailure};
use queueflow_sdk::{Json, StepOptions, WorkflowBuilder};
let dag = WorkflowBuilder::new("order_123")
.step("validate", "validate_order", &[])
.step_with_options("pay", "process_payment", StepOptions {
after: vec!["validate".to_owned()],
payload: Some(Json::from([("amount".to_owned(), serde_json::json!(42))])),
on_failure: Some(OnFailure::Halt),
config: Some(JobConfig { max_retries: Some(5), ..JobConfig::new() }),
..StepOptions::default()
})
.step("ship", "create_shipment", &["pay"])
.context(Json::from([("source".to_owned(), serde_json::json!("web"))]));
let body = dag.build()?; // duplicate names, dangling deps and cycles fail here, before any requeststep(name, task, after) adds a step gated on the named after steps; step_with_options adds
payload, config, queue, on_failure, on_success, when, fan_out and metadata.
context and metadata seed the workflow-level fields; timeout_secs sets the whole-workflow
deadline (1 second to 30 days). create_workflow calls build() for you.
Conditions, fan-out and sub-workflows (server 0.6.0 and later) use the generated models directly:
use queueflow_sdk::models::{ConditionOp, FanOut, StepCondition};
use queueflow_sdk::{StepOptions, WorkflowBuilder};
fn on(after: &[&str]) -> StepOptions {
StepOptions {
after: after.iter().map(|s| s.to_string()).collect(),
queue: Some("orders".to_owned()), // every step's job(s) run on this queue
..StepOptions::default()
}
}
let fulfilment = WorkflowBuilder::new("fulfilment")
.step_with_options("pick", "pick_items", on(&[]))
.step_with_options("pack", "pack_parcel", on(&["pick"]))
.timeout_secs(1800)
.build()?;
let dag = WorkflowBuilder::new("order_123")
.step_with_options("fetch", "fetch_order", on(&[]))
// Runs only when fetch.score > 0.8; otherwise skipped with `condition not
// met`, and its dependents still run (without a result for it).
.step_with_options("review", "queue_for_review", StepOptions {
when: Some(StepCondition {
path: Some(Some("fetch.score".to_owned())),
op: Some(Some(ConditionOp::Gt)),
value: Some(Some(serde_json::json!(0.8))),
..StepCondition::new()
}),
..on(&["fetch"])
})
// One child job per element of fetch.images, each with the element under
// `image`; the step result is {"results": [...]} in element order.
.step_with_options("resize", "resize_image", StepOptions {
fan_out: Some(FanOut {
item_key: Some(Some("image".to_owned())),
..FanOut::new("fetch.images".to_owned())
}),
..on(&["fetch"])
})
// An inline child workflow; its final context becomes the step result.
.sub_workflow("fulfil", fulfilment, on(&["review", "resize"]))
.timeout_secs(3600);A StepCondition is a leaf (path, optional op, value) or one of all, any, not. The
operators are eq (the default with a value), ne, gt, gte, lt, lte, exists (the
default without one), contains and in; a missing path never errors, it compares as absent.
sub_workflow(name, child, opts) takes the models::CreateWorkflowRequest another builder's
build() produced; opts.payload is merged into the child's context and the parent's context is
available to it under _parent. build() runs the server's structural checks locally
(duplicate names, dangling deps, cycles, a step with neither task nor workflow or with both a
fan-out and a workflow, condition shape and operator/value consistency, fan-out limits, the
timeout range and the 8-level nesting limit), recursively through sub-workflows, and returns
FacadeError::InvalidWorkflow before any request.
Run task handlers in this process against a remote QueueFlow server:
use std::time::Duration;
use queueflow_sdk::{HandlerError, Handlers, Json, WorkerOptions};
let handlers = Handlers::new()
.register("send-email", |ctx| async move {
// ctx.job is the leased models::Job; ctx.cancel fires if the lease is
// lost or the drain timeout passes (the handler future is dropped too).
let payload = ctx.job.payload.unwrap_or_default();
if payload.get("to").is_none() {
return Err(HandlerError::non_retryable("no recipient")); // dead-letters immediately
}
send_email(&payload).await?; // any std::error::Error converts to a retryable failure
Ok(Json::from([("sent".to_owned(), serde_json::json!(true))]))
});
let opts = WorkerOptions {
concurrency: 4, // jobs in flight at once (default 1)
lease_secs: 30, // heartbeat at half this interval (default 30)
wait_secs: 20, // server-side long poll when idle (default 20, max 30)
drain_timeout: Duration::from_secs(30), // wait for in-flight handlers after stop (default 30s)
..WorkerOptions::default()
};
let stop = opts.stop.clone(); // a CancelToken; keep it to stop the worker
let worker = tokio::spawn({
let qf = qf.clone();
async move { qf.run_worker("default", handlers, opts).await }
});
tokio::signal::ctrl_c().await?;
stop.cancel();
worker.await??;run_worker leases up to concurrency jobs per call (one per free slot), runs each handler on
its own tokio task, heartbeats every job at half the lease interval, and reports complete or
fail. A heartbeat that shows the lease is lost (HTTP 409, or any status other than running)
fires ctx.cancel, drops the handler future and suppresses the report; the server already owns
the outcome. Jobs whose task has no handler are failed non-retryably; a panicking handler kills
only its own task (its lease expires and the server redelivers).
When opts.stop is cancelled, run_worker abandons the in-flight lease long-poll, waits up to
drain_timeout for running handlers to finish and report, then returns Ok(()). It returns the
FacadeError::Http when the lease call is rejected with 401 or 403 (a wrong or missing worker
token cannot heal by retrying); other lease errors back off one second and are printed to
stderr on the first failure and every 30th after that (on_error replaces that).
Delivery is at-least-once, so handlers must be idempotent. Servers started with
--worker-token require QueueFlowOptions::worker_token.
The raw protocol is also exposed: lease_jobs(queue, LeaseOptions),
heartbeat_job(&lease, extend_secs), complete_job(&lease, result),
fail_job(&lease, message, retryable).
| Method | Description |
|---|---|
create_cron(name, schedule, task) -> String |
Register a recurring enqueue (5-field crontab, UTC); returns the schedule id. |
create_cron_with_options(name, schedule, task, CreateCronOptions) -> String |
Also set payload, queue and a config override. |
get_cron(id) / list_crons(&ListOptions) / delete_cron(id) |
Fetch / list / delete. |
pause_cron(id) / resume_cron(id) |
Stop firings / resume at the next future occurrence. |
| Method | Description |
|---|---|
list_dead_letters(&ListOptions) / get_dead_letter(id: i64) |
Inspect terminally-failed jobs. ListOptions::task_name and ListOptions::replayed (Some(false) = not yet replayed, Some(true) = replayed) narrow the list. |
replay_dead_letter(id: i64) -> String |
Re-run one as a fresh job; returns the new job id (at most once; a second replay is a 409). |
replay_dead_letters(&ReplayDeadLettersFilter) -> Vec<models::ReplayedDeadLetter> |
Re-run every unreplayed entry matching the filter, up to limit; each result has the dead_letter_id plus the new job_id or an error. |
ReplayDeadLettersFilter is {queue, task_name, created_after, created_before, limit}, all
Options with Default. Only unreplayed entries are considered, so calling it again replays
only what the previous call did not reach (default limit 100).
use queueflow_sdk::ReplayDeadLettersFilter;
let results = qf.replay_dead_letters(&ReplayDeadLettersFilter {
queue: Some("billing".to_owned()),
..ReplayDeadLettersFilter::default()
}).await?;
for r in &results {
match (&r.job_id, &r.error) {
(Some(Some(job)), _) => println!("dead letter {} replayed as job {}", r.dead_letter_id, job),
(_, Some(Some(err))) => println!("dead letter {} not replayed: {}", r.dead_letter_id, err),
_ => {}
}
}| Method | Description |
|---|---|
stats() -> models::StatsSnapshot |
Engine counters. |
queues() -> Vec<models::QueueStats> |
Live per-queue backlog: pending, scheduled, running, oldest_pending_age_secs, paused. |
pause_queue(name) / resume_queue(name) -> models::QueueControlResponse |
Stop / allow claims of this tenant's jobs on a queue. Idempotent; the response carries paused, running and pending. |
tasks() -> Vec<String> |
Registered task handler names. |
whoami() -> models::WhoAmIResponse |
The tenant the token is scoped to and its access (read_write or read_only). |
health() / ready() |
GET /health / GET /ready. |
Pausing a queue stops workers claiming this tenant's jobs on it; jobs already running finish, so
the queue is drained once running reaches zero. Pending jobs accumulate until resume_queue.
Anything the facade does not wrap (batch enqueue, raw request bodies) is one call away in
queueflow_sdk::apis, each function taking &qf.config:
use queueflow_sdk::apis::jobs_api;
use queueflow_sdk::models::CreateBatchJobsRequest;
let batch = jobs_api::create_batch_jobs(&qf.config, CreateBatchJobsRequest::new(reqs)).await?;Per-endpoint and per-model reference for the generated layer lives in docs/.
Every request carries Authorization: Bearer <token>. Two credentials exist:
- The tenant token (
QueueFlowOptions::token) authenticates the job, workflow, cron, DLQ and system routes. On a server started without--api-keys, any non-empty token is accepted (development mode). - The worker token (
QueueFlowOptions::worker_token) authenticates the worker-protocol routes (lease, heartbeat, complete, fail), used byrun_worker. Configure it on the server with--worker-token/QUEUEFLOW_WORKER_TOKEN. It is a separate secret, never a tenant token. It defaults to the tenant token, which only works on a server running without one.
Facade methods return FacadeError:
Http { op, status, body }: a non-2xx response;err.status()returnsSome(status).Transport { op, source }: no response, or it did not decode (connection refused, timeout, TLS, JSON).InvalidWorkflow(String): a DAG failed local validation, before any request.InvalidOptions(String):with_optionswas given an emptybase_urlortoken.Timeout(String): await_for_*poller, or a worker-protocol call, ran out of time.
use queueflow_sdk::FacadeError;
match qf.get_job("missing").await {
Ok(job) => println!("{}", job.status),
Err(err) if err.status() == Some(404) => println!("no such job"),
Err(FacadeError::Http { op, status, body }) => eprintln!("{} failed: HTTP {}: {}", op, status, body),
Err(other) => return Err(other.into()),
}Worker handlers return HandlerError: Retryable(String) (the server applies the job's retry
policy) or NonRetryable(String) (dead-letters immediately). Any std::error::Error converts
into Retryable with ?; HandlerError deliberately does not implement std::error::Error
itself, which is what makes that blanket conversion possible.
Generated functions return apis::Error<OperationError> directly; its ResponseError variant
carries the HTTP status, raw content, and the decoded error entity.
The generated jobs_api::stream_job_events and workflows_api::stream_workflow_events cannot
consume the server's SSE streams: each buffers the whole response until the stream closes and
then fails to decode it. Use wait_for_job / wait_for_workflow (polling) instead, or a
hand-rolled SSE consumer over GET /api/v1/jobs/{id}/events or
GET /api/v1/workflows/{id}/events.
tests/live.rs runs the facade against a real QueueFlow server (started with the default
queueflow serve, i.e. --mode all, so the built-in echo handler is registered). It creates
and waits on an echo job, checks idempotent re-creation and list filters, runs a two-step
workflow, exercises the cron, dead-letter, queue backlog, stats and whoami endpoints, and runs
run_worker against a dedicated queue: one scenario completes a job with a handler only the test
knows, another dead-letters a job with HandlerError::non_retryable and replays it. Bulk cancel,
bulk replay and pause/resume each run against a queue named for that run alone, so the suite is
safe on a shared server. Four workflow scenarios (server 0.6.0 and later) put every step on a
rust-live-<uuid> queue served by the test's own worker: a condition-skipped step whose
dependent still runs, a fan-out with ordered results and fan_out counts, a sub-workflow child
linked to its parent, and a retry after a non-retryable failure. The tests skip themselves unless QUEUEFLOW_URL is set, so plain
cargo test and CI stay offline.
QUEUEFLOW_URL=http://localhost:8000 \
QUEUEFLOW_TOKEN=dev \
QUEUEFLOW_WORKER_TOKEN=worker-secret \
cargo test --test liveQUEUEFLOW_TOKEN is the tenant token (default dev). QUEUEFLOW_WORKER_TOKEN is the server's
--worker-token; it defaults to the tenant token, which only works when the server runs without
one.
This crate is a thin hand-written facade over a generated core:
queueflow-sdk-rust/
├── src/
│ ├── facade.rs the hand-written facade (QueueFlow, run_worker, WorkflowBuilder, FacadeError)
│ ├── lib.rs declares and re-exports the facade
│ ├── apis/ generated per-tag functions (jobs_api, workflows_api, worker_api, ...)
│ └── models/ generated models
├── tests/
│ ├── facade.rs offline facade tests (wiremock)
│ └── live.rs live conformance tests (need QUEUEFLOW_URL)
└── docs/ generated per-endpoint and per-model reference
The core is generated by openapi-generator from the server's
OpenAPI spec
(scripts/generate-sdks.sh rust in queueflow-core). The facade is injected at generation time
from sdk-templates/rust/facade.mustache in that repo, so edit the template, not
src/facade.rs. Cargo.toml and src/lib.rs come from Cargo.mustache and lib.mustache
in the same directory.
cargo clippy --all-targets
cargo test- Documentation: docs.queueflow.dev
- Server: github.com/elision-labs/queueflow-core
- This SDK: github.com/elision-labs/queueflow-sdk-rust