perf(heartbeat): bound the dispatch head scan independently of queue depth (BLO-20736) - #1056
Closed
allyblockcast[bot] wants to merge 1 commit into
Closed
perf(heartbeat): bound the dispatch head scan independently of queue depth (BLO-20736)#1056allyblockcast[bot] wants to merge 1 commit into
allyblockcast[bot] wants to merge 1 commit into
Conversation
…depth (BLO-20736) The dispatcher's keyset scan filters agent_id = $1 AND status = 'queued' and orders by (created_at, id) with LIMIT 200. PostgreSQL estimates the two equality qualifiers independently and multiplies their selectivities, but they are almost perfectly correlated: a backlogged agent's rows are overwhelmingly queued, an idle agent has none. Measured on a 200k-row interleaved fixture the estimate is 250x low at queue depth 1000 (rows=4 vs 1000) and 42x low at depth 5000 (rows=118 vs 5000). On an estimate that small the planner stops treating LIMIT as a reason to preserve index order and picks Bitmap Heap Scan + top-N Sort. A sort cannot emit until it has consumed its whole input, so the dispatcher read and sorted the agent's ENTIRE queue to return 200 rows, under the strict per-agent start lock, getting worse as the backlog deepened. Read the page in two phases instead. Phase 1 projects ONLY (created_at, id) -- both live in heartbeat_runs_agent_dispatch_idx behind the (agent_id, status) prefix, so the page comes from an ordered Index Only Scan with no heap access that stops after SCAN_LIMIT entries. Phase 2 hydrates exactly that page by primary key. Measured, at both depths: 1000 -> 200 and 5000 -> 200 rows actually read. The projection is what makes the plan insensitive to the estimate rather than dependent on fixing it: with no heap fetches to pay for, the ordered path's LIMIT-scaled cost stays ~10 whether the planner believes 118 rows or 5167, while the bitmap alternative must still materialize the whole match set before its sort emits. The plan stays correct at a still-42x wrong estimate. Deliberately NOT fixed with extended statistics: CREATE STATISTICS ON (agent_id, status) does correct the estimate and then makes the plan worse -- the planner switches to a BitmapAnd of two other indexes and still sorts. Measured at both depths, and inconsistent between them. Phase 2 takes the id list as its only SQL predicate and re-checks status in JS. Adding AND status = 'queued' there makes the dispatch index attractive again and PostgreSQL drives the fetch from it instead of the primary key, reintroducing the unbounded shape phase 1 exists to remove. Callers advance the cursor from the probe rather than from the hydrated rows, so a page thinned by a row leaving the queue between the two statements is not mistaken for the end of the backlog. The new plan test asserts both properties at two depths against one shared ceiling, on a fixture whose queued rows are interleaved one per heap page rather than packed at the tail -- against tight clustering bitmap+sort genuinely is the better plan, so the packed fixture would be asserting the wrong thing. Co-Authored-By: Claude <noreply@anthropic.com>
Author
Author
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Important Issues (2)
Suggestions (1)
Strengths
Recommended Action
|
13 tasks
|
Superseded by #1069, which addresses the deferred-ID probe bound review finding, adds the deferred backlog plan/liveness case, and is opened from a human-authored branch for Ally review. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
Linked Issues or Issue Description
Refs BLO-20736 — no GitHub issue; the work is tracked in Paperclip.
Split out of BLO-20396 / #912, where the underestimate was found while deepening the query-plan fixture. Filed separately because the fix needed its own cost-model evaluation rather than riding along.
Depends on #963 (merged) for migration 0208 and the plan-test fixture this extends.
What Changed
readQueuedDispatchPage(new,server/src/services/heartbeat.ts) — reads one keyset page in two phases.(created_at::text, id). Both live inheartbeat_runs_agent_dispatch_idxbehind the(agent_id, status)prefix, so the page comes entirely from the index — an orderedIndex Only Scan,Heap Fetches: 0, that stops afterLIMITentries.runswould treat a page thinned by phase 2 as the end of the queue and strand the rest of the backlog.DispatchRuntype.packages/db/src/heartbeat-dispatch-query-plan.test.tsgains "bounds the dispatch head scan independently of queue depth": two depths (1000, 5000) against one sharedHEAD_ABSOLUTE_BOUND, on an interleaved + VACUUMed fixture.Why the projection is the fix, rather than better statistics
With zero heap fetches the ordered path's LIMIT-scaled cost stays ~10 whether the planner believes 117 rows or 5000, while the bitmap alternative must still materialize the whole match set before its sort can emit. The plan is correct despite the bad estimate rather than needing the estimate fixed — which is the second branch of the issue's AC #3, and sturdier than the first.
Two things I tried and rejected, both measured:
(agent_id, status)does fix the estimate (rows=1107 vs 1000 actual) and then makes the plan worse — the planner switches to aBitmapAndof two other indexes and still sorts.AND status = 'queued'to the phase-2 fetch makes the dispatch index look attractive again, so PostgreSQL drives the hydrate from it instead of the primary key and the unbounded shape returns. The dispatcher re-checks status in JS instead.Deliberately not converted: the recovery lane
The recovery lane has the same unbounded shape but filters
context_snapshot ->> 'source', which is JSONB and not in the dispatch index. Phase 1 is only cheap because it is index-covered; applying this there would make it page through all queued rows hunting for recovery rows — strictly worse. It has its own absolute bound and its own test.Verification
pnpm vitest run packages/db/src/heartbeat-dispatch-query-plan.test.ts— 3/3 passing, embedded Postgres, on this branch rebased ontomaster. Full monorepotypecheckgreen.The assertions are on
rowsInspected, which counts rows discarded by filters, so a plan that reads the queue and sorts it cannot pass by reporting its 200 output rows. Both depths share one ceiling — deriving it per-depth would make the claim vacuous.Before grows 4.3x with the backlog; after is flat, and stays flat while the estimate is still 43x wrong.
Plan report artifact —
EXPLAIN (ANALYZE, BUFFERS), before/after, both depthsReproduce with
BLO20396_PLAN_REPORT=/path/to/report. UUID lists collapsed for readability; everything else verbatim.