Skip to content
rusthacks.dev
Databases

Job Queues With SKIP LOCKED in Postgres From Rust Explained

Build a reliable job queue in a single table: claim rows with FOR UPDATE SKIP LOCKED, mark them done in the same transaction, retry failures with backoff, and know where this stops scaling.

4 min read
Summarize in AI
Flat illustration for the post “Job Queues With SKIP LOCKED in Postgres From Rust Explained”, composed around architecture.

Before reaching for a message broker, consider that you already run a transactional database. For many services, a table of jobs and one SQL clause give a queue with exactly-once claiming, durable storage and visibility with ordinary SQL. The clause is FOR UPDATE SKIP LOCKED. This post builds a worker with it. Versions: Rust 1.90 with edition 2024, SQLx 0.8, PostgreSQL 17, Tokio 1.47.

The table

SQL
CREATE TABLE jobs (
    id         BIGSERIAL PRIMARY KEY,
    kind       TEXT        NOT NULL,
    payload    JSONB       NOT NULL,
    status     TEXT        NOT NULL DEFAULT 'queued',  -- queued | done | dead
    attempts   INT         NOT NULL DEFAULT 0,
    max_tries  INT         NOT NULL DEFAULT 5,
    run_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_error TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX jobs_ready_idx ON jobs (run_at) WHERE status = 'queued';

The partial index covers only queued rows, so it stays small as completed jobs accumulate. Delete or archive finished jobs regularly (partitioning by day makes that cheap).

Claiming a job

SQL
SELECT id, kind, payload, attempts
  FROM jobs
 WHERE status = 'queued' AND run_at <= now()
 ORDER BY run_at
 LIMIT 1
 FOR UPDATE SKIP LOCKED;

FOR UPDATE locks the selected row for the transaction. SKIP LOCKED tells other workers running the same query to ignore locked rows and take the next one, instead of waiting. Ten workers polling at once each get a different job, with no coordination code.

The worker loop

rust
use sqlx::{PgPool, Row};
use std::time::Duration;

pub async fn work_one(pool: &PgPool) -> Result<bool, sqlx::Error> {
    let mut tx = pool.begin().await?;

    let Some(row) = sqlx::query(
        "SELECT id, kind, payload, attempts, max_tries FROM jobs
          WHERE status = 'queued' AND run_at <= now()
          ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED",
    )
    .fetch_optional(&mut *tx)
    .await?
    else {
        return Ok(false); // queue empty
    };

    let id: i64 = row.get("id");
    let kind: String = row.get("kind");
    let payload: serde_json::Value = row.get("payload");
    let attempts: i32 = row.get("attempts");
    let max_tries: i32 = row.get("max_tries");

    match handle(&kind, &payload, &mut tx).await {
        Ok(()) => {
            sqlx::query("UPDATE jobs SET status = 'done', attempts = attempts + 1 WHERE id = $1")
                .bind(id)
                .execute(&mut *tx)
                .await?;
        }
        Err(e) => {
            let next = attempts + 1;
            if next >= max_tries {
                sqlx::query("UPDATE jobs SET status = 'dead', attempts = $2, last_error = $3 WHERE id = $1")
                    .bind(id).bind(next).bind(e.to_string()).execute(&mut *tx).await?;
            } else {
                // exponential backoff: 2^attempt * 10 seconds
                sqlx::query(
                    "UPDATE jobs SET attempts = $2, last_error = $3,
                            run_at = now() + make_interval(secs => $4) WHERE id = $1",
                )
                .bind(id).bind(next).bind(e.to_string()).bind(10.0 * 2f64.powi(next))
                .execute(&mut *tx)
                .await?;
            }
        }
    }
    tx.commit().await?;
    Ok(true)
}

async fn handle(
    _kind: &str,
    _payload: &serde_json::Value,
    _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    Ok(())
}

pub async fn run(pool: PgPool) {
    loop {
        match work_one(&pool).await {
            Ok(true) => continue,                              // more work: no sleep
            Ok(false) => tokio::time::sleep(Duration::from_secs(1)).await,
            Err(e) => {
                tracing::error!(error = %e, "worker error");
                tokio::time::sleep(Duration::from_secs(5)).await;
            }
        }
    }
}

If the process crashes during handle, the transaction rolls back, the row's lock is released and another worker claims the job. Database effects performed through tx in the handler commit atomically with the status change, which gives you exactly-once for database work. A job that touches the outside world (an email, an HTTP call) can run twice if the crash comes after the call and before the commit, so make those calls idempotent (idempotency keys).

Waking workers promptly

Polling every second adds up to a second of latency. Add NOTIFY jobs_ready after inserting a job and have workers LISTEN on a dedicated connection, keeping the poll as a safety net (LISTEN and NOTIFY).

Limits

  • Open transactions: a job running for minutes pins a transaction. Use the lease pattern for long jobs.
  • Table churn: every attempt updates a row; dead tuples need vacuum, and a very hot queue can bloat. Tune autovacuum for the table.
  • Throughput: thousands per second on decent hardware is realistic; beyond that, contention on the index grows.
  • Fan-out and ordering: no consumer groups, replay or strict ordering across workers.

If you outgrow it, move to a purpose-built queue; the migration is easy when the handler is already a function of a payload (background job queues compared).

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.