perf(inbox): batch preceding-context assembly and bound-agent route validation - #2045
Draft
bestony wants to merge 5 commits into
Draft
perf(inbox): batch preceding-context assembly and bound-agent route validation#2045bestony wants to merge 5 commits into
bestony wants to merge 5 commits into
Conversation
Preceding-context assembly issued one query per claimed trigger plus one per chat, all inside the delivery transaction, so a 50-entry drain held its locks across 50+ sequential round-trips. Each trigger's lower bound is statically derivable — within a chat it is the previous trigger's id, and only the first trigger has to look outside the batch — so nothing depends on a previous iteration's result. Collapse the fan-out into one statement: `jsonb_to_recordset` for the batch, `lag()` for the in-batch bound, a short-circuiting `COALESCE` scalar subquery for the out-of-batch bound, and `CROSS JOIN LATERAL` for each trigger's window. The lock stays inside the LATERAL subquery. PostgreSQL rejects `FOR UPDATE` at any query level that also carries a window function, and on the nullable side of an outer join, so this is the only shape that keeps `FOR UPDATE OF e SKIP LOCKED` intact. Per-trigger ranges are disjoint by construction, so one statement locks exactly the rows the loop would have. Extend `idx_inbox_chat_silent` with a trailing `id`. The window bounds are correlated values inside the LATERAL, so the planner cannot estimate their width and would scan every silent row in the chat before discarding almost all of them. With `id` indexed the range collapses into the index scan. The new shape is a superset of the old one, so it replaces rather than adds an index. Ordering is unchanged: the LATERAL keeps `messages.created_at DESC` + LIMIT so the rows nearest the trigger survive the cap, and the outer query orders ascending for the prompt. The outer ORDER BY is required — a subquery's ordering is not carried to the outer level. Timestamps are rendered to ISO 8601 in SQL because a raw statement returns PostgreSQL's own timestamp text rather than a Date.
Every `inbox:ack` and every heartbeat walked the socket's bound agents and issued one single-row `agents` lookup each. Heartbeat did it twice, so a client holding N bindings cost 2N queries per heartbeat, and a client has no hard agent-count cap. Add `ensureAgentsStillRoutedHere`, which resolves the whole set in one query and applies the unchanged per-agent judgement — including the runtime-switch claim branch that parks a binding instead of dropping it. The single-agent helper now delegates to it so there is exactly one implementation of the judgement and its drop side effects. Candidates are snapshotted before the query, so the outcome no longer depends on the order in which `dropLocalAgentBinding` mutates `boundAgents`. Drop the heartbeat's second route check entirely. `restoredAgentIds` is already a subset of the ids the first check returned — it is filtered by an `EXISTS` on the same client and active status — and the repair path re-validates anyway: `maybeRepairInboxBacklog` gates on the in-memory route, then `drainBacklogForAgent` opens with its own `ensureAgentStillRoutedHere`, a strictly later and therefore fresher authoritative check. The only residual effect is that an agent that just moved away can still consume one repair-throttle slot, which is meaningless for a binding that is going away. The fake-db harness now supplies `agents.uuid`, which batched validation needs to map each returned row back to its agent; a single-row lookup left that mapping implicit.
Replaces the composite index this branch first proposed. Appending `id` to `(inbox_id, chat_id, notify, status)` fixed the scan but defeated B-tree deduplication: the four-column form compresses near-duplicate keys to ~7 bytes per row, while a key ending in the unique `id` is ~49 bytes per row. Measured on 240k rows that is 1.7 MB against 11 MB. Moving `status`/`notify` from the key into a predicate keeps `id` indexed without the size cost — the pair totals ~2.6 MB and is slightly faster than the composite. It also bounds growth: `inbox_entries` is append-only, and the silent index now only covers rows still pending rather than all history. The notify-cursor index is deliberately not filtered on `status`. Both its callers must see notify rows in any state — an already-acked trigger still closes a preceding-context window, and ACK-through walks acked rows to prove its prefix has no gap. Verified the other `(inbox_id, chat_id)` query shapes in this service do not regress: the chat-scoped claim and the claim-prefix scan are unchanged at the median, while ACK-through's prefix walk and its silent drain get ~4x faster now that `id` is in the index. The migration follows the runbook established by 0025 and 0064: `IF NOT EXISTS` guards plus an operator note carrying the out-of-transaction `CONCURRENTLY` form. The new indexes take new names so that runbook can create them before dropping the old one — reusing the old name would leave a window with the silent-row lookup unindexed. `DROP INDEX` takes ACCESS EXCLUSIVE, and this table sits in the delivery hot path. Assert the index shapes in inbox-delivery-indexes.test.ts so `id` in the key and the absence of a status filter on the notify cursor are contracts rather than incidental.
Counting only raw statements would still read as 1 if a per-trigger `select` were reintroduced alongside the batched one. Count both kinds and compare two drains of different sizes, so the assertion states what it means: the query count does not grow with the number of triggers. Also document why the route check reads its local binding after the query rather than before. A rebind updates the row and the binding together, so the other order compares a pre-query binding against a post-query row and drops an agent that had just rebound successfully.
…rrowed The previous wording called the exposure "a loop iteration", which reads as microseconds. The row is a snapshot from when Postgres executed the SELECT, so reading the binding after the query exposes result delivery plus an event loop turn — the other half of the same round-trip, not a smaller one. Reading the binding after the query is still the right order, but the comment should not imply a guarantee that is not there: the socket's message handler is not awaited by the emitter and bind frames are not serialised against heartbeat frames, so the interleaving is reachable either way.
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.
Closes #1724 (PERF-061).
Inbox context assembly and bound-agent validation both fanned out into sequential single-row queries. This replaces both with set-based statements.
What was slow
Preceding context (
services/inbox.ts) issued one query per claimed trigger plus one per chat, all inside the delivery transaction. A 50-entry drain held its row locks across 50+ sequential round-trips.Route validation (
api/agent/ws-client.ts) looked up oneagentsrow per bound agent on everyinbox:ack, and did it twice per heartbeat. A client holding N bindings cost 2N queries per heartbeat, and there is no hard per-client agent cap — so the cost scaled with both fleet size and heartbeat frequency.What changed
One statement for preceding context. Each trigger's lower bound is statically derivable: within a chat it is the previous trigger's id, and only the first trigger has to look outside the batch. Nothing depends on a previous iteration's result, so the whole loop collapses into
jsonb_to_recordset(batch input, matching the idiom already inclaimBacklogForPushFair) →lag()(in-batch bound) → short-circuitingCOALESCEscalar subquery (out-of-batch bound) →CROSS JOIN LATERAL(per-trigger window).The lock stays inside the LATERAL. PostgreSQL rejects
FOR UPDATEat any query level that also carries a window function, and on the nullable side of an outer join — this is the only shape that keepsFOR UPDATE OF e SKIP LOCKEDintact. Per-trigger ranges(trigger[i-1].id, trigger[i].id)are disjoint by construction, so one statement locks exactly the rows the loop would have.One query for route validation.
ensureAgentsStillRoutedHereresolves the whole set at once and applies the unchanged per-agent judgement, including the runtime-switch claim branch that parks a binding rather than dropping it. The single-agent helper delegates to it, so there is exactly one implementation of the judgement and its drop side effects.The heartbeat's second route check is removed, not batched.
restoredAgentIdsis already a subset of what the first check returned (it is filtered by anEXISTSon the same client + active status), and the repair path re-validates anyway:maybeRepairInboxBackloggates on the in-memory route, thendrainBacklogForAgentopens with its ownensureAgentStillRoutedHere— a strictly later, and therefore fresher, authoritative check. Removing it closes no race. The one residual effect: an agent that just moved away can still consume a repair-throttle slot, which is meaningless for a binding that is going away.Why the index changed
This is the part worth reviewing closely. Batching alone made the query slower, and the measurements are what drove the index change.
idx_inbox_chat_silentdid not includeid. In the old loop the window bounds were constants in each statement, so the planner walkedinbox_entries_pkeyover a narrow id range. Inside a LATERAL those same bounds are correlated values — the planner cannot estimate their width and falls back to scanning every silent row in the chat before discarding almost all of them:With
idreachable by the scan the range collapses into it (rows=26,Heap Blocks: exact=909).Partial, not a wider composite
The obvious fix — appending
idto the existing four-column key — works but is expensive, because it defeats B-tree deduplication. The four-column key has only a few hundred distinct combinations per inbox, so near-duplicate keys compress; a key ending in the uniqueidmakes every key distinct and the compression disappears:(inbox_id, chat_id, notify, status)(inbox_id, chat_id, notify, status, id)(inbox_id, chat_id, id)(7.6 bytes/row is below a bare index tuple, which is what confirms deduplication is active.)
So
status/notifymove from the key into predicates instead. That also bounds growth:inbox_entriesis append-only, and the silent index now covers only rows still pending rather than all history. The notify-cursor index is deliberately not filtered onstatus— an already-acked trigger still closes a preceding-context window, and ACK-through has to walk acked rows to prove its prefix has no gap.Measured effect
PG17, 240k
inbox_entries, 40k in the hot inbox across 60 chats, production-like 80% acked mix, one 50-trigger drain. DB-side only — no round-trips, which is the measurement most favourable to the old loop. Medians of repeated runs:+idThe other
(inbox_id, chat_id)shapes in this service were checked for regression:Adding one round-trip back in (~1.5 ms on managed PG), lock-hold time for a 50-entry drain goes from ~87 ms to ~4 ms.
Migration safety
inbox_entriessits in the delivery hot path, and this migration drops an index —DROP INDEXtakesACCESS EXCLUSIVE, a plainCREATE INDEXtakesSHARE. The migration file is hand-annotated with an operator runbook andIF NOT EXISTS/IF EXISTSguards, following the pattern in0025_inbox_silent_entries.sql,0026,0064, and0065(drizzle wraps migrations in a transaction, soCONCURRENTLYcannot appear in the file itself).The guards change re-execution behaviour only, not the resulting schema. Re-running
drizzle-kit generateon this branch reports "No schema changes, nothing to migrate" and leaves the working tree clean, so the snapshot still matchessrc/db/schema/inbox-entries.ts— the invariant behind "never hand-edit migrations" holds. The runbook itself was executed verbatim against PG17 to confirm it works: bothCREATE INDEX CONCURRENTLY IF NOT EXISTSstatements succeed,indisvalidis true for each, andDROP INDEX CONCURRENTLY IF EXISTSthen removes the old index.The new indexes take new names specifically so that runbook can create them before dropping the old one. Reusing
idx_inbox_chat_silentwould force a window where the silent-row lookup has no index behind it.Behaviour held fixed
ORDER BY messages.created_at DESC+LIMITstays inside the LATERAL, so the rows nearest each trigger survive the cap.ORDER BYonly decides which rowsLIMITkeeps; it is not carried outward. The outer query orders on the raw timestamp so the prompt block reads oldest → newest under any plan.messages.created_at, notinbox_entries.created_at—addParticipant's backfill writes its rows in oneINSERT VALUES (...)sharing a singlestatement_timestamp(). The 24h floor still comparesinbox_entries.created_at; that asymmetry predates this change and is preserved.interval, so the 24h boundary is bit-identical to before.2026-07-20 00:01:00.123456+00) rather than aDate.to_char(... AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')matchesDate#toISOString()byte for byte — checked against the boundaries that could diverge:to_chartoISOString().123456+002026-07-20T00:01:00.123Z.999999+00...00.999Z.0005+00(sub-ms truncation)...00.000Z...00.000Z.5+00(single digit padded)...00.500Z23:59:59.9999(carry boundary)...59.999Z0999(padded)0999-01-02T03:04:05.060Z12:00:00.123456+08(non-UTC input)2026-07-20T04:00:00.123ZThe explicit
AT TIME ZONE 'UTC'is what keeps this independent of session timezone.Verification
pnpm check·pnpm typecheck·pnpm test— 247 files / 2700 tests, all passing (testcontainers PG17, migration applied).New
inbox-preceding-context-batch.test.tscovers what batching can break while single-trigger tests stay green:executeat exactly 1 — the O(N)→O(1) claim as an assertion rather than a comment. Both statement kinds are counted, so reintroducing a per-triggerselectnext to the batched one would still fail itFOR UPDATEby another transaction is skipped, not blocked oninbox-delivery-indexes.test.tsgains an assertion on the new index shapes, soidin the key and the absent status filter on the notify cursor are contracts rather than incidental.The cap behaviour (keep the rows nearest the trigger when candidates exceed
PRECEDING_CONTEXT_MAX_ENTRIES) is already pinned by the existinginbox-ws-push.test.tscase and stays green.One test-harness change:
ws-client-branch-fake.test.ts'sactiveAgentRow()now suppliesuuid. Batched validation needs it to map each returned row back to its agent; a single-row lookup left that mapping implicit. The realagentstable has always returned the column.QA
Touches the WS/inbox path, so formal QA is warranted.
packages/qa/cases/cross-surface/authenticated-ws-inbox-delivery.mdalready covers this loop end-to-end; no new case needed.