Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Distributed Task Queue

A PostgreSQL-backed distributed task queue library in Rust, providing at-least-once delivery with no additional infrastructure dependencies beyond Postgres. The at-least-once guarantee applies to successfully submitted tasks — if submit returns Ok, the task is durably persisted and will be delivered at least once.

Features

  • Priority-ordered dequeue — tasks with lower priority numbers are processed first (0 = highest, 9 = lowest)
  • Concurrent task claimingSELECT ... FOR UPDATE SKIP LOCKED ensures multiple consumers never block each other
  • Visibility-timeout-based redelivery — crashed consumers' tasks are automatically reclaimed after timeout expiry; workers can call extend_visibility to prevent reclaim of long-running tasks
  • Delayed/scheduled tasks — submit tasks with a future scheduled_at for deferred execution
  • Exponential backoff retries — configurable per-task retry policy with backoff formula base * 2^retry_count, capped at 1 hour
  • Dead letter queue — tasks exceeding max_retries are moved to a separate dead_letter_tasks table
  • LISTEN/NOTIFY — optional low-latency wake-up via Postgres notifications (INSERT and NOTIFY are atomic within a single transaction), with automatic reconnection and poll fallback
  • Batch enqueue/dequeue — amortize round-trip cost under high throughput
  • Prometheus metrics — counters for enqueued/dequeued/completed/failed/retried/dead-lettered, gauge for queue depth, histogram for processing duration. The library provides a prometheus::Registry; the caller is responsible for exposing it (e.g., via an HTTP endpoint)
  • Graceful shutdown — in-flight tasks are given a configurable grace period to complete before abort
  • Horizontal scaling — multiple producers and consumers across processes

Architecture

Producer ──enqueue──▶ PostgreSQL ◀──dequeue── Consumer(s)
                        │                        │
                   NOTIFY channel ──────▶ NotificationListener

The NotificationListener is an internal component that maintains a dedicated PostgreSQL connection (separate from the connection pool) for LISTEN/NOTIFY. It collapses incoming notifications into a single wake-up signal for the consumer's dequeue loop and reconnects automatically with exponential backoff if the connection drops. When disabled (notify_pg_url = None), the consumer falls back to poll-only mode.

Single tasks table with partial indexes keeps the dequeue path fast regardless of total row count. A background archival process deletes completed tasks beyond a configurable retention period. Aggressive autovacuum tuning handles the high UPDATE churn.

See design/distributed-task-queue.md for the full design document including decision log, schema, SQL query patterns, edge cases, and known limitations.

Quick Start

Prerequisites

  • Rust 1.70+
  • PostgreSQL 14+ (uses gen_random_uuid())
  • Docker (for local development)

Run PostgreSQL

docker compose up -d

Apply Migrations

Run the SQL files in migrations/ against your database in order (002 depends on tables created by 001; 002 is idempotent on fresh installs):

psql postgres://taskqueue:taskqueue@localhost:5432/taskqueue \
  -f migrations/001_init.sql \
  -f migrations/002_add_check_constraints.sql

Usage

use distributed_task_queue::*;
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let pool = PgPoolOptions::new()
        .max_connections(10)
        .connect("postgres://taskqueue:taskqueue@localhost:5432/taskqueue")
        .await?;

    let metrics = MetricsCollector::new();
    let store = TaskStore::new(pool.clone(), metrics.clone());

    // --- Producer ---
    let producer = Producer::new(store.clone());
    let task_id = producer
        .submit(TaskSubmission {
            queue_name: "emails".to_string(),
            payload: serde_json::json!({"to": "user@example.com", "subject": "Hello"}),
            priority: 3,
            max_retries: 5,
            visibility_timeout_secs: 60,
            // Defaults: retry_backoff_base_secs = 1, delay_secs = 0
            ..Default::default()
        })
        .await?;
    println!("Submitted task: {task_id}");

    // --- Consumer ---
    let config = ConsumerConfig {
        queue_name: "emails".to_string(),
        worker_id: "worker-1".to_string(),
        concurrency: 10,
        poll_interval_secs: 5,
        notify_pg_url: Some("postgres://taskqueue:taskqueue@localhost:5432/taskqueue".to_string()),
        // Defaults: batch_size = 10, archival_retention_secs = 3600,
        //           shutdown_grace_period_secs = 30
        ..Default::default()
    };
    let consumer = Arc::new(Consumer::new(store, config, metrics)?);

    // Trigger shutdown on Ctrl-C.
    let consumer_handle = consumer.clone();
    tokio::spawn(async move {
        tokio::signal::ctrl_c().await.ok();
        consumer_handle.shutdown(); // synchronous, signals the run loop to stop
    });

    consumer
        .run(|task| async move {
            println!("Processing task {}: {:?}", task.id, task.payload);
            // Your processing logic here.
            Ok(())
        })
        .await?;

    Ok(())
}

