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 a jobs table 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 in HANDLERS, 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.

  1. Two workers can claim the same job (lines 29–33 of worker.py, critical). The SELECT takes no lock, so two workers read the same oldest row, and both UPDATEs 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 ....
  2. Payload decoded twice (line 78, critical). psycopg 3 already turns jsonb into a dict, and json.loads(dict) raises TypeError. Every job fails, and is retried forever. Pass payload straight to the handler.
  3. Retries never stop (lines 57–64, major). MAX_ATTEMPTS is never checked, so a poison job is retried for ever with growing delays and never reaches a dead state anyone can see. When attempts + 1 >= MAX_ATTEMPTS, set status = 'failed' and alert.
  4. Jobs stuck in running (line 39, major). If a worker dies after claiming, which happens on every deploy, the row stays running for ever. Store a lease such as locked_until, reclaim expired leases, and handle SIGTERM so a worker finishes its current job before exiting.
  5. Bookkeeping errors re-run work (lines 79–82, minor). finish is inside the try, so if the update fails after the handler succeeded, the job is requeued and runs again. If fail itself raises, the whole worker crashes. Separate handler errors from database errors and reconnect on connection loss.
  6. Wrong index (line 14 of the migration, minor). The polling query filters by status and run_at, not kind, so 12 workers each scan millions of finished rows every second. Use a partial index: CREATE INDEX ON jobs (run_at) WHERE status = 'queued'.
  7. Time zone mix-up (line 8 of the migration, minor). run_at is timestamp without time zone, so now() 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. Use timestamptz.
Next Post Previous Post
No Comment
Add Comment
comment url