Hintergrund-Jobs
Manche Arbeit sollte nicht während eines Requests passieren — eine Willkommens-E-Mail senden, einen Upload skalieren, eine Drittanbieter-API synchronisieren. Es inline zu tun lässt den Benutzer warten und koppelt die Response an einen unzuverlässigen externen Aufruf. Ein Hintergrund-Job verschiebt diese Arbeit auf eine Queue: der Handler kehrt sofort zurück, und ein Pool von Workern führt den Job Augenblicke später aus, mit automatischen Retries und einem Dead-Letter-Pfad für Fehlschläge. Das ist Django-Q / Celery / Laravel-Queues, in Rust.
Ein Begriff hier neu für dich? Queue, Worker, Retry/Backoff, Dead-Letter — siehe das Glossar.
Quelle:
rustango::jobs(Job,JobQueue,InMemoryJobQueue,JobError,JobDeadLetter) undrustango::jobs::DatabaseJobQueue(die tabellengestützte Queue, Alias vonpg::PgJobQueue) — hinter derjobs-Feature (standardmäßig aktiv).Ausführbare Version: die In-Memory-, Retry- und Dead-Letter-Snippets sind aus
jobs_doc.rskopiert (cargo test -p rustango --test jobs_doc); die persistente Queue wird per Dogfooding auf SQLite durchjobs_sqlite_live.rserprobt (cargo test -p rustango --features sqlite,jobs-postgres --test jobs_sqlite_live).
Inhaltsverzeichnis
- Schritt 1 — Einen Job definieren
- Schritt 2 — Eine Queue starten
- Schritt 3 — Aus einem Handler dispatchen
- Schritt 4 — In deine App verdrahten — das vollständige Beispiel
- Die Worker laufen lassen (CLI + Produktion)
- Retries und Backoff
- Der Dead-Letter-Handler
- Die persistente Queue (Produktion)
- Referenz
- Siehe auch
Schritt 1 — Einen Job definieren
Ein Job ist ein serialisierbares Struct, das Job implementiert. Die Payload
ist das, was in die Queue gestellt wird (nach JSON serialisiert); run() ist
die Arbeit. NAME routet eine in die Queue gestellte Payload zurück zu ihrem
Handler, er muss also eindeutig sein.
Erzeuge ein Gerüst mit der CLI — cargo run -- make:job WelcomeEmail — oder
schreibe es von Hand:
use rustango::jobs::{Job, JobError};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct WelcomeEmail {
user_id: i64,
}
#[async_trait::async_trait] // add `async-trait` to your Cargo.toml
impl Job for WelcomeEmail {
const NAME: &'static str = "welcome_email";
async fn run(&self) -> Result<(), JobError> {
// ... look up the user and send the email
Ok(())
}
}
run() gibt zurück:
Ok(())— erledigt.Err(JobError::Retryable(msg))— transient; der Worker wiederholt mit Backoff.Err(JobError::Fatal(msg))— permanent; überspringe Retries, Dead-Letter es sofort.
Überschreibe const MAX_ATTEMPTS: u32 = 3; auf dem Impl, um die
Retry-Obergrenze zu ändern (Standard 5).
Schritt 2 — Eine Queue starten
Für einen einzelnen Prozess (und für Dev und Tests) führt InMemoryJobQueue
Jobs auf tokio-Worker-Tasks aus — ohne Datenbank. Registriere jeden Job-Typ,
dann start() die Worker (Jobs werden nicht aufgenommen, bis du es tust):
use rustango::jobs::{InMemoryJobQueue, JobQueue};
use std::sync::Arc;
let queue = Arc::new(InMemoryJobQueue::with_workers(4)); // 4 worker tasks
queue.register::<WelcomeEmail>().await; // before start/dispatch
queue.start().await;
// ... app runs ...
queue.shutdown().await; // on shutdown: drain in-flight jobs, then stop
Behalte den Arc<InMemoryJobQueue> in deinem App-State, damit Handler ihn
erreichen können.
In-Memory bedeutet In-Memory. In der Queue stehende oder in Bearbeitung befindliche Jobs gehen beim Neustart verloren. Für alles, dessen Verlust du dir nicht leisten kannst, verwende die persistente Queue.
Schritt 3 — Aus einem Handler dispatchen
Sobald die Queue läuft, ist das Dispatchen ein einziger Aufruf — er stellt in die Queue und kehrt sofort zurück, sodass der Request nicht auf die Arbeit wartet:
queue.dispatch(&WelcomeEmail { user_id: 42 }).await?;
Ein Worker nimmt ihn auf und führt run() Augenblicke später aus. Durchgängig
verifiziert: alle drei dispatchten Jobs laufen auf den Workern.
for user_id in 1..=3 {
queue.dispatch(&WelcomeEmail { user_id }).await.unwrap();
}
// → all three WelcomeEmail::run() calls execute on the worker pool
Schritt 4 — In deine App verdrahten
Die Schritte 1–3 zusammengesetzt: baue die Queue und registriere jeden Job
einmalig beim Boot, starte die Worker, übergib die Queue an deine Routen,
damit Handler sie erreichen können, und drain dann beim Shutdown. Die Queue lebt
in deiner main.rs:
// src/main.rs
use std::sync::Arc;
use rustango::jobs::{InMemoryJobQueue, JobQueue};
use rustango::manage::Cli;
#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Build the queue + register every job type BEFORE starting workers.
let queue = Arc::new(InMemoryJobQueue::with_workers(4));
queue.register::<WelcomeEmail>().await;
queue
.on_dead_letter(|dl| async move {
tracing::error!(job = dl.name, attempts = dl.attempts, error = %dl.error,
"job dead-lettered");
})
.await;
// 2. Start the workers — tokio tasks that run inside THIS process.
queue.start().await;
// 3. Share the queue with handlers (axum extension/state).
let app = urls::api().layer(axum::Extension(queue.clone()));
// 4. Boot the server. Cli::run() blocks until Ctrl-C / SIGTERM.
Cli::new().api(app).with_health().run().await?;
// 5. On shutdown: drain in-flight jobs, then stop.
queue.shutdown().await;
Ok(())
}
Ein Handler dispatcht, indem er die Queue aus dem Request wieder ausliest:
use axum::Extension;
use std::sync::Arc;
use rustango::jobs::{InMemoryJobQueue, JobQueue};
async fn signup(Extension(queue): Extension<Arc<InMemoryJobQueue>>) -> StatusCode {
// ... create the user ...
queue.dispatch(&WelcomeEmail { user_id: 42 }).await.ok(); // returns instantly
StatusCode::CREATED
}
Speichere den konkreten Queue-Typ. Der
JobQueue-Trait ist nicht object-safe (seineregister/dispatchsind generisch), du kannst also nichtArc<dyn JobQueue>im State halten — behalte den konkretenArc<InMemoryJobQueue>(oderArc<DatabaseJobQueue>).
Die Worker laufen lassen (CLI + Produktion)
start() spawnt die Worker als tokio-Tasks innerhalb des aktuellen
Prozesses. Wenn du also deine App mit der CLI startest — cargo run (das den
Server ausführt) — laufen die Worker direkt daneben. Für die meisten Apps ist
das alles, was du brauchst: ein Prozess bedient Requests und leert die
Queue; es gibt keinen separaten Worker-Befehl.
cargo run # starts the server AND the in-process workers
cargo run -- make:job WelcomeEmail # scaffold a new job type
Ein dedizierter Worker-Prozess (Produktion)
Im großen Maßstab willst du die Worker oft getrennt von der Web-Ebene — damit
ein Traffic-Spike keine Jobs aushungern kann und du beide unabhängig skalierst.
Mit der persistenten Queue zieht jeder Prozess
aus derselben rustango_jobs-Tabelle, führe also einfach ein zweites,
serverloses Binary aus, das die Queue baut, startet und bis zu einem Signal
blockiert:
// src/bin/worker.rs — run with `cargo run --bin worker`
use std::sync::Arc;
use rustango::jobs::{DatabaseJobQueue, JobQueue};
#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = /* connect your tri-dialect Pool */;
DatabaseJobQueue::ensure_table_pool(&pool).await?;
let queue = Arc::new(DatabaseJobQueue::with_workers_pool(pool, 8));
queue.register::<WelcomeEmail>().await; // register the SAME job types
queue.start().await;
tokio::signal::ctrl_c().await?; // block until Ctrl-C / SIGTERM
queue.shutdown().await; // drain in-flight, then exit
Ok(())
}
Deploye es als eigenen Container/Service und skaliere auf N Replicas — sie
ziehen alle sicher aus der geteilten Tabelle. Der Web-Prozess muss dann nur noch
dispatch (er muss keine Worker start()). Kopple den Worker mit einem
periodischen reclaim_stuck_jobs_pool-Sweep,
um Jobs eines abgestürzten Workers zurückzuholen.
Retries und Backoff
Ein Job, der Retryable zurückgibt, wird mit exponentiellem Backoff (1s, 2s,
4s, 8s, …) bis zu MAX_ATTEMPTS erneut in die Queue gestellt. Verwende es für
transiente Fehler — einen Timeout, eine rate-limitierte API, einen Deadlock:
#[async_trait::async_trait]
impl Job for FlakyImport {
const NAME: &'static str = "flaky_import";
async fn run(&self) -> Result<(), JobError> {
match call_external_api().await {
Ok(_) => Ok(()),
Err(e) => Err(JobError::Retryable(e.to_string())), // try again later
}
}
}
Der zugrunde liegende Test dispatcht einen Job, der einmal fehlschlägt und dann gelingt, und stellt sicher, dass er mehr als einmal lief und schließlich gelang — das Retry findet tatsächlich statt.
Der Dead-Letter-Handler
Wenn ein Job seine Retries erschöpft — oder sofort Fatal zurückgibt — wird er
an den Dead-Letter-Callback übergeben, statt zu verschwinden. Registriere
einen (vor start), um den Fehlschlag zu loggen, zu alarmieren oder zu
persistieren:
queue
.on_dead_letter(|dl| async move {
// dl: JobDeadLetter { name, payload, attempts, error }
tracing::error!(job = dl.name, attempts = dl.attempts, error = %dl.error,
"job dead-lettered");
})
.await;
Ein Fatal-Fehler überspringt Retries und landet hier beim ersten Versuch:
async fn run(&self) -> Result<(), JobError> {
Err(JobError::Fatal("unprocessable payload".into())) // → dead-letter now
}
Der Test bestätigt, dass der Callback für einen Fatal-Job genau einmal
feuert, mit intaktem name und error des Jobs.
Die persistente Queue (Produktion)
InMemoryJobQueue ist Einzelprozess und vergisst beim Neustart. Für echte
Deployments — mehrere Worker, mehrere Replicas, Jobs, die einen Absturz
überstehen müssen — verwende DatabaseJobQueue (derselbe Typ wie
PgJobQueue). Jobs leben in einer rustango_jobs-Tabelle; Worker nehmen sie
mit einem transaktionsbegrenzten UPDATE … RETURNING auf, es ist also über
Prozesse hinweg sicher. Sie ist tri-dialektisch (PostgreSQL, MySQL, SQLite).
Die Job-Definitionen aus Schritt 1 bleiben unverändert.
use rustango::jobs::DatabaseJobQueue; // = rustango::jobs::pg::PgJobQueue
use rustango::jobs::JobQueue;
use std::sync::Arc;
use std::time::Duration;
// 1. Create the jobs table once at boot (idempotent, tri-dialect).
DatabaseJobQueue::ensure_table_pool(&pool).await?;
// 2. Build the queue over your pool + N workers, and start it.
let queue = Arc::new(
DatabaseJobQueue::with_workers_pool(pool.clone(), 4)
.poll_interval(Duration::from_millis(50)),
);
queue.register::<WelcomeEmail>().await;
queue.start().await;
// 3. Dispatch exactly as before — now it's durable.
queue.dispatch(&WelcomeEmail { user_id: 42 }).await?;
Abgestürzte Worker wiederherstellen. Stirbt ein Worker mitten im Job, bleibt die Zeile gesperrt. Führe einen periodischen Sweep aus, um Sperren älter als eine Schwelle freizugeben, damit ein anderer Worker den Job wieder aufnimmt:
// e.g. from a scheduled task every few minutes:
let reclaimed = DatabaseJobQueue::reclaim_stuck_jobs_pool(&pool, Duration::from_secs(300)).await?;
Sowohl der Dispatch-and-Run-Fluss als auch reclaim_stuck_jobs_pool werden per
Dogfooding gegen SQLite in jobs_sqlite_live.rs erprobt.
Referenz
Backends
| Backend | Verwenden für | Übersteht Neustart? |
|---|---|---|
InMemoryJobQueue | Einzelprozess, Dev, Tests | nein |
DatabaseJobQueue (PgJobQueue) | Produktion; Multi-Worker / Multi-Replica | ja (rustango_jobs-Tabelle) |
JobError
| Variante | Wirkung |
|---|---|
Retryable(String) | erneut in die Queue mit exponentiellem Backoff, bis zu MAX_ATTEMPTS |
Fatal(String) | Retries überspringen → sofort Dead-Letter |
Queue(String) | interner Queue-Fehler (Serialisierung/Registrierung) |
JobQueue-Methoden: register::<T>() · dispatch(&payload) · start() ·
shutdown() · pending_count(). DatabaseJobQueue ergänzt
ensure_table_pool, with_workers_pool, poll_interval und
reclaim_stuck_jobs_pool.
