Skip to content

UN-4254 [PERF] Run PG-queue ops in autocommit and slow idle polling - #2328

Merged
johnyrahul merged 6 commits into
mainfrom
ccr-679ddaa3-wvlx3r
Oct 22, 2026
Merged

johnyrahul merged 6 commits into
mainfrom
ccr-679ddaa3-wvlx3r

Conversation

@johnyrahul

@johnyrahul johnyrahul commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

What

  • PgResultBackend turns on autocommit on the connections it opens itself, so each result store / get / forget is sent as one statement, with no BEGIN / COMMIT round trips.
  • A shared is_dead_after_autocommit_error() helper in connection.py decides when an owned autocommit connection must be dropped after an error. Both PgQueueClient._cursor and PgResultBackend._cursor use it.
  • Idle polling defaults go from POLL_INTERVAL=0.1 / BACKOFF_MAX=2.0 to 0.5 / 5.0.

PgQueueClient's own autocommit already landed on main in #2349 (UN-4176). This PR merges main and does not repeat that change.

Why

From the UN-4136 load tests (see UN-4254):

  • BEGIN/COMMIT was about 34–38% of all statements under load. Most of it wrapped operations that are already one atomic statement. UN-4176 [FIX] Cut idle PG-queue polling to one autocommit claim per cycle #2349 removed this for the queue client; the result backend still paid it on every result write and read.
  • Claim polling was another 15–17%. Each consumer slot makes one claim per idle cycle, so idle load is about slots ÷ BACKOFF_MAX and follows worker count, not work. Idle dev namespaces were running about 150 claims/s each on the shared staging instance.

How

  • PgResultBackend.conn sets autocommit = True only when it opens its own connection. Every statement it runs is a single idempotent SELECT, INSERT … ON CONFLICT DO NOTHING or UPDATE, so nothing relied on two statements committing together.
  • Under autocommit, rollback() sends nothing to the server, so it no longer exposes a dead connection that psycopg2 still reports as open. is_dead_after_autocommit_error() therefore also treats a bare psycopg2.DatabaseError as a dead connection. This is the rule PgQueueClient._cursor already had inline; it now lives in one place. is_connection_dead() is unchanged, so no retry site starts retrying a bare DatabaseError.
  • A connection the server terminates is still caught as before: it raises OperationalError and psycopg2 marks it closed.
  • _DEFAULT_POLL_INTERVAL and _DEFAULT_BACKOFF_MAX are raised. The consumer comment and the README table describe the backoff as it works: it starts after a cycle that handles nothing, doubles up to BACKOFF_MAX, and resets when a cycle handles a message.

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)

  • Autocommit is limited to connections the result backend opens itself. create_pg_connection() is unchanged: pg_barrier's reconnect-retry depends on it not being in autocommit. The reaper, scheduler and leader election run multi-statement transactions on their own connections and are untouched. Connections passed in by a caller keep the caller's mode and are never dropped.
  • An owned result-backend connection is now dropped on a bare DatabaseError. If that error was only an unmapped statement error, the cost is one needless reconnect. It is not retried, the same as before this PR.
  • Pickup latency on idle queues: a message on an idle queue can wait up to 5 s before a slot claims it (previously up to 2 s). The executor queue behind Prompt Studio runs, and ide-callback, are the most visible cases. You can lower it per worker type with WORKER_PG_QUEUE_CONSUMER_BACKOFF_MAX. 5 s stays well under HEALTH_STALE_SECONDS (60 by default, 180+ in every deployed config).

Database Migrations

  • None

Env Config

  • No new variables. Defaults change for WORKER_PG_QUEUE_CONSUMER_POLL_INTERVAL (0.1 → 0.5) and WORKER_PG_QUEUE_CONSUMER_BACKOFF_MAX (2.0 → 5.0). Deployments that set them explicitly are unaffected. BACKOFF_MAX must still be at least POLL_INTERVAL, so a deployment that sets only BACKOFF_MAX below 0.5 would now fail at startup; no compose file or chart sets either variable.

Relevant Docs

  • workers/queue_backend/pg_queue/README.md consumer config table is updated.

Related Issues or PRs

  • UN-4254, found in the UN-4136 load tests. Related: UN-3445 (PG-queue migration), UN-4176 [FIX] Cut idle PG-queue polling to one autocommit claim per cycle #2349 / UN-4176 (queue-client autocommit, merged into this branch).
  • Not in this PR: the other half of the polling fix (one poller per pod, or a Redis wake-up signal on enqueue). LISTEN/NOTIFY is ruled out because it doesn't work through PgBouncer's per-transaction connection pooling (see result_backend.py).

Dependencies Versions

  • None

Notes on Testing

  • test_pg_queue_client.py, test_pg_result_backend.py, test_pg_queue_consumer.py against a local Postgres 15 with the pg_queue migrations applied: 217 passed. 8 more fail identically on main locally (a stale local schema and venv) and were excluded.
  • New tests:
    • the result backend's owned connection runs in autocommit (also after a reconnect); an injected connection keeps its mode;
    • a bare DatabaseError drops the owned connection, while a SQLSTATE-mapped statement error keeps it;
    • live DB: a backend terminated with pg_terminate_backend heals on the owned autocommit connection (the read drops the handle, the write self-heals);
    • live DB: an owned autocommit PgQueueClient send / read / set_vt / delete round trip;
    • the default BACKOFF_MAX stays below the default HEALTH_STALE_SECONDS.
  • The fixes were checked by mutation (each new test fails with its fix reverted) and by an independent review against a live database.

