Skip to content
Skein
← All projects

infra · advanced · 7d

At-least-once job queue

Build a durable job queue on Postgres with visibility timeouts and idempotent consumers, so a crashed worker never drops a job.

Most queuing tutorials hand you a managed broker and skip the hard part: what actually happens when a worker crashes mid-job. This project strips that away and forces you to build the safety net from first principles — an atomic claim that can't be double-grabbed (and why SKIP LOCKED, not plain FOR UPDATE), a visibility timeout tuned from p99 with a heartbeat for long jobs, and an idempotent consumer that turns unavoidable re-delivery into a no-op via an atomic dedup+effect transaction. The final milestone turns the queue into a product: exponential backoff with jitter, a dead-letter queue whose depth is your earliest alert, RED metrics, and a chaos test killing workers at every crash point to prove zero lost and zero unsafe duplicate effects.

Deliverable

A Postgres-backed queue where every enqueued job runs at least once across worker crashes (SKIP LOCKED + visibility timeout + heartbeat), duplicates are no-ops via atomic idempotency keys, poison jobs land in DLQ after N attempts, and a dashboard shows claim throughput, visibility re-queues, and DLQ depth.

Milestones

0/5 · 0%
  1. 01Claim jobs without double-grab

    Make the claim atomic so two workers never grab the same job. The naive pattern — SELECT a pending row then UPDATE it to in-flight in two statements — is a race: between the SELECT and UPDATE another worker can SELECT the same row and both will UPDATE it, double-grabbing one job. Fix it by collapsing select and lock into one statement: `SELECT ... FOR UPDATE SKIP LOCKED` inside a transaction that also UPDATEs the row to in-flight and RETURNs it. The FOR UPDATE locks the row, SKIP LOCKED makes the second worker skip the locked row instead of blocking (a plain FOR UPDATE would serialize all workers on the hot queue table, adding lock-wait latency proportional to worker count). Prove it: run two concurrent workers polling the same queue with 100 pending jobs and assert they never receive the same job id, and that a skipped row is immediately available to the other poller — the claim throughput scales with worker count, not with lock waits.

    Definition of done
    • Two concurrent workers polling the same queue with 100 pending jobs never receive the same job row; a skipped-locked row is picked up by the other worker within one poll interval — verified with a concurrent claim test.
    • The claim is one transaction: SELECT FOR UPDATE SKIP LOCKED + UPDATE to in-flight + RETURNING, with no separate SELECT-then-UPDATE race window.
    Self-review

    Show the claim SQL and the two-worker concurrent test with job ids. A senior reviewer checks the claim is one transaction with SKIP LOCKED (not plain FOR UPDATE) and asks why plain FOR UPDATE would serialize workers.

  2. 02Re-queue jobs from dead workers

    Add a visibility timeout so a job whose worker dies before acking is not lost — it becomes claimable again after the timeout instead of stuck in 'claimed' forever. The timeout is the queue's most sensitive tuning knob and the senior mistake is setting it from intuition: too short and a slow-but-alive worker races its own task (the job is re-queued and processed twice concurrently, risking duplicate effects); too long and a real crash stalls the lane for minutes before any other worker can pick it up. The right value is slightly above p99 job duration, not p50 and not a conservative 10x. Implement it: claimed jobs carry `visible_at = now + visibility_timeout`; a sweeper or `WHERE visible_at < now()` claim predicate re-queues expired jobs. Prove it with a crash test: kill a worker mid-job (SIGKILL before ack) and show the job becomes claimable after exactly the visibility timeout, not lost and not immediately.

    Definition of done
    • Killing a worker mid-job before ack makes the job claimable again after exactly the visibility timeout (not lost, not immediate) — verified with a crash-before-ack test that asserts re-queue timing.
    • The timeout value is documented with p99 job duration and the two failure modes (too short = race live workers, too long = stall lane) are stated with the chosen value's rationale.
    Self-review

    Kill a worker before ack and show the job re-appears after the timeout. A senior reviewer checks the timeout is set from p99 (not p50/10x) and asks you to state both failure modes if it were 2x shorter or 5x longer.

  3. 03Heartbeat lease extension for long jobs

    Let long jobs outlive the visibility timeout without being stolen. A job whose real duration exceeds the timeout would otherwise be re-queued while still running — two workers would then process the same job concurrently, turning at-least-once into at-least-twice with overlapping effects. The fix is a heartbeat that extends the lease: the worker periodically UPDATEs `visible_at = now + visibility_timeout` on a sub-timeout interval (e.g. every timeout/3) while the job is in-flight. The extension must be atomic and must not extend a job that was already re-queued (check `WHERE status='claimed' AND visible_at` still matches). Prove it: run a job that sleeps 3× the visibility timeout with heartbeats and show it is never re-queued; kill the heartbeat mid-job and show it is re-queued after one timeout. Document why extending the timeout itself (instead of heartbeating) is wrong — it would stall real crashes for the full long duration.

    Definition of done
    • A job sleeping 3× the visibility timeout with heartbeats every timeout/3 is never re-queued; killing the heartbeat mid-job makes it claimable after one timeout — both verified with timed tests.
    • Lease extension is atomic (checks status/visible_at) and the interval choice plus why 'just increase the timeout' is wrong are documented.
    Self-review

    Show a 3×-timeout job with heartbeats never stolen and one where heartbeat stops and it is re-queued. A senior reviewer checks the extension is conditional on status/visible_at and asks why increasing the timeout itself would stall real crashes.

  4. 04Make re-delivery a no-op

    Make the consumer idempotent so a re-delivered job — whether from visibility timeout or heartbeat loss — produces exactly one effect. The only honest way is to record the idempotency key in the same transaction as the business effect: `INSERT dedup_key` + `UPDATE business_table` in one commit. If they are in separate commits, a crash between them either double-applies (effect committed, dedup key not — next delivery applies again) or replays forever (dedup key committed, effect not — next delivery is wrongly considered done). The job's natural idempotency key (e.g. `job_id` or `order_id`) is stored in a dedup table with a unique constraint; re-delivery with the same key hits the constraint and becomes a no-op. Prove it: process the same job twice with the same key and assert exactly one business effect; crash between effect and dedup insert (simulated by failing the transaction) and show neither is visible.

    Definition of done
    • Processing the same job twice (same idempotency key) produces exactly one business effect; the second run is a no-op via the unique dedup constraint — verified with a double-delivery test.
    • The dedup key and business effect are in the same transaction; a crash between them (simulated by rolling back) leaves neither visible — the atomicity boundary is tested and documented.
    Self-review

    Show double delivery with one effect and a rollback between dedup insert and business write leaving nothing. A senior reviewer checks both are in one transaction and asks what breaks if they were two commits.

  5. 05Dead-letter, backoff, and chaos

    Add the production safety net: a poison job that always fails must not loop forever and occupy the queue head, blocking every other job behind it. After N failures (e.g. 5) with exponential backoff and jitter (`delay = base * 2^attempt + rand(0, jitter)`), the job moves to a dead-letter table instead of being re-queued — DLQ depth rising is the earliest signal that something upstream is broken or a payload is malformed, and you should alert on it, not just on latency. Make the queue observable: emit metrics for claim throughput, visibility re-queue rate, DLQ depth, and job duration p50/p99. Then run a chaos test: kill workers at every crash point (before claim, during job, before ack, during dedup insert) under concurrent load and assert zero lost jobs and zero unsafe duplicate effects. The chaos test is the proof that the atomicity boundaries from milestones 1 and 4 actually hold when the process dies at the worst instant.

    Definition of done
    • A poison job that always fails lands in DLQ after N attempts with exponential backoff+jitter (delays verified); DLQ depth is a metric and an alert threshold is documented.
    • A chaos test killing workers at every crash point under concurrent load shows zero lost jobs and zero unsafe duplicate effects; claim throughput and DLQ depth are visible on a dashboard.
    Self-review

    Show the poison-job DLQ after N attempts with backoff delays and the chaos test killing workers at 4 crash points with zero lost/unsafe. A senior reviewer checks backoff is exponential+jitter (not fixed), DLQ depth is the alert signal, and the chaos covers the dedup atomicity boundary.

