The Queue facade dispatches background work to a driver and lets a separate
worker process drain it: HTTP handlers return fast, the heavy lifting runs
behind the scenes. Reach for it whenever a request would otherwise block on
something that can be done later - sending mail, hitting a webhook, generating
a report. Pair with Bus when you want the work to run now in the
current task and return a typed result; pair with Events when
you want one signal to fan out to many listeners.
Quick start
Define a job, register it once at boot, push it:
use async_trait;
use ;
use ;
// Boot once (the worker process and the dispatch process both need this).
set_driver;
;
// Push from a handler:
push.await?;
A worker process drains the configured driver until cancelled:
use Arc;
use Duration;
use CancellationToken;
use ;
let driver = driver?;
let cfg = WorkerConfig ;
let shutdown = new;
run_worker.await;
In a scaffolded app, the worker is started by the binary's queue:work
subcommand - cargo run -- queue:work - which runs the same bootstrap your
HTTP server does, so observers and listeners registered in bootstrap()
fire identically for inserts from a queue handler.
Drivers
Five drivers ship in-tree. Configure via QUEUE_DRIVER env or by calling
Queue::set_driver(...) programmatically.
| Driver | Use for | Strengths |
|---|---|---|
MemoryQueueDriver |
tests, single-process apps | tokio::time::DelayQueue for available_at, virtual-clock compatible |
RedisQueueDriver |
production fan-out | consumer groups + XAUTOCLAIM + ZSET-backed delayed jobs |
DatabaseQueueDriver |
single-DB apps | FOR UPDATE SKIP LOCKED on Postgres/MySQL, BEGIN-serialised on SQLite |
SyncQueueDriver |
dev, CI | runs the handler inline on push, no worker |
NullQueueDriver |
testing wrappers | drops every push without running |
Queue::bootstrap_from_env() reads QUEUE_DRIVER and wires the matching
driver; Queue::bootstrap_default() always wires the memory driver. The
server boot path calls one of these for you - most apps only configure via
env.
Environment configuration
QUEUE_DRIVER=redis
QUEUE_REDIS_URL=redis://127.0.0.1:6379
QUEUE_REDIS_STREAM=suprnova-queue
QUEUE_REDIS_GROUP=default
QUEUE_REDIS_CONSUMER=consumer-1
QUEUE_VISIBILITY_TIMEOUT_SECS=60
# Database driver - DB::init() must run first
QUEUE_DRIVER=database
QUEUE_DB_TABLE=jobs
The database driver validates QUEUE_DB_TABLE as a SQL identifier at
construction, so a malformed env value fails boot rather than reaching SQL
composition. Redis uses sea-streamer-redis under the hood with
AutoCommit::Disabled; the visibility timeout is fixed at consumer-group
construction time, so the per-pop visibility_timeout argument is ignored
on Redis (a documented divergence from the trait contract imposed by
Redis Streams).
Why Suprnova diverges
Laravel routes every queueable through the Bus, distinguishing
ShouldQueue jobs at dispatch time. Suprnova splits the two: Bus for
synchronous work that returns a typed result, Queue for asynchronous
work that survives a process crash. PHP needs the implicit routing
because its request-per-process model makes "do this later, in another
process" hard to model otherwise. Tokio doesn't - explicit Bus::dispatch
vs Queue::push is clearer, faster, and surfaces the durability choice
at the call site. See bus.md for the side-by-side.
Push variants
Every push variant takes a typed J: Job value and returns when the
envelope is committed to the driver - not when the handler runs.
| Method | Behavior |
|---|---|
Queue::push(job) |
enqueue immediately |
Queue::push_later(job, at) |
available at a specific DateTime<Utc> |
Queue::later(delay, job) |
available after delay from now |
Queue::push_unique(job) |
dedupe by J::unique_id within J::unique_for, returns Ok(true) for fresh, Ok(false) for duplicate |
Queue::push_unique_later(job, at) |
unique + scheduled |
Queue::later_unique(delay, job) |
unique + delayed |
Queue::bulk(vec![job1, job2, ...]) |
push every job (driver may use a native bulk path) |
push_unique requires the cache layer to be bootstrapped - the dedupe
lock lives in Cache via
Idempotency::commit_on_success. A failed push releases
the dedupe key so the caller can retry; a successful push holds it for
J::unique_for seconds. The job must override Job::unique_id(&self) to
return Some(id) - None returns an internal error.
Job configuration
Override Job's associated functions to tune behavior per impl:
use Duration;
use ;
Queue routing
By default every job goes to one queue and every worker drains all of it. Once some jobs are slower or more important than others, you want dedicated worker pools: a long-running export shouldn't sit behind a thousand welcome emails.
A job can state where it belongs:
…and an operator can override that centrally, without touching the job:
// bootstrap::register()
use Queue;
;
;
Resolution runs highest-priority first:
- a route registered with
Queue::route - the job's own
Job::queue/Job::connection - the driver / global default
Passing None for a field leaves that dimension alone, so routing a job's
connection does not disturb the queue it already declared.
The two dimensions run at different depths today. The queue is honored end
to end - stamped on the envelope, stored by the driver, filtered by --queue.
The connection resolves the connection name carried on the JobQueueing
/ JobQueued lifecycle events, which is what listeners and dashboards see;
one process-global driver still receives every push, so routing a job's
connection does not yet select a different driver. Declaring connections now
is forward-compatible for when per-connection drivers land, not behavioral.
Then dedicate a worker to it:
A job with no route belongs to default, so --queue=default drains
unrouted work rather than stranding it.
Why Suprnova diverges
Laravel's Queue::route(...) takes a class string; Suprnova takes the job as a
type parameter, so a renamed or deleted job is a compile error rather than a
route that silently stops matching.
The larger divergence is what happens when a driver can't filter.
QueueDriver::pop_from rejects a queue filter it cannot honor instead of
falling back to draining everything. A worker told to drain only billing that
quietly drains all queues looks identical to a working deployment until the
wrong pool consumes the wrong jobs - so the misconfiguration is made loud at
the first poll. The memory and database drivers filter natively; a driver that
doesn't - the Redis driver is one, since a single stream consumer group has no
per-queue storage - will error rather than mislead.
The jobs table
DatabaseQueueDriver expects this schema. The queue column is what makes
--queue filtering possible:
(
id TEXT PRIMARY KEY,
job_name TEXT NOT NULL,
queue TEXT NULL,
envelope_json TEXT NOT NULL,
available_at BIGINT NOT NULL,
reserved_until BIGINT NULL,
reserved_token TEXT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL
);
ON jobs(available_at);
ON jobs(queue);
queue is nullable, and an unrouted job stores NULL rather than 'default'.
That is deliberate: a row written by an older binary is indistinguishable from
an unrouted row written by a new one, so a mixed-version fleet drains the same
work during a rolling upgrade.
Adding the column to an existing table is required, not just for
filtering: push names the queue column in its INSERT whether or not the
job is routed, so a 0.7.0+ binary fails every push against a table that lacks
it. Run the migration first, then roll binaries - older binaries list their
columns explicitly and ignore the new one, so that order is safe:
jobs ADD COLUMN queue TEXT NULL;
ON jobs(queue);
Backoff schedules
| Variant | Behavior |
|---|---|
Fixed { secs } |
constant per-attempt delay |
Exponential { base_secs, cap_secs, jitter_ratio } |
min(base * 2^(attempts-1), cap) × random in [1±jitter] |
Sequence { secs } |
one entry per attempt; the last entry repeats once exhausted |
The default is Exponential { base_secs: 2, cap_secs: 300, jitter_ratio: 0.25 } -
2 seconds to 5 minutes with ±25% jitter.
Job middleware
Six middleware ship in-tree, all mirroring Illuminate\Queue\Middleware\*:
| Middleware | Behavior |
|---|---|
WithoutOverlapping |
hold a Cache::lock for the duration; release-with-delay on contention |
RateLimited |
gate on RateLimiter budget; release until the window resets |
ThrottlesExceptions |
rate-limit on consecutive failures, not requests |
Skip::when(cond) / Skip::unless(cond) |
drop the job when the condition is met |
FailOnException |
promote matching errors to permanent failures (no retry) |
SkipIfBatchCancelled |
drop the job if its owning batch was cancelled |
Wire them on the Job impl:
use Arc;
use Duration;
use ;
WithoutOverlapping and RateLimited need the cache subsystem booted
(Cache::init or App::bind::<dyn CacheStore>(...) at startup).
A lock that will not release does not fail the job
If WithoutOverlapping cannot release its lock after the handler has
run - the cache backend blipped, the connection dropped - it logs at
warn and returns the handler's own outcome anyway. The lock then
lapses at expire_after.
That is deliberate. By the time the release runs, the handler has already committed its side effects: rows written, mail sent, charges made. Reporting the release failure as a job failure would make the worker retry and do all of it a second time, which is a worse outcome than a lock key held for its TTL. A handler that genuinely failed still reports its failure - suppressing the release error does not suppress the handler's.
The release-without-burning-attempt contract
Middleware returns a JobOutcome rather than Result<()>. Four variants:
JobOutcome::Completed- handler ran, ack.JobOutcome::Released { delay }- re-enqueue afterdelaywithout incrementingattempts. Used byWithoutOverlapping,RateLimited. The worker hands the whole operation toQueueDriver::release, and every in-tree driver requeues its own stored copy in place, so the message is never simultaneously reserved and visible, and never neither. The attempt count is preserved with no arithmetic in the worker for a driver to disagree with - the stored copy was never bumped for this run.JobOutcome::Failed { reason }- dead-letter now, persist to the failed-jobs store, do not retry.JobOutcome::Deleted- drop the reservation without dead-letter. Used bySkip. If the job belonged to a batch, the batch'spending_jobsdecrements anyway so callbacks can fire.
This contract is what makes "throttled because the bucket was full" feel different from "failed because the handler errored" in retry accounting, metrics, and lifecycle events.
What counts as an attempt
Two ways a job leaves a worker without finishing, and both consume an attempt:
- The handler failed - returned
Err, or panicked into the framework's boundary. The worker nacks; the driver requeues withattempts + 1. - The worker died - OOM kill,
abort(), a segfault,docker kill, or the SIGKILL a supervisor sends when a stop times out. Nothing settles anything; the reservation simply lapses. Whichever worker reclaims the job charges the attempt at that point.
The second case used to be free, and that was a hole rather than a
kindness: a job that reliably kills its worker could never exhaust
max_tries and so could never be dead-lettered. It would kill each worker
that claimed it, come back byte-identical, and kill the next one, for as
long as anything kept restarting workers.
All three in-tree drivers charge it, because swapping QUEUE_DRIVER must
not change whether a poison job can be stopped. database detects a
lapsed reserved_until; memory charges it when the reaper moves the
reservation back to visible; redis reads the entry's delivery count from
XPENDING, since a Redis stream entry is immutable and its own counter is
the only record.
JobOutcome::Released is the deliberate exception - see the contract
above. A job throttled by RateLimited never ran, so it owes nothing.
On Redis, reclaim has two clocks. --visibility-timeout sets how long
an entry must sit unacked before it qualifies for reclaim; a second
interval governs how often a consumer looks. The driver ties the second to
the first, so a lost job comes back within roughly twice the configured
timeout rather than the timeout plus a fixed 30 seconds.
The budget is checked before the handler runs, not only when settling. Every other dead-letter decision happens after a handler returns, which assumes the handler returns. A job that kills its worker cannot reach that check, so the worker also refuses to dispatch a job whose attempts are already spent - it dead-letters it instead, before it takes another worker down. Without this, counting the attempt would only make a number climb while the job kept cycling.
What this means for you. attempts counts deliveries to a worker,
not handler failures. A worker lost for reasons unrelated to the job - a
host reboot, an OOM caused by a noisy neighbour - burns an attempt from
that job's budget too. Laravel behaves the same way. Size max_tries with
that in mind, and prefer idempotent handlers: at-least-once delivery was
always the contract, and this makes the redelivery path count honestly
rather than silently.
Lifecycle events
Workers emit Laravel-shape lifecycle events through the
Event facade. Listeners get the envelope's identity (id,
job_name, attempts, max_tries, connection), not the typed job
instance - the worker is type-erased over JSON payloads. Errors travel
as a String since FrameworkError doesn't derive Clone.
| Event | Fires when |
|---|---|
JobQueueing |
before the envelope hits the driver |
JobQueued |
after the driver accepts |
JobProcessing |
worker popped, about to dispatch |
JobProcessed |
handler returned Ok |
JobAttempted |
every terminal settlement (success, fail, timeout) |
JobExceptionOccurred |
handler returned Err, will retry |
JobReleasedAfterException |
retry-after-error re-enqueue happened |
JobReleased |
middleware-driven release (no failure) |
JobFailed |
dead-lettered |
JobTimedOut |
per-attempt timeout exceeded |
Looping |
every loop iteration (before the pop) |
WorkerStarting / WorkerStopping |
once per worker lifetime |
WorkerInterrupted |
Queue::restart() signal observed |
Subscribe with the normal Event::listen API. Events are best-effort -
Event::dispatch with no listeners is a no-op Ok(()), so workers in
deployments without Event::init() pay nothing.
Failed-jobs storage
Dead-lettered jobs land in the configured FailedJobStore:
use Arc;
use ;
set_failed_store;
// In admin tooling:
let store = failed_store.unwrap;
for record in store.all.await?
store.forget.await?;
store.flush.await?;
Three backends:
MemoryFailedJobStore- in-processVec, lost on restart.DatabaseFailedJobStore- persists to afailed_jobstable via SeaORM.NullFailedJobStore- discards every record. Mirrors Laravel'sNullFailedJobProvider.
When the store rejects a record
If the configured store returns an error, the worker logs at error and
leaves the reservation intact rather than acking. The job returns on
visibility expiry and is retried - it is not silently dropped.
That is deliberate. The alternative, acking anyway, discards a job that already exhausted its attempts and failed to be recorded anywhere, which is unrecoverable. A job that keeps coming back is recoverable: fix the store and the next delivery lands.
The practical case is a DatabaseFailedJobStore pointed at an unmigrated
failed_jobs table. Until you migrate, dead-lettering jobs cycle at one
redelivery per visibility timeout, each logging the store's error. If you
genuinely want failures discarded, configure NullFailedJobStore - that
succeeds, so the job acks and is gone.
Retrying
use Uuid;
// Single record - false if the id wasn't in the store.
retry_failed.await?;
// Bulk - optional cutoff (only retry records older than `before`).
let count = retry_all_failed.await?;
retry_failed loads the envelope, resets attempts, available_at, and
the idempotency_key, pushes through the configured driver, then deletes
the failed-job record. Mirrors php artisan queue:retry <id> plus
queue:flush semantics (each retried envelope is pushed AND removed
from the store).
failed_jobs schema
The DatabaseFailedJobStore expects this table (managed by your
migrations):
(
id TEXT PRIMARY KEY,
connection TEXT NOT NULL,
queue TEXT NOT NULL,
job_name TEXT NOT NULL,
envelope_json TEXT NOT NULL,
exception TEXT NOT NULL,
failed_at BIGINT NOT NULL
);
ON failed_jobs(failed_at);
The table argument to DatabaseFailedJobStore::new is validated as a
SQL identifier at construction.
Queued batches
Dispatch a group of jobs with progress tracking and completion callbacks:
use Arc;
use ;
set_batch_repository;
// Register named callbacks at boot.
register_callback;
register_callback;
let id = batch
.name
.add
.add
.add
.then
.catch
.finally
.dispatch
.await?;
// Inspect progress later:
let repo = batch_repository.unwrap;
let snap = repo.find.await?.unwrap;
println!;
Each worker settles its job against the batch, and when pending_jobs
hits zero the worker fires the registered then/catch/finally
callbacks. By default the first failure cancels the batch;
.allow_failures() keeps remaining jobs going.
Durable batches
MemoryBatchRepository is lost on restart, which strands every in-flight
batch: its counters are gone, pending_jobs can never reach zero again,
and the callbacks never fire. Use DatabaseBatchRepository in production:
use Arc;
use ;
set_batch_repository;
Two tables, which the framework does not create - add them to your
migrations, the same way jobs and failed_jobs work:
(
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
total_jobs INTEGER NOT NULL,
options_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
cancelled_at INTEGER NULL,
finished_at INTEGER NULL
);
(
batch_id TEXT NOT NULL,
job_id TEXT NOT NULL,
failed INTEGER NOT NULL,
settled_at INTEGER NOT NULL,
PRIMARY KEY (batch_id, job_id)
);
DatabaseBatchRepository::with_tables(db, batches, settlements) names them
yourself; both names are validated as SQL identifiers at construction.
Note what pending_jobs and failed_jobs are not: columns. They are
derived from the settlement rows on every read -
pending_jobs = max(0, total_jobs - COUNT(settlements))
failed_jobs = COUNT(settlements WHERE failed)
because queues are at-least-once, so the same job settles more than once
whenever a redelivery happens, an ack is duplicated, or a worker dies
between doing the work and recording it. A counter decremented per
settlement drifts on every one of those, and the drift is not cosmetic:
pending_jobs gates the callbacks, so an early zero fires then while
other jobs in the batch are still running. With the counts derived and the
primary key on (batch_id, job_id), a repeat settlement inserts nothing and
there is no counter to get wrong - across processes, not just within one.
When a dispatch fails halfway
If a driver.push fails partway through dispatch(), the jobs that
already reached the queue are real and already stamped with the batch id.
So the batch is settled rather than removed: every envelope that was not
pushed is recorded as a failed job, and the batch is cancelled.
total_jobs still counts what you asked for, failed_job_ids names
exactly the jobs that never made it, the ones already queued settle
normally, and SkipIfBatchCancelled drops the rest - so pending_jobs
still reaches zero and your catch/finally callbacks still run. If
nothing was pushed at all, dispatch fires them itself, because no worker
is left to. You get the original push error back either way.
Batch options
| Option | Builder method | Effect |
|---|---|---|
| Allow failures | .allow_failures() |
continue scheduling after a job fails |
| Then callback | .then(name) |
runs on every-job-success |
| Catch callback | .catch(name) |
runs on first failure |
| Finally callback | .finally(name) |
runs after batch settles either way |
| Skip cancelled | SkipIfBatchCancelled middleware on the job |
drop remaining jobs when batch is cancelled |
BatchCallback impl
use async_trait;
use ;
use FrameworkError;
;
Register at boot with batch::register_callback(Arc::new(SendSummary)).
Callbacks are keyed by name() - the batch's options store callback
names, so a process restart picks up registered callbacks by lookup
instead of trying to deserialize a closure (Rust closures don't
serialize).
Queued chains
Sequential workflows where each link runs only after the previous one's handler acks:
chain
.add?
.add?
.add?
.dispatch
.await?;
The first envelope is pushed immediately; the rest travel on its
chain_remaining payload field. On every successful settlement the
worker pops the next entry and dispatches it. A failure breaks the
chain - subsequent links are never enqueued.
Terminal settlement
Finishing a chained job means two things: enqueue the successor, and release the job just finished. As two separate operations there is no safe order. Ack first, and a crash in the gap loses the rest of the chain permanently - nothing is left in the queue to retry from. Push first, and the same crash redelivers the finished job, so its handler runs again and the successor is enqueued twice.
So the worker hands both to the driver at once, via
QueueDriver::settle(token, follow_ups):
| Outcome | Meaning |
|---|---|
Settled::Atomically |
successor enqueued and reservation dropped in one transaction |
Settled::Stale |
the reservation was reclaimed by another consumer; nothing was enqueued or dropped |
Settled::Unsupported |
this driver cannot settle transactionally |
DatabaseQueueDriver implements it: both effects are one transaction, and
the reservation-keyed DELETE doubles as a fence. If your visibility
timeout expired while the handler was running and another worker picked the
job up, the delete matches nothing, the transaction rolls back, and you get
Stale - having enqueued nothing. Two-step settlement cannot express that
at all: your push succeeds, the new owner's push succeeds, and the chain
forks.
Redis and the in-memory driver answer Unsupported and keep the
push-before-ack ordering, which trades permanent loss for an at-least-once
duplicate. That is the framework's documented contract, and it is why
chained envelope ids are derived from their predecessor rather than random -
a redelivered step re-pushes the id it pushed before, so the duplicate is
recognisable as the same logical step.
If you write a driver whose follow-up write and acknowledgement share a
transaction domain, implement settle. Its default returns Unsupported,
so drivers written before this existed keep working unchanged.
Introspection
size.await?; // total
pending_size.await?; // available_at <= now, not reserved
delayed_size.await?; // available_at > now
reserved_size.await?; // currently popped, not yet acked
clear.await?; // drop every envelope, returns the count
driver_name?; // configured driver name for logs / admin
The QueueDriver trait declares defaults for size / pending_size /
reserved_size / delayed_size / clear; MemoryQueueDriver and
DatabaseQueueDriver implement them natively. RedisQueueDriver
returns an "unsupported" error for size / clear - use the admin
redis-cli for those.
Worker restart signal
php artisan queue:restart translates to:
restart.await?;
The signal lives in Cache as a millisecond timestamp. Workers poll
once per loop and exit cleanly when the timestamp is newer than their
start time. Pair with a supervisor (systemd, Kubernetes, the
supervisor module) so a fresh worker picks up where the previous one
stopped.
Graceful shutdown
The worker's CancellationToken fires at the next pop boundary, never
mid-dispatch. A handler that's already been popped runs to completion
(bounded by its own Job::timeout() if set) before the worker exits.
That means in-flight side effects don't get torn mid-stride, but a
SIGTERM can take up to the per-job timeout to drain. Set
WorkerConfig::max_jobs for a periodic-restart strategy on long-lived
workers; the worker exits cleanly after that many settlements regardless
of outcome.
Settlement metrics
The worker emits a queue.settlement.failures counter via Metrics on every ack/nack failure. Attributes: operation
("ack" | "nack"), driver (the configured driver's name), job
(the job_name), outcome ("success", "dead_letter", "retry",
"deleted", "timeout_dead_letter", "timeout_retry", "released").
A non-zero rate here means at-least-once delivery may re-deliver a successful side effect or lose attempt accounting - alert on it explicitly.
Typed errors
MaxAttemptsExceeded, TimeoutExceeded, and ManuallyFailed mirror
Laravel's MaxAttemptsExceededException / TimeoutExceededException /
ManuallyFailedException. The worker attaches the relevant cause to
the dead-letter JobFailed event so listeners can pattern-match instead
of substring-searching the error message.
Connection naming
Workers tag every lifecycle event with a connection name. By default
this is the driver's name() (e.g. "memory", "redis", "database").
Apps that run multiple connections at once can override:
set_connection_name;
Testing
Queue::fake() semantics live in queue::testing:
let _guard = install_fake;
my_code_that_dispatches_jobs.await;
;
// For delayed dispatches, pin the scheduled timestamp:
;
The fake guard serialises parallel tests via a process-wide mutex; it
captures (payload, available_at) per push and clears on Drop. In
fake mode, push_unique always records the push as fresh - dedupe is
irrelevant when no driver is wired.
Idempotency is the worker's contract with you
Redis-backed queue drivers can't make nack atomic - XADD and XACK
are separate commands. A crash between them re-delivers the message via
XAUTOCLAIM. In-memory and database drivers are exactly-once-per-attempt,
but the worker loop doesn't distinguish drivers, so every job handler
in a production deployment must be idempotent.
For typical command-style jobs, wrap the handler body in
Idempotency::once or
Idempotency::commit_on_success keyed by a stable
per-operation key (entity id, caller-supplied request id, etc.). When a
retry must return the original outcome rather than skip re-execution,
use Idempotency::remember, which records the success value and
replays it on later deliveries.
Next
- Bus - synchronous dispatcher with typed results
- Events - pub/sub fan-out
- Idempotency - the contract handlers honour for at-least-once delivery
- Cache - backs
push_unique,WithoutOverlapping,RateLimited - Mocking - every fake guard, including
Queue::fake
