A job queue where a crashed or zombie worker can't apply an effect twice
A durable job queue and workflow engine in Go, on PostgreSQL alone
Summary
- Problem
- Background jobs must survive crashes and restarts with no job lost and no effect applied twice. Exactly-once delivery over an unreliable network is impossible, so the guarantee has to be stated precisely.
- My role
- Sole developer
- Team
- Personal project
- Status
- Shipped
- Timeline
- October 2026
- Stack
- Go, PostgreSQL, TLA+ (TLC), Docker Compose, GitHub Actions
- Next
- Scale past the measured ceiling of about 1,200 jobs/s on one Postgres by running one Conveyor stack per queue.
What shipped
- Applied every job's effect at most once, with 60 of 60 jobs completed and zero fencing violations under crashes, pauses, a Postgres restart and dropped connections, by guarding writes with fencing tokens.
- Reached 1,243 jobs/s on 500 jobs, against 298–643 for River on the same machine, by batching SKIP LOCKED dequeues and debouncing LISTEN/NOTIFY wake-ups.
- Checked the lease protocol's interleavings on every push by running a TLA+ model through TLC in CI.
- Throughput, 500 jobs
- 1,243 jobs/s
- Baseline: River 298–643 jobs/s, by fetch setting
- 4 worker loops × 8 in flight, debounced NOTIFY, same Mac and Postgres 17 for both (results/benchmarks-2026-10-08.json); start latency is the cost, p50 23 ms against River's 2 ms at its fastest setting
- Jobs completed under faults
- 60 of 60
- kill -9, SIGSTOP/SIGCONT, a Postgres stop and start, and 5% dropped connections at 30 ms latency (results/fault-*.json), with zero fencing-token violations
- Code available on request
- Present this project
On this page
Context and constraints
Background work has to survive worker crashes, network partitions and Postgres restarts, with no job lost and no effect applied twice. Conveyor guarantees two things, stated in docs/design.md §5.4: every accepted job is eventually executed at least once, and every job’s effect (a ledger insert plus a state transition) commits at most once, even when a zombie worker finishes after losing its lease. It is single-region, with no multi-tenant isolation, and it relies on Postgres’s now() for leases.
Architecture
Producers enqueue over HTTP or the Go client with an idempotency key, which is unique per queue, so a retried enqueue is harmless. Workers dequeue in batches with FOR UPDATE SKIP LOCKED, which also issues a new lease version as a fencing token. Heartbeats extend the lease only while the worker still holds the current token, and every outcome write is guarded the same way. A scheduler expires leases and runs cron, and LISTEN/NOTIFY wakes idle workers.
Request flows
- Enqueue and run a job: Producer (HTTP or Go client) → Conveyor API → PostgreSQL (jobs, queues, cron, ledger) → LISTEN / NOTIFY → Worker pool → Job handler and its effect → PostgreSQL (jobs, queues, cron, ledger)
- Recover from a crashed worker: Scheduler and lease reaper → PostgreSQL (jobs, queues, cron, ledger) → LISTEN / NOTIFY → Worker pool
Engineering decisions
Where should job state live?
- Options
- Redis
- Kafka
- A pluggable storage layer
- PostgreSQL for everything
- Chose
- PostgreSQL for jobs, queues, cron, dependencies and the fault-injection ledger
- Trade-off
- The throughput ceiling is lower than Redis or Kafka; sharding by queue is the documented escape hatch, unused in v1.
- Evidence
- SELECT … FOR UPDATE SKIP LOCKED, advisory locks and LISTEN/NOTIFY give correctness with no second store to keep atomic with.
What does "exactly once" mean when delivery can't be exactly once?
- Options
- Claim exactly-once delivery
- Accept duplicate effects
- At-least-once execution with effects guarded by a fencing token
- Chose
- Every dequeue issues a new lease version, and every outcome write is guarded by owner and version
- Trade-off
- Effects in external systems are the handler's responsibility; Conveyor can only pass the token on as an idempotency key.
- Evidence
- A zombie worker's completion updates 0 rows, so it can't overwrite a newer result; the fault runs recorded zero fencing violations.
How are the protocol's interleavings checked?
- Options
- Unit tests only
- Fault injection only
- A TLA+ model checked by TLC in CI
- plus fault injection
- Chose
- A TLA+ model checked in CI on a small configuration (3 jobs, 2 workers)
- Trade-off
- The model abstracts SQL, priorities, cron and the network, and larger configurations run by hand.
- Evidence
- spec/tla/Conveyor.tla runs in the CI tlc job on every push, next to go vet, staticcheck and the race-detector tests.
How do idle workers find new work quickly?
- Options
- Poll on a timer
- NOTIFY on every enqueue
- Debounced NOTIFY
- Chose
- Debounced NOTIFY
- Trade-off
- Start latency rises from a 1 ms p50 to 23 ms.
- Evidence
- Throughput rose from 789 to 1,243 jobs/s on the same 500-job run.
Evaluation
Conveyor was benchmarked against River, an established Go job queue, on the same Mac and Postgres 17. River’s throughput depends heavily on its fetch cooldown, so both settings are reported.
| System | Throughput | p50 enqueue→start | p95 | p99 |
|---|---|---|---|---|
| Conveyor, debounced NOTIFY | 1,243/s | 23 ms | 48 ms | 53 ms |
| Conveyor, NOTIFY per enqueue | 789/s | 1 ms | 7 ms | 13 ms |
| River v0.49.0, fetch cooldown 100 ms | 298/s | 591 ms | 1,088 ms | 1,165 ms |
| River v0.49.0, fetch cooldown 1 ms | 643/s | 2 ms | 3 ms | 5 ms |
500 jobs per run. The raw outputs are in results/benchmarks-2026-10-08.json, and the fault-injection results are in results/fault-*.json (code available on request).
Deployment and operations
The full stack runs with Docker Compose. CI runs go vet, staticcheck, the tests with the race detector, and TLC on the TLA+ model on every push. Fault injection, covering kill -9, SIGSTOP, a Postgres restart and dropped connections, runs as go test ./internal/fault. Trace context is stored on every job and shown in the job view, and a runbook covers operations. The live demo is self-hosted with Docker Compose; there is no public deployment.
Results
All 60 jobs completed under every injected fault, with zero fencing-token violations. On one Postgres, throughput tops out around 1,200 jobs/s on the development Mac. River inserts faster at its default fetch settings; Conveyor trades that for an explicit fencing and heartbeat contract. The concurrency limit per queue is a soft cap, counted without locking.
Code highlight
A heartbeat that a zombie worker can't win
The lease version is the fencing token. A worker whose lease expired and was reissued matches 0 rows, learns it is stale, and must not apply its effect.
// Heartbeat extends the lease. Returns ErrStaleLease when the caller no
// longer holds the current fencing token.
func Heartbeat(ctx context.Context, db *sql.DB, id int64, owner string, version int64, lease time.Duration) error {
res, err := db.ExecContext(ctx, `
UPDATE jobs SET lease_expiry = now() + $4::interval, updated_at=now()
WHERE id=$1 AND state='running' AND lease_owner=$2 AND lease_version=$3`,
id, owner, version, fmt.Sprintf("%d seconds", int(lease.Seconds())))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrStaleLease
}
return nil
}What I'd do next
Export trace context to a tracing backend, which is stored per job today but not sent anywhere, and add more than the current minimal set of Prometheus metrics.
Talk about this project
I'm happy to walk through any decision here in more depth.