Starter

fallowlone/skein-projects

projects/at-least-once-queue

Open on GitHub ↗
  • README.md
  • src/queue.ts
  • test/queue.test.ts
Grab just this project npx degit fallowlone/skein-projects/projects/at-least-once-queue at-least-once-queue

Implement the stubs, then run the tests until they pass: bun test

Fork the repo and push your work — the grade workflow runs the suite plus static checks on your own runners.

Rubric

Junior Mid Senior
Claim atomicity A worker selects a pending job and updates it in two separate statements; under concurrent load, two workers occasionally grab the same row. Claim is a single UPDATE ... WHERE state='pending' RETURNING with FOR UPDATE SKIP LOCKED — concurrent workers never double-grab, and a skipped row is immediately available to the next poller. You can reason about the contention model: SKIP LOCKED scales to many workers with no lock-wait latency, but concentrates all pending work on the oldest rows; you measure the claim throughput ceiling and know when to partition the queue table.
Visibility timeout & re-delivery A crashed worker's job is stuck in 'claimed' until manually intervened; there is no automatic re-queue. A sweeper or lease check re-queues jobs whose visibility timeout expired; a live worker extends its lease via heartbeat so long jobs are not stolen. You set the timeout against p99 job duration and can articulate the two failure modes: too short means a slow-but-alive worker races its own task; too long means a real crash leaves the lane stalled for minutes — you document the chosen value and its rationale.
Idempotent consumer & dead-letter Re-delivered jobs are processed again, occasionally producing duplicate side effects; no attempt cap exists. A dedup key recorded in the same transaction as the effect makes re-delivery a no-op; after N failures the job moves to a dead-letter table instead of looping. You reason about the atomicity boundary: if the effect and dedup key are in separate commits, a crash between them either double-applies or replays forever — your design makes both impossible, and you prove it with a chaos test killing workers at every crash point.
Reference walkthrough (spoiler)

