tinybus

Backend
2026-04-27T07:41:54Z
GoBackendPostgresSQLJob Queue

tinybus is a small Go library + CLI that implements a durable job queue using Postgres' row-level locks as the broker. Producers INSERT rows; workers claim them in a single CTE+UPDATE+RETURNING round-trip with FOR UPDATE SKIP LOCKED, which gives exactly one row to exactly one worker without serializing the pool behind a global lock. A sweeper goroutine periodically reclaims locks older than the configured lease, making the system crash-safe at-least-once: a worker that dies mid-job leaves a stuck `locked_at`, the sweeper clears it, another worker picks the job up.

The schema commits to several deliberate trade-offs documented inline in the migration file. Payload is BYTEA (opaque, fast) rather than JSONB (queryable, slower). State is implicit in nullable timestamps (locked_at, dead_at, run_at) rather than a status enum, so adding new states never requires ALTER TYPE. Three partial indexes back the hot paths: idx_jobs_ready (the claim query), idx_jobs_dead (dead-letter inspection), idx_jobs_in_flight (the sweeper). Partial means even a queue with millions of historical rows keeps fetch latency constant.

Retries use exponential backoff with equal jitter (d/2 deterministic plus a random [0, d/2]) to avoid the dogpile where 1000 jobs that all failed at the same instant retry at the same instant. Failures past max_attempts are moved to dead state with the last error preserved, rather than deleted. Successes delete the row by default (smaller table, cheaper inserts).

The CLI ships migrate up/down (with a ledger table tracking applied versions), enqueue, worker, producer (for the docker-compose demo), and stats subcommands. The worker exposes /healthz and /stats over HTTP when --http-addr or $PORT is set, making it Railway-ready out of the box. Migrations are embedded into the binary via Go's embed.FS, so deployment is a single artifact with no separate SQL bundle.

Integration tests run against a real Postgres via testcontainers-go, gated behind a build tag so the unit tests stay Docker-free. The headline test starts 4 workers against 50 jobs and asserts every job runs exactly once: the proof-by-evidence of the SKIP LOCKED claim.

  • Single CTE+UPDATE+RETURNING claim query: one round-trip per job, no race window between SELECT and UPDATE
  • Postgres row-level locks (FOR UPDATE SKIP LOCKED) as the broker. No Redis, no AMQP, no Kafka
  • State stored implicitly in nullable timestamps; three partial indexes back the hot paths
  • Lock-expiry sweeper reclaims locks from crashed workers (lease-based, not heartbeat; the same choice River and Oban make)
  • Exponential backoff with equal jitter to prevent retry dogpiling after transient outages
  • Embedded migrations via embed.FS; ledger-tracked up/down runner is ~150 lines
  • Functional-options public API (WithDSN/WithPool/WithLeaseDuration/WithConcurrency)
  • Integration tests using testcontainers-go gated behind //go:build integration; unit tests need no Docker
  • Headline correctness test: 4 workers + 50 jobs + assert each ran exactly once
  • Railway-ready out of the box: distroless image, $PORT-aware /healthz, railway.json checked in
  • Single third-party runtime dep (jackc/pgx/v5); everything else is the standard library