Type-safe job queues for Rust on RabbitMQ.
Queues are an enum. Jobs are structs. A job can only be enqueued to, or consumed from, the queue it belongs to, and the compiler is what checks. Sending another application's job to the wrong queue is not a bug you find in production.
producer.enqueue(&BuildReport { id: 1 }).await?; // BuildReport belongs to OtherQueueserror[E0271]: type mismatch resolving `<BuildReport as Job>::Queue == AppQueues` --> src/main.rs:35:22 | 35 | producer.enqueue(&BuildReport { id: 1 }).await.unwrap(); | ------- ^^^^^^^^^^^^^^^^^^^^^^ type mismatch resolving `<BuildReport as Job>::Queue == AppQueues` | | | required by a bound introduced by this call | note: expected this to be `AppQueues` 25 | #[job(queue = OtherQueues::Reports)] | ^^^^^^^^^^^ note: required by a bound in `queuey::Producer::<Q, B>::enqueue` | pub async fn enqueue<J: Job<Queue = Q>>(&self, job: &J) -> Result<uuid::Uuid> { | ^^^^^^^^^ required by this bound in `Producer::<Q, B>::enqueue`
A producer, a worker and a handler
Two derives and a builder. The same program runs against a broker or entirely in memory, which is what makes the jobs testable.
use queuey::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Queues)]
#[queues(prefix = "myapp")]
enum AppQueues {
#[queue(prefetch = 10)]
Emails,
#[queue(retry(max_attempts = 3, backoff = "exponential", base = "1s", max = "2m"))]
Images,
}
#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = AppQueues::Emails, retry(max_attempts = 5))]
struct SendEmail { to: String, body: String }
struct EmailHandler;
#[async_trait]
impl JobHandler for EmailHandler {
type Job = SendEmail;
async fn handle(&self, job: SendEmail, ctx: JobContext) -> Result<(), JobError> {
tracing::info!(to = %job.to, attempt = ctx.attempt, "sending");
Ok(())
}
}
#[tokio::main]
async fn main() -> queuey::Result<()> {
let backend = Arc::new(RabbitMqBackend::connect("amqp://guest:guest@localhost:5672/%2f").await?);
let producer = Producer::<AppQueues, _>::new(backend.clone()).await?;
producer.enqueue(&SendEmail { to: "a@b.c".into(), body: "hi".into() }).await?;
let worker = Worker::<AppQueues, _>::builder(backend)
.handler(EmailHandler)
.close_backend_on_shutdown(true)
.build()
.await?;
worker.run().await?;
Ok(())
}use queuey::prelude::*;
// Same queue set, same job, same handler as the RabbitMQ tab.
#[tokio::test(start_paused = true)]
async fn flaky_email_is_retried_then_delivered() -> queuey::Result<()> {
let backend = Arc::new(MemoryBackend::new());
let producer = Producer::<AppQueues, _>::new(backend.clone()).await?;
let worker = Worker::<AppQueues, _>::builder(backend.clone())
.handler(EmailHandler)
.build()
.await?;
let handle = worker.handle();
let running = tokio::spawn(worker.run());
producer.enqueue(&SendEmail { to: "a@b.c".into(), body: "hi".into() }).await?;
tokio::time::sleep(Duration::from_secs(10)).await; // virtual
handle.shutdown();
running.await.unwrap()?;
assert_eq!(backend.pending("myapp.emails"), 0);
assert!(backend.dead_letters("myapp.emails").is_empty());
Ok(())
}A job that cannot run yet is not a job that failed
An API answers 429 with Retry-After: 30. Nothing went wrong. The job has to wait exactly that long, and then run before the backlog that piled up meanwhile. queuey does that by giving the held job a higher broker priority than ordinary work, so it is handed out at the front of the queue rather than joining the back of it.
if response.status() == 429 {
// A handler that wants a cap enforces its own: nothing else will.
if ctx.deferrals >= 5 {
return Err(JobError::fatal_msg("still rate limited after five deferrals"));
}
let retry_after = parse_retry_after(&response).unwrap_or(Duration::from_secs(30));
return Err(JobError::deferred_msg(retry_after, "rate limited"));
}- It costs no attempt.The attempt counter does not move and the retry policy is never consulted. Nothing failed, so nothing is dead-lettered.
- It goes to the front, not the back.The held job is republished at the highest priority level its queue was declared with, while everything enqueued normally sits at priority zero. The broker therefore hands it to a consumer ahead of the backlog instead of behind it.
- The priority lasts one round.If the job then fails and is retried, the retry goes out at normal priority. Nothing is owed a permanent place at the front.
Retry policy is part of the declaration
The same grammar on a queue and on a job. Durations are string literals parsed at compile time, and zero is rejected everywhere one is accepted.
retry(
max_attempts = 3, // total attempts including the first; 1 means no retries
backoff = "exponential", // "none" | "fixed" | "exponential" (default)
delay = "1s", // fixed only, required
base = "1s", // exponential only, default "1s", must be <= max
factor = 2.0, // exponential only, default 2.0
max = "5m", // exponential only, default "5m"
jitter = true, // exponential only, default true
)A job's own policy wins over its queue's. With neither, a retryable failure is dead-lettered on the first attempt, which is the one default worth knowing before you ship.
Full jitter is on by default. Without it, an outage that fails a thousand jobs produces a thousand retries at the same instant, then again two seconds later.
How backoff is computed, and every attribute key with its range.
What else is in the box
- Retries you write once
- Exponential backoff with base, factor, cap and full jitter; a fixed delay; or none. Set on the queue, overridden on the job.
- Fatal and retryable failures
- A handler says which kind of failure it hit. Retryable goes through the policy, fatal goes straight to the dead-letter queue.
- Dead letters you can read
- Every undeliverable job lands on q.dead with the reason, the original queue and the attempt count in its headers.
- Graceful shutdown
- Stop consuming, finish what was already pulled, await what is in flight. Nothing is pulled and then discarded.
- An in-memory backend
- The whole stack with no broker. It honours delays through tokio::time, so a five-minute backoff costs a test no wall time.
- One dependency
- The derives resolve their own paths through the facade, so there is no crate = "..." attribute and no direct dependency on the core.
Next: the same queues from other languages
A queue is rarely one service's private business. We are working on queuey libraries for other languages that speak this exact wire format, so a job enqueued by a Rust producer can be handled by a worker that is not written in Rust, and the other way round.
- The wire format is already the contract.An envelope is plain JSON in the message body, and the topology is plain AMQP queues with no custom exchanges. A library in another language has to agree on the envelope fields and the queue names, and nothing else. The format is documented field by field.
- Retries, deferral and dead letters come along.They are broker behaviour, not Rust behaviour: hold queues, TTLs and priorities. A worker in any language gets the same semantics by declaring the same topology. How that topology is built.
- Type safety stays a per-language question.The Rust derives are what make a mix-up a compile error here. Each library will use whatever its own language offers for the same job, rather than pretending every language can check what rustc checks.
None of this has shipped. There is no release date, no published crate or package for another language, and no beta to sign up for. It is on the roadmap because the design already allows it, not because it is nearly done.
If you want a particular language, or you are already reading these queues from one, say so on the issue tracker. That is what will decide the order.