Tests

# Start Postgres
docker compose up -d

# Run integration + e2e tests
cargo test

# Run performance benchmarks (ignored by default)
cargo test --test perf_tests -- --ignored --nocapture

44 integration tests, 7 end-to-end tests, and 6 performance benchmarks.

How This Was Built

Design

The design document was created using the sisyphus-design skill — a structured approach to producing detailed, implementation-ready software designs with explicit decision logs, edge case analysis, and known limitations.

Implementation

The code was implemented by Claude Code (dev) and reviewed/fixed by Codex (review).

Prompt used to generate the initial implementation:

Implement the distributed task queue system specified in design/distributed-task-queue.md. That file is the complete spec — follow it exactly.

Use Docker to provision PostgreSQL for tests. Include full unit tests (against real PG, not mocks) and end-to-end tests covering concurrency with multiple consumers.

Plan first — identify what can be parallelized, then execute. Use subagents (Agent tool, model opus) to build independent modules simultaneously. Don't ask for approval on the plan, just go.

Prompt used for the review-fix loop:

Run codex review --uncommitted using a subagent (Agent tool, model sonnet). The subagent should parse the output and return only the identified issues (severity + description) — discard all intermediate/diagnostic output.

Then loop: fix all P1/High issues, re-run the review via subagent, and repeat until the review reports no P1 or High severity issues remaining.

Differences Between Design and Implementation

The implementation follows the design closely but deviates in several places — some are intentional improvements, some are pragmatic API changes, and one is a missing feature.

Category Design Implementation Why
NOTIFY transactional semantics NOTIFY fires after INSERT as best-effort; failure leaves the task persisted INSERT + NOTIFY wrapped in a single transaction; NOTIFY failure rolls back the INSERT Prevents duplicate-on-retry: if caller retries after NOTIFY error, the task would be inserted twice
NOTIFY for delayed tasks NOTIFY fires unconditionally after every enqueue NOTIFY skipped when delay_secs > 0 Waking consumers for a task they can't dequeue yet is pointless
Consumer wake-up sources Two sources: NOTIFY and poll timer Third source added: scheduled_wake timer via new TaskStore::next_pending_at method Avoids waiting a full poll interval for delayed/retried tasks becoming runnable between ticks
Consumer drain mode One dequeue per wake-up cycle If dequeue returns a full batch, loops back immediately without waiting Efficiently drains backlogs under load
Constructors async/sync TaskStore::new, Consumer::new, Consumer::shutdown are async All three are synchronous No async work needed at construction time
archive_completed signature archive_completed(retention_secs, batch_size) — no queue scoping Takes an additional queue_name parameter Scopes archival to a specific queue for isolation
NotificationListener channel subscribe() returns an mpsc::Receiver Constructor takes mpsc::Sender as parameter; no subscribe() method Consumer owns the channel and passes the sender in
extend_visibility SQL visible_after = now() + interval visible_after = GREATEST(visible_after, now()) + interval Prevents accidentally shortening an already-extended window
ack/nack SQL No RETURNING clause; returns rows_affected Uses RETURNING queue_name Obtains queue name for metrics without a separate query
Dequeue ordering Ordering guaranteed by CTE Application-side sort after RETURNING PostgreSQL doesn't preserve CTE ordering through UPDATE...RETURNING
Queue name validation [a-zA-Z0-9_]+, no length limit [a-z0-9_]+, max 54 bytes, rejects empty PG folds unquoted NOTIFY channels to lowercase — uppercase would silently break LISTEN/NOTIFY matching
Input validation Queue name validation only (in Schema section) Full validation in enqueue (priority 0-9, max_retries >= 0, visibility_timeout >= 1, etc.) and Consumer::new (concurrency >= 1, poll_interval >= 1, etc.) Guard-rail layer to catch invalid inputs before hitting the database
NotificationListener keepalive Periodic SELECT 1 every 30s to detect half-open TCP connections Not implemented Relies on sqlx::PgListener's built-in connection handling and reconnect-on-error
Task abort on shutdown Individual JoinHandle::abort calls JoinSet + shutdown().await Cleaner API; also reaps completed handles each iteration to prevent unbounded growth
Archival under load Runs every 60s Also checked opportunistically during drain mode Prevents sustained load from starving archival
sweep_to_dlq metrics Increments tasks_dead_lettered_total only Also increments tasks_failed_total Dead-lettered tasks are also failures
Histogram buckets Not specified [0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0] Explicit buckets covering sub-second to 1-minute task durations
Default impls Not specified Added for TaskSubmission, ConsumerConfig, MetricsCollector Ergonomic convenience with ..Default::default() pattern

License

MIT

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages