Review: Postgres Job Queue Worker Written by an AI Assistant
Your team asked an AI coding assistant to replace a cron script with a background job queue stored in PostgreSQL. It opened this pull request. You are the reviewer, and nothing merges without your approval.
The assistant's description:
Adds ajobstable and a worker loop. Jobs are claimed oldest first, retried with exponential backoff up to 5 attempts, and enqueued inside the caller's transaction so a job only exists if the work that created it committed. Handlers live inHANDLERS, keyed by job kind.
Where it runs: 12 worker processes on 3 hosts, using psycopg 3 with its default type adapters, against PostgreSQL 15. The queue handles about 50,000 jobs a day (sending emails and charging subscriptions), and finished jobs are kept for 90 days. Workers are restarted on every deploy, several times a day. The web app's database sessions use the Europe/London time zone; the workers' sessions use UTC.
Your task
- Flag every problem you would block the merge for. Click a line number to comment, and Shift-click to cover a range.
- For each one, choose the issue type and severity, and explain what fails and how to fix it.
- You are scored on the problems you catch, the right issue type and a clear explanation. Missing a critical problem fails the round.
Aim for 20 minutes, as in a real review round.
Two workers run claim_job at the same instant. Walk through both transactions.
What Python type does psycopg 3 return for a jsonb column?
MAX_ATTEMPTS is defined. Where is it used?
A deploy kills a worker halfway through a job. What is that row's status a day later?
Look at the column types and the index in the migration, with 90 days of finished jobs in the table.
A strong reviewer blocks this change on two grounds: as written no job ever succeeds, and once that is fixed, concurrent workers will run the same job twice, which means duplicate emails and duplicate subscription charges.
- Two workers can claim the same job (lines 29–33 of worker.py, critical). The
SELECTtakes no lock, so two workers read the same oldest row, and bothUPDATEs succeed because the update does not check the status. With 12 workers polling, duplicates are routine. Claim in one statement:UPDATE jobs SET status = 'running' ... WHERE id = (SELECT id ... FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING .... - Payload decoded twice (line 78, critical). psycopg 3 already turns
jsonbinto adict, andjson.loads(dict)raisesTypeError. Every job fails, and is retried forever. Passpayloadstraight to the handler. - Retries never stop (lines 57–64, major).
MAX_ATTEMPTSis never checked, so a poison job is retried for ever with growing delays and never reaches a dead state anyone can see. Whenattempts + 1 >= MAX_ATTEMPTS, setstatus = 'failed'and alert. - Jobs stuck in running (line 39, major). If a worker dies after claiming, which happens on every deploy, the row stays
runningfor ever. Store a lease such aslocked_until, reclaim expired leases, and handleSIGTERMso a worker finishes its current job before exiting. - Bookkeeping errors re-run work (lines 79–82, minor).
finishis inside thetry, so if the update fails after the handler succeeded, the job is requeued and runs again. Iffailitself raises, the whole worker crashes. Separate handler errors from database errors and reconnect on connection loss. - Wrong index (line 14 of the migration, minor). The polling query filters by
statusandrun_at, notkind, so 12 workers each scan millions of finished rows every second. Use a partial index:CREATE INDEX ON jobs (run_at) WHERE status = 'queued'. - Time zone mix-up (line 8 of the migration, minor).
run_atistimestampwithout time zone, sonow()is stored as local time of the writing session. Jobs enqueued from the web app (London time) and compared by workers (UTC) run an hour late in summer. Usetimestamptz.