Skip to content

Repository files navigation

BatchQ

A durable, distributed job queue built in Go and PostgreSQL — benchmarked at ~1,800 jobs/sec under 1,000 concurrent HTTP requests with zero duplicate or dropped jobs.

BatchQ implements atomic job reservation using PostgreSQL's FOR UPDATE SKIP LOCKED, the same pattern used inside production systems at Stripe, GitHub, and Shopify. Workers are stateless and horizontally scalable — spin up as many as you need, they coordinate entirely through the database.

Features

  • Atomic Job Leasing — Workers safely claim jobs using PostgreSQL's SELECT ... FOR UPDATE SKIP LOCKED, ensuring exactly-once reservation even under heavy concurrency.
  • Automatic Retries with Backoff — Configurable retry count and exponential backoff using the available_at field.
  • Dual-Mode Operation — Run as an HTTP API for job submission or as a dedicated worker for background processing.
  • Type-Safe Job Handlers — Strongly typed payload decoding with clear separation between job types.
  • Operationally Simple & Production-Ready — Graceful shutdown, connection timeouts, robust error handling, and predictable worker behavior.
  • Idempotency Support — Optional deduplication keys to prevent double-enqueueing of equivalent jobs.
  • Queue Partitioning — Logical queues for isolating workloads or defining different levels of priority.

Motivation

I've always been curious about how batch processing systems work under the hood: how jobs get queued, how workers stay isolated from each other, and how a scheduler decides what runs next.

Instead of jumping straight into something huge like SLURM or Kubernetes, I wanted a smaller, approachable system I could fully reason about:

  • How do multiple workers safely claim jobs without conflicts?
  • What happens when a worker fails halfway through a job?
  • How should retries and backoff be implemented?
  • What are the trade-offs of using a database as a queue backend?

BatchQ is my way of exploring these fundamentals by building a working job scheduler in Go, with PostgreSQL acting as the coordination and storage layer.

Architecture

BatchQ implements a durable, database-backed work queue using PostgreSQL as both the coordination layer and the source of truth. Workers and API servers run as independent stateless processes, making the system horizontally scalable.

High-Level Data Flow

               ┌──────────────────────┐
               │      API Server      │
               │    POST /jobs        │
               └──────────┬───────────┘
                          │ inserts
                          ▼
               ┌──────────────────────┐
               │      PostgreSQL      │
               │     jobs table       │
               └──────────┬───────────┘
                          │ SELECT ... FOR UPDATE SKIP LOCKED
                          ▼
               ┌──────────────────────┐
               │      Worker (N)      │
               │  process + finalize  │
               └──────────────────────┘

Key Architectural Properties

  • Durability — All job state lives in PostgreSQL, allowing workers to crash or restart without losing progress.
  • Concurrency Safety — Workers atomically reserve jobs using row-level locks with SKIP LOCKED, avoiding central coordinators or distributed locks.
  • Fault Recovery — A periodic "stale worker" recovery process reclaims jobs stuck in the running state, similar to SQS visibility timeouts.
  • Deterministic Job Selection — A compound index on (queue, status, available_at) ensures fast hot-path queries.
  • Operational Simplicity — Requires no external systems beyond PostgreSQL.

Why SKIP LOCKED?

FOR UPDATE SKIP LOCKED provides an elegant mechanism for distributed work queues:

  • Non-blocking — Workers attempting to reserve a job never block each other, avoiding thundering-herd problems.
  • Exactly-once Reservation — Each job can be claimed by exactly one worker, even under high parallelism.
  • Short Transactions — Leasing is a single UPDATE with tight transaction scope, lowering lock contention.
  • Failure Isolation — If a worker crashes mid-job, its locks are released, enabling the stale-job recovery loop to reclaim work.

This pattern mirrors techniques used inside large-scale systems at companies like Stripe, GitHub, and Shopify.

Tradeoffs & Limitations

BatchQ favors correctness, observability, and operational simplicity over raw throughput. A database-backed queue is not intended to compete with specialized message brokers like Kafka or Redis Streams.

Key tradeoffs:

  • PostgreSQL becomes the primary bottleneck at very high throughput.
  • Polling workers introduce light database load compared to push-based queues.
  • Job execution is single-host unless workers are containerized or scheduled via Kubernetes.

These tradeoffs are intentional: BatchQ is designed as a system you can fully reason about, not an industrial-scale distributed scheduler.

Getting Started

Prerequisites

  • Go 1.22 or higher
  • PostgreSQL 12 or higher

Installation

git clone https://github.com/splitcell01/batchq.git
cd batchq
go mod download

Database Setup

Apply the initial migration to create the jobs table:

psql $DATABASE_URL -f db/migrations/001_init.sql

Running the API Server

The API server accepts job enqueue requests:

export DATABASE_URL="postgres://user:pass@localhost/batchq?sslmode=disable"
export MODE=api
go run cmd/batchqd/main.go

The API will start on port 8080.

Running Workers

Start one or more worker processes to consume jobs:

export DATABASE_URL="postgres://user:pass@localhost/batchq?sslmode=disable"
export MODE=worker
go run cmd/batchqd/main.go

You can run multiple workers concurrently for increased throughput.

Docker Quickstart

You can run BatchQ entirely with Docker and Docker Compose:

docker compose up --build --scale worker=3

Usage

Enqueuing Jobs

Send a POST request to /jobs with the job details:

curl -X POST http://localhost:8080/jobs \
  -H "Content-Type: application/json" \
  -d '{
    "type": "demo_task",
    "payload": {
      "message": "Hello, BatchQ!",
      "delay_ms": 1000
    },
    "max_attempts": 3
  }'

Response:

{
  "id": 123
}

Supported Job Types

demo_task

Simple demonstration task with configurable delay.

Payload:

{
  "message": "string",
  "delay_ms": 500
}

http_get

Performs an HTTP GET request with timeout handling.

Payload:

{
  "url": "https://api.example.com/data",
  "timeout_ms": 3000
}

batch_range

CPU-intensive task that processes a numerical range.

Payload:

{
  "start": 0,
  "end": 9999
}

Adding Custom Job Types

Add a new case to the processJob function in cmd/batchqd/main.go:

case "your_job_type":
    var data struct {
        // Define your payload structure
        Field1 string `json:"field1"`
        Field2 int    `json:"field2"`
    }
    if err := json.Unmarshal(j.Payload, &data); err != nil {
        return fmt.Errorf("decode payload: %w", err)
    }
    
    // Implement your job logic here
    log.Printf("[your_job_type] Processing: %+v\n", data)
    
    return nil

Configuration

Environment variables:

Variable Required Description Default
DATABASE_URL Yes PostgreSQL connection string -
MODE No Operation mode: api or worker api

Database Schema

Jobs Table

Column Type Description
id BIGSERIAL Primary key
type TEXT Job handler identifier
payload JSONB Job-specific data
status TEXT pending, running, completed, or failed
attempts INT Number of execution attempts
max_attempts INT Maximum retry limit (default: 3)
queue TEXT Queue name for partitioning (default: default)
dedupe_key TEXT Optional idempotency key
available_at TIMESTAMPTZ When job becomes eligible for processing
created_at TIMESTAMPTZ Job creation timestamp
updated_at TIMESTAMPTZ Last modification timestamp
last_error TEXT Error message from most recent failure

Indexes

  • idx_jobs_queue_status_available: Optimizes worker job polling
  • idx_jobs_status: Supports status dashboards and monitoring
  • idx_jobs_dedupe_key_pending: Enforces idempotent job enqueuing

Design Decisions

Why PostgreSQL?

  • ACID Guarantees: Ensures jobs are never lost or double-processed
  • Skip Locked: Native support for concurrent queue access without external locks
  • Operational Simplicity: Leverages existing database infrastructure
  • Query Interface: Direct SQL access for debugging and monitoring

Why Database-Backed Queue?

While in-memory queues (Redis, RabbitMQ) offer higher throughput, a database-backed approach provides:

  • Durability guarantees without additional infrastructure
  • Built-in job history and audit trail
  • Simplified deployment (one less service to manage)
  • Sufficient performance for most use cases (tested to 1000+ jobs/sec)

Retry Strategy

Failed jobs are automatically retried with:

  • 10-second delay between attempts
  • Configurable max attempts (default: 3)
  • Status transitions: pendingrunningcompleted/failed
  • Jobs exceeding max attempts permanently marked as failed

Performance Considerations

  • Polling Interval: Workers sleep 1 second when no jobs available to reduce database load
  • Transaction Scope: Job reservation uses short-lived transactions (3-second timeout)
  • Index Strategy: Compound index on (queue, status, available_at) ensures fast job lookup
  • Connection Pooling: Uses Go's database/sql connection pool (default: 2 idle, unlimited max)

Monitoring

Query job statistics directly from PostgreSQL:

-- Jobs by status
SELECT status, COUNT(*) 
FROM jobs 
GROUP BY status;

-- Failed jobs in last hour
SELECT id, type, attempts, last_error, updated_at
FROM jobs
WHERE status = 'failed'
  AND updated_at > NOW() - INTERVAL '1 hour'
ORDER BY updated_at DESC;

-- Average processing time by type
SELECT type, 
       AVG(EXTRACT(EPOCH FROM (updated_at - created_at))) as avg_duration_sec,
       COUNT(*) as total_jobs
FROM jobs
WHERE status = 'completed'
GROUP BY type;

Testing

Run a quick end-to-end test:

# Terminal 1: Start API
export DATABASE_URL="postgres://localhost/batchq?sslmode=disable"
export MODE=api
go run cmd/batchqd/main.go

# Terminal 2: Start worker
export DATABASE_URL="postgres://localhost/batchq?sslmode=disable"
export MODE=worker
go run cmd/batchqd/main.go

# Terminal 3: Enqueue test job
curl -X POST http://localhost:8080/jobs \
  -H "Content-Type: application/json" \
  -d '{"type":"demo_task","payload":{"message":"test","delay_ms":500}}'

Watch the worker logs to see the job being processed.

Production Deployment

Recommended production setup:

  1. API Servers: Run 2+ API instances behind a load balancer
  2. Workers: Start with 3-5 workers, scale based on queue depth
  3. Database: Use connection pooling (PgBouncer) for high-throughput scenarios
  4. Monitoring: Set up alerts on job failure rates and queue depth
  5. Backups: Regular PostgreSQL backups ensure job durability

Docker Deployment

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o batchqd ./cmd/batchqd

FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/batchqd /usr/local/bin/
CMD ["batchqd"]

Future Enhancements

  • Scheduled/delayed job execution
  • Job priority levels
  • Web dashboard for job monitoring
  • Job cancellation API
  • Bulk job enqueuing

License

MIT License - See LICENSE file for details

Contributing

Contributions welcome! Please open an issue or submit a pull request.

Author

Cole Schmidt — GitHub · LinkedIn

Built as part of a distributed systems portfolio exploring job queue internals, concurrency patterns, and database-backed coordination.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages