Background jobs

For asynchronous work (reminders, exports, cleanup), a module runs its own worker. PostgreSQL provides safe concurrent locking via FOR UPDATE SKIP LOCKED.

The worker #

// services/reminder.rs — chaque module gère son propre travail de fond (polling)
pub async fn run_worker(state: Arc<AppState>) {
    loop {
        tokio::time::sleep(Duration::from_secs(60)).await;
        if let Err(e) = process_due(&state).await {
            tracing::error!(error = %e, "worker memo");
        }
    }
}

async fn process_due(state: &AppState) -> Result<()> {
    let due: Vec<(Uuid,)> = sqlx::query_as(
        "SELECT id FROM memo.jobs WHERE status='pending' AND run_after <= NOW()
         ORDER BY run_after FOR UPDATE SKIP LOCKED LIMIT 50"
    ).fetch_all(&state.db).await?;
    for (id,) in due {
        // … traiter, puis :
        sqlx::query("UPDATE memo.jobs SET status='done', done_at=NOW() WHERE id=$1")
            .bind(id).execute(&state.db).await?;
    }
    Ok(())
}

Starting it at boot #

// dans main.rs, avant axum::serve :
tokio::spawn(reminder::run_worker(Arc::new(state.clone())));

The worker — in other languages #

In Rust it is a tokio task; elsewhere, a looping (systemd) daemon. The SKIP LOCKED query stays the same:

// Rust — tâche tokio
tokio::spawn(async move {
    loop {
        tokio::time::sleep(Duration::from_secs(60)).await;
        let due = sqlx::query("SELECT id FROM memo.jobs
            WHERE status='pending' AND run_after <= NOW()
            FOR UPDATE SKIP LOCKED LIMIT 50").fetch_all(&db).await;
        // … traiter, puis UPDATE … SET status='done'
    }
});
<?php
// worker.php — démon lancé par systemd : php worker.php
while (true) {
    $rows = $pdo->query("SELECT id FROM memo.jobs
        WHERE status='pending' AND run_after <= NOW()
        FOR UPDATE SKIP LOCKED LIMIT 50")->fetchAll();
    foreach ($rows as $r) { /* traiter, puis marquer 'done' */ }
    sleep(60);
}
# Python — boucle de worker
import time
while True:
    rows = db.execute("""SELECT id FROM memo.jobs
        WHERE status='pending' AND run_after <= NOW()
        FOR UPDATE SKIP LOCKED LIMIT 50""").fetchall()
    for r in rows:
        ...  # traiter, puis status='done'
    time.sleep(60)
// Go — goroutine
go func() {
    for {
        time.Sleep(60 * time.Second)
        rows, _ := db.Query(`SELECT id FROM memo.jobs
            WHERE status='pending' AND run_after <= NOW()
            FOR UPDATE SKIP LOCKED LIMIT 50`)
        // … traiter, puis UPDATE … SET status='done'
        rows.Close()
    }
}()
# Perl — boucle de démon
while (1) {
    my $due = $dbh->selectall_arrayref(q{
        SELECT id FROM memo.jobs
        WHERE status='pending' AND run_after <= NOW()
        FOR UPDATE SKIP LOCKED LIMIT 50}, { Slice => {} });
    # ... traiter, puis status='done'
    sleep 60;
}
Note

SKIP LOCKED lets several workers pick jobs without stepping on each other. The core also has a core.jobs queue; a module can either reuse it or manage its own, as here.