Why at-least-once is the honest baseline: exactly-once delivery requires distributed coordination that is either very expensive (two-phase commit) or impossible across heterogeneous systems. Any durable queue built on a single datastore can only promise at-least-once — a job runs again if the worker crashes before acking — and correctness is pushed to the consumer via idempotency.

FOR UPDATE SKIP LOCKED as the claim primitive: it combines select and lock in one statement so no second worker can observe the same row, and the SKIP avoids lock-wait pile-ups — a blocking FOR UPDATE would serialize all workers on a hot table instead of letting them fan out.

The visibility timeout tuning trap: the right timeout is slightly above p99 job duration — not p50, not a conservative 10x guess. Too short and you race live workers; too long and crashed-worker jobs stall the lane. A heartbeat that renews the lease on a sub-timeout interval is the correct fix for long jobs, not a larger timeout.

Dead-letter queue depth as the earliest signal: a poison job crashes every worker that touches it, and without a DLQ it occupies the queue head forever, blocking every other job behind it. DLQ depth rising is the first observable symptom that something upstream is broken or a payload is malformed — alert on it, not on job latency alone.

Make it senior

  • Partition the queue table by hash of job type so claim throughput scales beyond the single-table SKIP LOCKED ceiling; measure the ceiling before and after.
  • Add fair queueing so one tenant's burst doesn't starve another tenant's jobs — implement per-tenant claim quotas and prove isolation under load.
  • Replace polling with LISTEN/NOTIFY so claim latency drops from poll interval to notification latency; measure the p99 claim delay improvement.

Skills

SELECT ... FOR UPDATE SKIP LOCKEDvisibility timeout & lease renewalidempotency keys (atomic dedup + effect)dead-letter queue & poison-job handlingchaos testing & queue observability

Suggested stack

postgresnodehono

Resources