Screenshots

N/A

Checklist

I have read and understood the Contribution Guidelines.

🤖 Generated with Claude Code

The PG-queue client and result backend ran every single-statement
operation inside psycopg2's implicit BEGIN ... COMMIT, so each claim
(including empty polls), lease renewal, ack, enqueue and result
read/write cost three round trips. Their owned connections now run in
autocommit and send only the statement. create_pg_connection() itself
is unchanged: pg_barrier's reconnect-retry relies on it staying
non-autocommit, and the reaper, scheduler and leader election run
multi-statement transactions on their own connections. Injected
connections keep the caller's mode.

Idle consumers polled every 0.1s after a task and backed off only to
2s, so idle DB load scaled with slots x queues. Defaults are now
POLL_INTERVAL=0.5 and BACKOFF_MAX=5.0 (still env-tunable); BACKOFF_MAX
stays well under HEALTH_STALE_SECONDS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGgUm6WuXrgzYq1WhrFcX7
main's #2349 already made PgQueueClient's owned connection autocommit, so
client.py takes main's version. The consumer backoff comment is corrected
for read_any (one claim per cycle per slot, not per queue), and the
injected-connection test that duplicated main's is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ckend

Remediation of review findings F1, F2, F4, F5, F6, F7, F8:
- F1: PgResultBackend._cursor treats a bare DatabaseError as a dead
  connection, as PgQueueClient._cursor does, since rollback() sends nothing
  under autocommit; docstring corrected.
- F2: pin server-side backend termination on the result backend (live DB).
- F4, F5, F8: backoff comment and README rows no longer claim extra pollers
  shorten pickup, and say a handled message (not a claim) resets the backoff.
- F6: drop the PgQueueClient autocommit unit test that duplicated main's.
- F7: pin the default backoff cap below the default health-stale threshold.

Unstract-Remediation: remediation-deep iteration 1
…ients

Behaviour-preserving cleanup (simplify pass):
- connection.is_dead_after_autocommit_error holds the bare-DatabaseError
  rule and its rationale once; PgQueueClient._cursor and
  PgResultBackend._cursor both call it. is_connection_dead is unchanged.
- The live-DB reconnect tests share an owned-backend fixture and a
  read-back helper; the redundant TEST_DB_HOST setdefault is dropped
  (pg_conn already sets it).

Unstract-Remediation: remediation-deep iteration 2
…de does

The confirming review showed a server-terminated connection raises
OperationalError and is marked closed, so it was never only the new helper
that caught it. Reword is_dead_after_autocommit_error's docstring, the
PgQueueClient._cursor comment and both result-backend test comments to the
narrow case the helper actually adds (a bare DatabaseError on a connection
psycopg2 still reports open), and have the termination test assert the
OperationalError it really sees.

Unstract-Remediation: remediation-deep iteration 2
@johnyrahul
johnyrahul marked this pull request as ready for review October 22, 2026 10:48
@Zipstack Zipstack deleted a comment from CLAassistant Oct 22, 2026
@sonarqubecloud

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable failures were found.

Summary

This PR reduces database round trips and idle polling.

  • Result-backend connections skip transaction round trips.
  • Idle queue slots wait longer between polls.
  • Queue clients use the shared autocommit error check.

No actionable issues were found. Tests were inspected but not run during this review.

Acknowledged tradeoffs: johnyrahul explicitly noted longer idle pickup delays, startup failures for configurations that set only BACKOFF_MAX below 0.5, and a possible unnecessary reconnect after a bare DatabaseError.

Reviews (1) · Last reviewed commit: "Merge branch 'main' into ccr-679ddaa3-wv..." · Reviewed by Greptile

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
⚪ e2e-agent-kv e2e 0 0 0 1 0.1
✅ e2e-api-deployment e2e 3 0 0 0 16.1
✅ e2e-coowners e2e 1 0 0 0 1.1
✅ e2e-etl e2e 1 0 0 0 16.2
✅ e2e-login e2e 2 0 0 0 1.0
✅ e2e-prompt-studio e2e 1 0 0 0 14.6
✅ e2e-smoke e2e 2 0 0 0 0.8
✅ e2e-workflow e2e 1 0 0 0 15.9
✅ frontend unit 629 0 0 0 13.8
✅ integration-backend integration 1137 0 0 26 82.7
✅ integration-connectors integration 1 0 0 7 9.6
✅ integration-workers integration 181 0 0 1 56.3
❌ ui e2e 0 1 0 0 0.0
✅ unit-agent-kv-schema unit 143 0 0 0 1.3
✅ unit-backend unit 2016 0 0 1 39.7
✅ unit-connectors unit 82 0 0 0 8.7
✅ unit-core unit 281 0 0 0 2.2
✅ unit-filesystem unit 1 0 0 0 2.9
✅ unit-platform-service unit 18 0 0 0 2.0
✅ unit-rig unit 120 0 0 0 3.1
✅ unit-runner unit 12 0 0 0 2.1
✅ unit-sdk1 unit 878 0 0 0 30.4
✅ unit-tool-sidecar unit 8 0 0 0 1.3
✅ unit-workers unit 1531 0 0 2 108.5
TOTAL 7049 1 0 38 430.4

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • platform-key-whoami — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

@johnyrahul
johnyrahul merged commit 4139b4e into main Oct 22, 2026
14 checks passed
@johnyrahul
johnyrahul deleted the ccr-679ddaa3-wvlx3r branch October 22, 2026 11:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant