Question presented to candidate: "We want to run background jobs from a PostgreSQL table instead of adding a message broker. Several workers will poll the same table. How do you make sure each job is picked up by exactly one worker, workers do not block each other, and a job is not lost when a worker crashes?"
What a strong answer should cover:
- Why the naive "SELECT the oldest queued job, then UPDATE it" double-claims under concurrency, and why plain
FOR UPDATEis correct but makes workers queue one behind another - The claim statement:
SELECT ... ORDER BY ... LIMIT 1 FOR UPDATE SKIP LOCKEDinside a CTE thatUPDATE ... RETURNINGthe job, so claiming is one atomic step - Crash safety: either keep the transaction open while working (a crash rolls back and releases the row) or commit a lease (
locked_until) and have a reaper re-queue expired jobs; attempts counter and a dead-letter state - Indexing: a partial index on the ready jobs so each claim is an index lookup, not a scan of finished rows; table bloat and vacuum on a busy queue
- Engine notes: SKIP LOCKED since PostgreSQL 9.5 and in MySQL 8.0; SQLite has one writer, so a single
UPDATE ... RETURNINGclaim is safe there but does not scale out - Limits: ordering is only approximate with SKIP LOCKED, at-least-once delivery means handlers must be idempotent, LISTEN/NOTIFY can replace tight polling
Clarifying questions expected:
- How many jobs per second and how long does a job take (seconds or minutes)? That decides between holding the transaction open and using a lease.
- Is strict ordering required, or is "roughly oldest first" acceptable?
- What should happen to a job that keeps failing?
Code / implementation expected: Yes — the jobs table, the claim query, the completion and the reaper for expired leases.
Schema used by the examples: