Repository navigation
UN-4176 [FIX] Cut idle PG-queue polling to one autocommit claim per cycle - #2349
Conversation
…ycle Idle PG-queue consumers were the largest steady source of DB statements: every poll cycle sent one claim per queue, each wrapped in BEGIN/COMMIT. - PgQueueClient: connections the client opens itself run in autocommit, so each single-statement operation (claim, ack, lease renewal, send) is one statement instead of three. Injected connections are untouched (they may be the caller's open transaction), and create_pg_connection stays non-autocommit for pg_barrier. - PgQueueClient.read_any: claims across several queues in one statement, with an indexed per-queue probe (LATERAL on the partial claim index) so cost does not grow with the backlog. - PgQueueConsumer.poll_once: multi-queue consumers make one read_any claim per cycle. The first queue probed rotates every cycle so no queue starves; priority still orders messages within a queue. Single-queue consumers keep the existing claim SQL. No queue lists change. Measured locally with all PG workers idle: queue polling 89.0 -> 9.2 statements/s; API deployment still completes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax
|
|
[High impact] The PR appears safe to merge; no new actionable issues were found.
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax
The concurrent-readers test could pass with one reader draining every message before the other read, and a reader's exception died in its thread. Add a test that holds reader A's claim transaction open while reader B claims (lock_timeout turns a wait into an error), and release the parallel drain from a barrier with results collected through futures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax
Self-review — round 1
Mode: INITIAL. This is a local working diff of Signals: lenses 6 and 8 raised (the claim and poll logic in Verdict: REQUEST CHANGES Summary: Critical 0 · High 1 · Medium 6 · Low 5 · Lenses run 19/19 (13 examined, 6 N/A) Findings1. [High] [Lens 6] Rotating
2. [Medium] [Lens 3] The cycle-wide
3. [Medium] [Lens 9] The per-queue probe locks up to n×qty rows to claim qty, and a concurrent reader that hits them sees an empty queue and backs off
4. [Medium] [Lens 8] Autocommit removes the failed-rollback dead-connection signal that
5. [Medium] [Lens 13]
6. [Medium] [Lens 13] Batch order within a
7. [Medium] [Lens 16] The module and
8. [Low] [Lens 16] "The queue probed first rotates" is inaccurate, because every queue is probed on every call
9. [Low] [Lens 17] "Stamped before each queue read" is stale in three places, and the new comment misstates the constant
10. [Low] [Lens 16] Comments that cite removed behaviour, or a caller that was not found
11. [Low] [Lens 16] "One statement per cycle" holds only on a client-owned autocommit connection
12. [Low] [Lens 13]
Unanchored findings
Lens checklist
Open questions for the author
Assumptions made
Generated by Claude Code |
- Rotate the multi-queue claim from one past the queue actually served (QueueMessage.queue_name), so busy queues take equal turns; advancing by one per cycle gave a busy queue an extra turn per empty queue before it. - poll_once separates the claim from handling: a handler failure logs the msg_ids left unstarted and still counts the messages processed. - Document the accepted read_any trade-off (the probe locks every queue's head; a racing reader can see [] and back off) and the autocommit one-statement rule; fix stale "each queue read" comments. - Tests: read_any lease (vt), priority order within a qty>1 batch, queue_name on read/read_any, reconnect after pg_terminate_backend on an owned autocommit connection, equal turns around an empty queue on real Postgres; bad-args test no longer needs a database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax
Self-review — round 2
Mode: INITIAL, on the local diff. The base is Signals: lens 6 raised (claim, lease and queue-consumer logic), lens 8 (retries and connection recovery), lens 13 (tests changed), lens 16 (comments changed) and lens 17 (the Verdict: REQUEST-CHANGES Summary: 0 Critical, 0 High, 3 Medium, 5 Low (8 findings). Findings1. [Medium] [Lens 3, 6, 8]
2. [Medium] [Lens 3, 8, 11] Owned-connection autocommit makes a statement that failed in flight ambiguous, which
3. [Medium] [Lens 16] The
4. [Medium] [Lens 13] Untested
5. [Low] [Lens 16, 17]
6. [Low] [Lens 3, 10]
7. [Low] [Lens 8]
8. [Low] [Lens 16] Two inaccurate test comments
Not covered / caveats
Lens checklist (19)
cost: agents=8 (5 first-round, 3 re-dispatched) wall=? files=5 lines=618 lenses-examined=15 of 19 Generated by Claude Code |
- poll_once skips a message whose handling fails and runs the rest of the batch, so a deterministic failure cannot strand its batch-mates (each redelivery costs them a read_ct attempt); a connection-dead failure still stops the batch, since the rest could not be acked either. - Warn when a claimed message's queue_name is unknown instead of silently rotating by one. - Document the autocommit trade-offs: a claim lost in flight stays claimed until its lease lapses and counts toward read_ct; send()'s ambiguous post-commit window is the INSERT's own reply. Fix the _cursor NOTE and the HEALTH_STALE comment (a batch, not a task). - Tests: rotation follows the last message of a batch, the unknown-name fallback, skip-and-continue, connection loss stopping the batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax
…rror - poll_once no longer stops a batch on a connection-classified handler error: the client drops the dead handle and reconnects for the next ack (and the classifier also matches cancellations and deadlocks), so every failure is now logged and skipped. - On a client-owned (autocommit) connection, rollback() can no longer prove a connection dead, so a bare psycopg2.DatabaseError is treated as dead and the handle dropped, keeping what the failed-rollback path did. - read_any trade-off text names single-queue consumers too; comments no longer claim rollback() cannot fail. - Tests: owned conn dropped on a bare DatabaseError, kept on a statement error; injected conn kept; a connection error does not stop the batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax
Self-review — round 3
Verdict: APPROVE WITH NITS. Summary: Critical 0 · High 0 · Medium 2 · Low 3 · Lenses run 19/19 (11, 12, 14, 18 and 19 are N/A, with reasons below). I didn't run the security deep-dive. The diff has no endpoint, parser, authn/authz, secret or new external-input boundary. The boundary sweep traced 6 log sinks and found them clean. Findings
Unanchored findings
Lens checklist
Open questions for the author
Assumptions made
cost: agents=6 wall=? lenses-examined=14 of 19 files=5 lines=731 Addressed in Generated by Claude Code |
A handler failure stops the batch again, as each queue's batch did on main: rounds 2-4 showed both alternatives cost something (stopping burns a read_ct attempt for the unstarted tail; continuing runs work whose ack cannot land during a DB outage, so it runs twice). Matching main keeps this PR to its scope. Still counts the messages handled and logs the msg_ids left unstarted; the trade-off is written in poll_once. Reword the bare-DatabaseError comment as the heuristic it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax
Self-review — round 4
Verdict: REQUEST CHANGES Summary: Critical: 0 · High: 0 · Medium: 1 · Low: 1 · Lenses run: 19/19 (lens 4 read directly, no security deep-dive needed)
Findings1. [Medium] [Lens 3, 6] — A batch now keeps running tasks while the DB is unreachable, so finished work is redelivered and runs twice
2. [Low] [Lens 16] — "no SQLSTATE subclass, so not a statement error" is stated more strongly than psycopg2 guarantees
Unanchored findings
Checks that came back clean
Addressed in Generated by Claude Code |
- _handle drops and acks a payload that is not a JSON object instead of raising on payload.get(); with the batch stopping on a handler failure, such a row would otherwise stop its batch on every delivery. - The handling-failure log names the queue and lists unstarted msg_ids only when there are any. - Fix the _cursor NOTE: an injected connection is never dropped. - Tests: the failure position parametrised over the batch (return value, work done, log text, rotation), and the non-object drop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax
Self-review — round 5
Verdict: APPROVE WITH NITS Summary: Critical 0 · High 0 · Medium 0 · Low 4 · Lenses run 19/19 (5, 11, 12, 14, 18 and 19 are N/A, see the checklist). Scope: 5 files, 730 lines under Signals raised: lenses 6, 8, 13 and 16 (queue-consumer batch logic, tests, comments). Security: the deep-dive wasn't triggered because no attack surface fired. The boundary sweep traced 2 log sinks and found them clean: they carry only msg_ids. Fan-out: five agents ran.
Test evidence: the cold reader ran both changed test files: 177 passed, including the 35 integration tests against real Postgres. The code-reviewer also checked the new Round-4 delta: the delta is correct. In Findings
Unanchored findings
Addressed in Generated by Claude Code |
Self-review — round 6 (loop converged)
Verdict: APPROVE, with 3 Low findings and nothing above Low. I reviewed HEAD 8c78d0f against merge-base 6da4590. The base fetch worked. This is a local diff, so there was no GitHub file count to check it against. I read the round-6 delta ( Summary: Critical 0 · High 0 · Medium 0 · Low 3 · Lenses run 19/19 (5 N/A with reasons) Signals: lenses 6 and 8 (concurrency, queue-consumer logic) and 13 (tests changed) were raised. The security deep-dive did not trigger because no attack-surface trigger fired. The boundary sweep traced one sink, the malformed-payload log, which is finding 2. Findings
Notes on the round-6 delta
Author's disposition: nothing above the severity floor, and this round reviewed the round-5 fixes, so the self-review loop is complete.
Generated by Claude Code |
vishnuszipstack
left a comment
There was a problem hiding this comment.
Ran the PR Review Toolkit agents (code-reviewer, silent-failure-hunter, type-design-analyzer, pr-test-analyzer, comment-analyzer, code-simplifier) against 8c78d0f.
Correctness: no blocking issues found. Checked the rotation key in _dequeue_any_sql (never negative, same key in both ORDER BYs), the order of the 10 placeholders, FOR UPDATE SKIP LOCKED LIMIT inside the LATERAL, autocommit set only on owned connections, every client op being one statement, and no outside caller using PgQueueClient.conn for a multi-statement transaction. The handler-failure slice and count are also right. The accepted trade-offs in the description hold as written.
Inline comments, highest priority first: the queue_name default and the multi-queue E2E test gap (medium), then type/robustness, simplification, a test flake, and comment-accuracy nits. Nothing here blocks the merge.
… rotation - QueueMessage.queue_name is required (both constructors always set it), so the consumer's unknown-name fallback and its test are gone; message is annotated Any, since a raw writer can store any JSON. - read_any de-duplicates queue names: a repeated queue's second probe returned the same rows and crowded another queue out of the batch. - Rotation is done in Python by binding the queue list already rotated; the SQL orders by ord and takes 4 parameters instead of 10. - Comment accuracy: unlocked when the claim commits; rollback() can't detect a dead server connection; drop the "before this loop" wording. - Tests: end-to-end multi-queue claim, execute and ack on real Postgres; duplicate queue names still fill the batch; pg_terminate_backend waits for the backend to exit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax
Keep the PR to its scope and to required changes only: - Restore the SQL-side rotation (cosmetic simplification, not a defect; the de-duplication of queue names stays). - Restore QueueMessage.message's dict annotation and docstring, a pre-existing public field this PR does not need to change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax
|
Unstract test resultsPer-group results
Critical paths
|



What
BEGIN/COMMITaround it.Why
BEGIN+COMMIT, so it cost 3 statements. A 7-queue executor process therefore sent 21 statements every cycle, even with nothing to do.How
PgQueueClient: a connection the client opens itself now runs in autocommit. Every client operation (claim, ack, lease renewal, send) is one statement, so the transaction wrapper only added round trips.create_pg_connectionitself stays non-autocommit, whichpg_barrierrelies on.PgQueueClient.read_any(): claims from several queues in one statement.LATERALon the existing partial claim index), so cost does not grow with the backlog.read():SKIP LOCKED, lease,read_ct.QueueMessage.queue_name(new, required field): the queue a message was claimed from. Bothread()andread_any()fill it in.PgQueueConsumer.poll_once():read_any()call per cycle.Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)
run()backs off). A handler failure stops the batch, as each queue's batch did before; the messages already handled are still counted, and the msg_ids left unstarted are logged (they stay claimed until their lease lapses and the reaper re-arms them). Batches above 1 only happen when the lease equals the max runtime (batch_sizeis otherwise forced to 1)._handleon every delivery.PgQueueClientcreates itself. Injected connections andpg_barrier's connection are untouched, so callers that rely on a shared transaction behave as before (e.g. unstract-cloud's agentic fan-out claim, which relies onsend()committing its claim INSERT together with the enqueue).rollback()sends nothing, so it can no longer prove a connection dead. Termination surfaces asOperationalError(dropped and reconnected, covered by apg_terminate_backendtest), and, as a heuristic, a barepsycopg2.DatabaseErroris now treated as dead as well, keeping what the failed-rollback path did before (a rare unmapped statement error costs only a needless reconnect).read_any): a claim commits at statement end, so if the connection dies after the claim but before its rows are read back, those messages stayclaimeduntil their lease lapses (≤lease_seconds, 120 s by default), then the reaper re-arms them; that delivery counts towardread_ct. Before, the claim would have rolled back. The window is one statement's reply on a dying connection.read_any): under contention, the probe locks up toqtyready rows in every queue for the duration of the single statement; candidates that aren't chosen stayready. A reader racing that statement skips them, so if each queue's only ready row is locked it gets[]and takes the normal empty-poll back-off (starting atpoll_interval, 0.1 s). This only happens while several consumers contend for the last rows; idle polls lock nothing.Database Migrations
read_any()uses the existing partial claim index.Env Config
Relevant Docs
PgQueueClient.read_any,PgQueueClient.connandPgQueueConsumer.poll_once.Related Issues or PRs
Dependencies Versions
Notes on Testing
read_any: claiming from whichever queue has work (with its lease andqueue_name), rotation and wrap-around, priority within a queue and within aqty>1batch, a batch spanning queues, candidates that aren't chosen stayingready, a held claim being skipped rather than waited on, two concurrent readers never double-claiming, an owned autocommit connection persisting the claim, and reconnecting afterpg_terminate_backend, and duplicate queue names still filling the batch.SKIP LOCKEDremoved from the multi-queue claim, rotation advancing by one regardless of the queue served, rotation following the first instead of the last message of a batch, queue-name de-duplication removed,message/queue_nameswapped in the claim's final SELECT, a handler failure continuing past the failed message or returning 0, the non-object payload guard removed, the unstarted-msg_id log slice, the bare-DatabaseErrorrule removed or widened to statement errors, theread_anylease set tonow(), and priority dropped from the batch order.REQUIRE_PG_TESTS=1for the PG queue client, consumer, dispatch, supervisor, barrier and reaper tests).pg_stat_statements:BEGIN/COMMITfrom the queue client disappeared.Screenshots
Checklist
I have read and understood the Contribution Guidelines.
🤖 Generated with Claude Code
https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax
Generated by Claude Code
Generated by Claude Code