UN-3445 [FIX] PG queue — indexed claim state removes high-concurrency scan-past#2159
Conversation
… scan-past The dequeue filtered `vt <= now()` per-row with vt deliberately unindexed, so claimed-but-unacked (in-flight) rows sat at the front of each priority band and were scanned past on every claim — O(in-flight) per claim. Under 150 concurrent this collapsed throughput to ~1.5 req/s (vs Celery ~4.75) with monotonically climbing latency; adding worker lanes made it worse. Replace the per-row visibility filter with an indexed `state` (ready|claimed) machine: - the claim walks a partial index containing ONLY `state='ready'` rows, so in-flight rows are absent from the traversal — claim cost is flat regardless of in-flight depth (EXPLAIN: identical cost at 0 vs 150 in-flight). - redelivery of a crashed worker's row moves from implicit vt-expiry to an explicit reaper re-arm (`state='claimed'` + expired vt -> `ready`) run every 5s tick, with a `pg_reaper_queue_rearmed_total` counter. - `vt` stays out of every index, so the frequent lease-renewal UPDATE (set_vt) stays HOT-eligible; only the once-per-message state transitions are non-HOT. Portable core PostgreSQL only (partial indexes, check constraint, column default) — no extensions; works on GCP/Azure/AWS/on-prem. Performance tuning (fillfactor/autovacuum) is intentionally NOT in the migration (a locked-down managed role could reject `ALTER TABLE SET (autovacuum_*)` and fail the migrate) — it lives as an optional per-environment ops step. Flag-gated (pg_queue_enabled); Celery path untouched; backward-compatible — the column defaults to 'ready' so old-code consumers keep working on the new schema. Validated locally against real Postgres: 336 unit/integration tests; e2e lifecycle + crash->reaper-redeliver + live-lease safety; 50-claimer / concurrent-reaper stress (no double-claim, no loss, reaper re-armed 0 live claims); read_ct poison bound intact across re-arm cycles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
muhammad-ali-e
left a comment
There was a problem hiding this comment.
Automated PR review — PG-queue indexed claim state (UN-3445)
Six-agent review (code / silent-failure / type-design / tests / comments / simplify). The core change is sound — the partial claim_idx WHERE state='ready' correctly eliminates the O(in-flight) scan-past, the raw SQL matches the Django model, and the reaper re-arm is the right explicit replacement for the implicit vt<=now() self-heal.
The findings below are inline. Top items to weigh before merge:
- Redelivery now hard-depends on the reaper leader — a crashed worker's row strands forever if the reaper isn't running/elected (previously self-healing in the claim). No dedicated failure metric makes such an outage invisible.
- Deploy-time: the
AddFieldbackfill readies in-flight rows (transient double-delivery); indexes/constraint build under table locks. - Type design: the
ready/claimedenum is scattered as ~14 bare literals across both codebases despite the provenunstract.core+ drift-test precedent used forpriority. - Tests: no wiring test that the tick calls
rearm_expired_claims/increments the metric, and no always-run unit/rollback test for the helper (integration tests are Postgres-gated).
None is a hard crash bug; nothing blocks the design.
| FROM {msg} | ||
| WHERE queue_name = %s | ||
| AND vt <= now() | ||
| AND state = 'ready' |
There was a problem hiding this comment.
[HIGH — redelivery robustness] Crash redelivery now depends solely on the reaper leader; a stranded claimed row is no longer self-healing.
Changing the claim predicate from vt <= now() to state = 'ready' removes the inline self-heal: previously any claimant re-admitted an expired row, so redelivery needed no external component. Now an expired claim stays state='claimed' (invisible to the claim) and only rearm_expired_claims flips it back — and that runs only on the leader reaper tick. The reaper "ships dark" (reaper.py module docstring; launched via python -m queue_backend.pg_queue.reaper), so in any env that runs the PG queue but doesn't run/elect the reaper, crashed-worker messages are stranded forever with no signal — a new availability regression, not just added latency.
Fix: make the coupling explicit and enforced — treat the reaper as a mandatory co-deployed process wherever dequeue is enabled, add reaper-liveness alerting for the queue path, and document the dependency here. Optionally keep a degradation path: ... WHERE state='ready' OR (state='claimed' AND vt <= now()) so self-heal survives a reaper outage. (Raised independently by code-review + silent-failure passes.)
| # (partial claimed-index scoped). Best-effort within the tick's own | ||
| # discard-conn-on-error guard. | ||
| try: | ||
| rearmed = rearm_expired_claims(self._get_sweep_conn()) |
There was a problem hiding this comment.
[MED/HIGH — observability] No dedicated failure metric/log for the re-arm sweep; a persistent redelivery outage is invisible.
Every sibling recovery job has a failure signal (barrier_recovery_failures, claim_recovery_failures, sweep_failures + per-table streak log). This path added only the success counter queue_rearmed. On failure, rearm_expired_claims rolls back and re-raises into run()'s generic handler, which just bumps the shared tick_failures — so a re-arm that fails every tick (rows stuck claimed, nothing redelivering) is indistinguishable from a barrier/scheduler failure.
Fix: add a queue_rearm_failures counter + a dedicated log line (mirror _run_sweep's table + consecutive-streak log) so a redelivery outage is traceable on its own.
Also (test gap): the autouse stub_queue_rearm fixture promises "any wiring test opts in via the returned mock," but no such test exists — nothing asserts the leader tick actually calls rearm_expired_claims, increments the metric only when non-zero (if rearmed:), or is ordered after barrier recovery and before schedule dispatch. A refactor dropping this call would leave every reaper test green.
| # died (state='claimed', vt expired) back to 'ready'. Runs EVERY tick (the | ||
| # redelivery cadence), like barrier recovery above and NOT the retention | ||
| # sweep — a crashed batch must not wait the 5-min sweep interval. Cheap | ||
| # (partial claimed-index scoped). Best-effort within the tick's own |
There was a problem hiding this comment.
[MED — error semantics] "Best-effort" is misleading here — this block re-raises and aborts the rest of the tick.
In this file "best-effort" means swallow and continue (_run_sweep, _maybe_refresh_gauges). This block does the opposite: on failure it discards the conn and re-raises (next line), skipping dispatch_due_schedules (the Beat replacement), _maybe_sweep, and gauge refresh for that tick. So a persistent re-arm fault (e.g. a statement/lock timeout over a large claimed set) silently becomes a full scheduler outage — scheduled workflows stop firing under the same generic tick_failures signal.
Fix: drop the "Best-effort" wording (it re-raises, matching barrier-recovery — not the sweeps); and consider routing re-arm through the _run_sweep best-effort pattern so a redelivery fault doesn't starve unrelated scheduler/sweep work. If gating the scheduler on re-arm is intentional, say why.
| model_name="pgqueuemessage", | ||
| name="pg_queue_message_dequeue_idx", | ||
| ), | ||
| migrations.AddField( |
There was a problem hiding this comment.
[MED — deploy safety] AddField(default="ready") backfills all pre-existing rows to ready, including in-flight (future-vt) ones → transient double-delivery on deploy.
The new claim ignores vt and keys off state only. Any message that was claimed-but-unacked under the old design (future vt) is backfilled to ready and becomes immediately re-claimable, so during a rolling deploy a still-processing message can be picked up by another consumer → one-time double processing. It's absorbed by the at-least-once/idempotent contract, but it's real deploy-time behavior.
Fix: either document that the queue must be drained / consumers quiesced before applying 0014, or backfill defensively so genuinely in-flight rows land claimed (e.g. a follow-up RunSQL setting state='claimed' WHERE vt > now()).
| sql="ALTER TABLE pg_queue_message ALTER COLUMN state SET DEFAULT 'ready'", | ||
| reverse_sql="ALTER TABLE pg_queue_message ALTER COLUMN state DROP DEFAULT", | ||
| ), | ||
| migrations.AddIndex( |
There was a problem hiding this comment.
[MED — lock during migration] Indexes are built non-concurrently and the CHECK constraint is validated under a full-table lock.
Django AddIndex emits plain CREATE INDEX (SHARE lock — blocks writes for the build) and AddConstraint emits ADD CONSTRAINT ... CHECK (ACCESS EXCLUSIVE + full-table validation); the migration is atomic, so these locks are held for its whole duration. On a queue table with a backlog this stalls producers and consumers during deploy. Usually brief (table is ack-drained), hence Medium.
Fix (if zero-downtime over a backlog matters): split into a non-atomic migration using AddIndexConcurrently, and add the constraint NOT VALID then VALIDATE CONSTRAINT separately. Otherwise note the expected lock in the runbook.
| index (state='claimed' only, ~concurrency rows) scopes the scan; redelivered | ||
| work is absorbed by the unchanged idempotency stack (claim_batch / | ||
| FileHistory / _callback_already_ran), exactly as an old-design vt-expiry re-run | ||
| was. Idempotent; rolls back on error. Runs EVERY tick (redelivery cadence), |
There was a problem hiding this comment.
[LOW — comment accuracy] "Runs EVERY tick" omits the leader gate.
rearm_expired_claims is called unconditionally in tick(), but only after the leadership check — a standby returns earlier and never re-arms. Reword to "Runs every leader tick (redelivery cadence), not the retention sweep," so someone debugging why a standby isn't re-arming isn't misled. (Also: this docstring and the inline call-site comment at ~L1089 largely duplicate the cadence/cost rationale — let the docstring own it and trim the inline to the ordering/placement point.)
| raise | ||
|
|
||
|
|
||
| def rearm_expired_claims(conn: PgConnection) -> int: |
There was a problem hiding this comment.
[HIGH — test gap] rearm_expired_claims has no unit SQL-contract or rollback/re-raise test, though every sibling sweep helper does.
sweep_expired_results and sweep_orphan_dedup each have a mock-cursor SQL-contract test and a parametrized rollback-on-error test in test_pg_reaper.py. This structurally-identical helper is in neither — its only coverage is the two integration tests, which are skipped whenever Postgres is unreachable (pg_conn fixture). So the error path (rollback then re-raise) has zero always-run coverage.
Add: test_rearm_expired_claims_sql (mock cursor rowcount=2 → asserts return value, UPDATE ... SET state='ready' WHERE state='claimed' AND vt <= now(), commit called once), and add rearm_expired_claims to the test_sweep_rolls_back_on_error parametrize list.
| # real UPDATE on their dummy connections; the SQL-contract test imports the real | ||
| # helper directly, and any wiring test opts in via the returned mock. | ||
| @pytest.fixture(autouse=True) | ||
| def stub_queue_rearm(monkeypatch): |
There was a problem hiding this comment.
[MED — test gap] This autouse fixture's docstring promises an opt-in wiring test that doesn't exist.
It stubs rearm_expired_claims in every reaper test, so nothing verifies the tick actually wires it. Add tests mirroring TestSchedulerTick/TestRetentionSweepTick: (1) leader ticks twice → stub_queue_rearm.call_count == 2; (2) standby → assert_not_called(); (3) non-zero return → queue_rearmed.inc(n) called, zero return → not called (the if rearmed: guard); (4) ordering recover → rearm → schedule. Also add a test_rearm_error_discards_owned_conn (set side_effect, pytest.raises, assert _sweep_conn is None) — parallels exist for scheduler and recovery but not re-arm.
| assert client.read(queue_name, vt_seconds=30, qty=10) == [] | ||
|
|
||
| # Reaper re-arms the expired claim → row returns to 'ready'. | ||
| assert rearm_expired_claims(pg_conn) == 1 |
There was a problem hiding this comment.
[MED — test gaps] The core state-machine invariants are only implied, not asserted.
Good behavioral tests here, but consider adding: (1) idempotency — after this re-arm returns 1, an immediate second rearm_expired_claims(pg_conn) == 0 (row is now ready); one line, documents the double-tick safety property. (2) CHECK constraint — a raw UPDATE ... SET state='bogus' should raise psycopg2.errors.CheckViolation (mirror the existing test_db_check_constraint_matches_fairness_bounds for priority); the model calls this "the backstop no writer can bypass," yet it's untested. (3) batch claim writes state — claim qty>1 and assert every returned row is state='claimed' in the DB while an unclaimed row stays ready (the write half of the machine the whole PR rests on).
Nit (simplify): the function-local from ... import rearm_expired_claims here and at L659 can hoist to the module-level imports (L24-28) if it imports cleanly.
| was. Idempotent; rolls back on error. Runs EVERY tick (redelivery cadence), | ||
| not the retention sweep — see PgReaper.tick. | ||
| """ | ||
| try: |
There was a problem hiding this comment.
[LOW — simplify] Third copy of the same try/commit/rollback envelope across the sweep helpers.
rearm_expired_claims repeats the exact try: cursor.execute(...); rowcount; commit / except: _rollback_after_sweep_failure(conn, table); raise shared with sweep_expired_results and sweep_orphan_dedup; only the SQL/params/table vary. Optional: extract _execute_sweep_dml(conn, table, sql, params=()) next to _rollback_after_sweep_failure — each helper keeps its (valuable) docstring and reduces to one line, behavior identical. Similarly the three try/except: self._discard_owned_sweep_conn(); raise blocks in tick() (barrier / re-arm / dispatch) could share a tiny _sweep_step() context manager (contextlib already imported).
…l, re-arm observability
Addresses the automated six-agent review on the claim-state change:
- Type design / magic strings (HIGH): add `QueueMessageState(StrEnum){ready,claimed}`
to unstract.core.data_models (next to WorkloadType) as the single source of
truth; source the value at every site (backend model default/constraint/index
conditions, workers claim SQL in client.py, reaper re-arm SQL) so a typo can't
silently break the state machine. Drift-guard test asserts the DB CheckConstraint
matches the enum (mirrors the fairness precedent).
- Deploy safety (MED): migration now backfills genuinely in-flight rows to
'claimed' (`UPDATE ... WHERE vt > now()`) so a rolling deploy over a non-empty
queue can't transiently double-deliver a still-processing message. No-op at first
enablement (empty table).
- Observability (MED/HIGH): add `pg_reaper_queue_rearm_failures_total` + a
dedicated error log so a persistent redelivery outage is distinguishable from a
barrier/scheduler fault (was only under the shared tick_failures).
- Comment accuracy: "best-effort" wording corrected (the block re-raises like
barrier recovery); "O(1)-amortised" -> "independent of in-flight depth"; "stays
HOT" -> "HOT-eligible (needs fillfactor<100)"; "runs EVERY tick" -> "every leader
tick"; dropped ungrounded ~16/~150 fleet numbers and the wrong `.create()` note.
- Tests: `test_rearm_expired_claims_sql` + rollback parametrize; re-arm tick wiring
(leader/standby gating, recover->rearm->schedule ordering, metric-only-when-nonzero
guard, error-discards-conn + failure-counter); client idempotency (double-tick),
state CHECK drift guard, and batch-claim-writes-state; hoisted local imports.
345 tests pass (real Postgres) + e2e re-verified (claim uses the partial index at
0 vs 150 in-flight — scan-past still gone). No stray migration (enum deconstructs
to the same literals).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — thorough review. Addressed in Addressed (code)
Deferred, with rationale
345 tests pass against real Postgres; e2e re-verified the claim uses the partial index at 0 vs 150 in-flight (scan-past still gone); no stray migration (enum deconstructs to the same literals). |
…r handler Use logging.exception() (implies exc_info=True) instead of logger.error(..., exc_info=True) inside the except block, per Sonar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| # 'claimed' so they stay invisible until their lease expires. No-op at the | ||
| # first enablement (the queue table is empty while the flag is off). | ||
| migrations.RunSQL( | ||
| sql="UPDATE pg_queue_message SET state = 'claimed' WHERE vt > now()", |
There was a problem hiding this comment.
[HIGH — test gap] The deploy-safety backfill has zero test coverage, and a regression would silently reintroduce fleet-wide double-processing.
This UPDATE … SET state='claimed' WHERE vt > now() is the one line that stops a rolling deploy over a non-empty queue from re-marking every in-flight (claimed-but-unacked, future-vt) row as ready → instant re-claim → one-time double processing across the entire in-flight set. Its correctness rests on an ordering invariant that nothing locks down: AddField first backfills all rows to ready, and only this subsequent RunSQL re-classifies the genuinely in-flight ones. A later refactor that drops/reorders this step, or inverts the vt > now() predicate, would reintroduce the exact double-processing this PR exists to prevent — and every current test would still pass (the reaper-side state machine is well covered; this migration step is exercised by nothing).
Suggested test (Django MigrationExecutor): migrate to 0013, insert one row with vt = now() + interval '1 hour' (in-flight) and one with vt = now() - interval '1 hour' (idle), migrate to 0014, then assert the future-vt row is state='claimed' and the past-vt row is state='ready'. That is the only test that catches a regressed or removed backfill. (No migration-executor harness exists under backend/ yet, so this also establishes the pattern.)
|
| Filename | Overview |
|---|---|
| backend/pg_queue/migrations/0014_remove_pgqueuemessage_pg_queue_message_dequeue_idx_and_more.py | Adds state column, partial indexes, and CheckConstraint; deploy-safety backfill re-classifies in-flight rows correctly; operation ordering is sound (AddField → backfill → AddIndex → AddConstraint). |
| workers/queue_backend/pg_queue/client.py | Claim SQL updated from vt<=now() to state='ready' partial-index predicate; state literals sourced from shared enum; set_vt and module docstrings updated to reflect the new reaper-based redelivery model. |
| workers/queue_backend/pg_queue/reaper.py | New rearm_expired_claims function is correct, idempotent, and bounded by the claimed partial index; wired into tick() after barrier recovery with matching error posture (re-raise + conn discard) and dedicated metrics counters. |
| backend/pg_queue/models.py | State field, CheckConstraint, and two partial indexes added to match the migration; enum aliases imported from the shared single source of truth. |
| unstract/core/src/unstract/core/data_models.py | New QueueMessageState StrEnum (READY/CLAIMED) is the cross-codebase single source of truth; well-documented with drift-test reference. |
| workers/queue_backend/pg_queue/metrics.py | Two new Prometheus counters (queue_rearmed_total, queue_rearm_failures_total) follow the existing counter pattern and correctly distinguish redelivery outages from barrier/scheduler faults. |
| workers/tests/test_pg_queue_client.py | New integration tests cover redelivery-via-reaper, live-lease non-re-arm, state check-constraint drift, migration backfill SQL, and batch claim state writes. |
| workers/tests/test_pg_reaper.py | New TestQueueRearmTick class covers leader/standby gating, tick ordering, metric guard, and error posture; autouse stub correctly patches module-level name. |
| backend/pg_queue/tests/test_migration_0014_backfill.py | Structural regression guard for the deploy-safety backfill: verifies the RunSQL is present, has the correct predicate, and runs after AddField. |
| workers/tests/test_pg_schema_drift.py | Adds state to the worker schema contract for pg_queue_message; minimal and correct. |
Reviews (2): Last reviewed commit: "UN-3445 [FIX] address review round 2 — b..." | Re-trigger Greptile
… docstrings - [HIGH — test gap] The deploy-safety in-flight backfill had zero coverage. Add two complementary guards (backend has no pytest-django/migration-executor harness, so a full-migration run isn't cheap): a STRUCTURAL test (backend/pg_queue/tests/test_migration_0014_backfill.py) pinning the backfill RunSQL present, with the `state='claimed' WHERE vt > now()` predicate, ordered AFTER AddField — catches drop/reorder/invert; and a BEHAVIOURAL test (test_pg_queue_client::test_inflight_backfill_sql_classifies_by_vt) proving on real Postgres that future-vt rows land 'claimed' and past-vt rows stay 'ready'. - [Greptile] Fixed two stale client.py docstrings that still described crash redelivery as implicit vt-expiry reappearance: the module docstring and set_vt now describe the reaper-mediated re-arm (state='claimed' + expired lease -> reaper -> 'ready') and note set_vt's dual role (lease renewal + poison re-park). 160 workers tests + the new backend structural test pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Round 2 addressed in Toolkit — [HIGH] deploy-safety backfill had zero test coverage
Together they cover both "the step is still there and correctly ordered" and "the predicate actually selects the in-flight set." Greptile — stale 160 workers tests + the new backend structural test pass. Greptile's other observations (correct claim SQL, no reaper-vs-ack race, concurrent re-arm safety) matched the design intent — no change needed there. |
|
c9ea34d
into
feat/UN-3445-pg-queue-integration



Problem
The PG-queue dequeue filtered
vt <= now()per-row, withvtdeliberately left out of the index. So claimed-but-unacked (in-flight) rows — which carry a futurevt— sat at the front of each priority band and were scanned past on every claim: cost was O(in-flight depth).Load-testing exposed the consequence. At 150 concurrent, PG throughput collapsed to ~1.5 req/s (vs Celery ~4.75) with monotonically climbing latency — and adding worker lanes made it worse (more in-flight → deeper scan-past). At ≤50 concurrent it was fine (small in-flight depth), which is why it only showed under real load.
Fix
Replace the per-row visibility filter with an indexed
state(ready|claimed) machine:state='ready'rows — in-flight rows are absent from the traversal, so there is nothing to scan past. Claim cost is flat regardless of in-flight depth (EXPLAINshows identical cost at 0 vs 150 in-flight).vt-expiry (in the claim) to an explicit reaper re-arm (state='claimed'+ expiredvt→ready), run every 5s tick (not the 5-min retention sweep), with apg_reaper_queue_rearmed_totalcounter.vtstays out of every index, so the frequent lease-renewalUPDATE(set_vt) stays HOT-eligible; only the once-per-message state transitions (claim / ack / rare re-arm) are non-HOT.Portability
Pure core PostgreSQL — partial indexes, check constraint, column default. No extensions; identical on GCP Cloud SQL / Azure / AWS RDS+Aurora / on-prem. Performance tuning (
fillfactor+ aggressive autovacuum) is intentionally kept out of the migration — a locked-down managed role could rejectALTER TABLE SET (autovacuum_*)and fail the whole migrate, and tuning is per-environment anyway. It lives as an optional ops step (see below).Safety
pg_queue_enabled); the Celery path is untouched.'ready'(DB-levelSET DEFAULT), so old-code consumers keep working on the new schema — verified live (the existing old-image PG workers stayed healthy against the migrated table).claim_batch/ FileHistory /_callback_already_ran), exactly as an old-designvt-expiry re-run was.Design provenance
Chosen via a Red/Blue debate + code fact-check. The debate's showstopper (reaper re-arm → double customer write/billing) was fact-checked invalid (the guards above stop it; the reaper only re-arms the same
vt-expired set the old claim already redelivered). The real residual — redelivery now depends on the reaper (already the crash backstop) — is covered by the 5s-tick cadence + the re-arm counter (alert onoldest_claimed_expired_agerecommended). The two-table split (more WAL) and admission-orchestrator (bounds depth, doesn't cure it) were rejected.Validation (local, against real Postgres)
EXPLAIN ANALYZE: claim uses the partial index with identical cost at in-flight depth 0 and 150.read_ctpoison bound intact across re-arm cycles.Follow-ups (not in this PR)
ALTER TABLE pg_queue_message SET (fillfactor=90, autovacuum_vacuum_scale_factor=0.02, …)if a soak shows partial-index bloat.max_connections-bound) can't cover.set_vtowner-guard gap (a claim-token/owner_id) — separate ticket; neither introduced nor fixed here.🤖 Generated with Claude Code