fix(server): scan only the non-acked delta when committing inbox ACKs - #2107
Draft
bestony wants to merge 4 commits into
Draft
fix(server): scan only the non-acked delta when committing inbox ACKs#2107bestony wants to merge 4 commits into
bestony wants to merge 4 commits into
Conversation
`ackThroughEntryIdForBoundAgents` selected — and, under `FOR UPDATE`, locked — every notify=true row in the `(inbox_id, chat_id)` partition up to the ACK cursor, including rows acked long ago. No index matched that query, so the planner fell back to the primary key and walked the whole `id <= cursor` range. Cost per ACK was O(chat history) and lifetime cost O(N^2), and a duplicate ACK that commits nothing paid the same price. Already-acked rows cannot affect any of the three decisions the commit makes: they never trigger a prefix gap, are never committable, and are never reset-from-pending. Excluding them in SQL is therefore an exact equivalence rather than an approximation, and it holds because `acked` is terminal — recovery resets `delivered` rows, never `acked` ones. Restricting the predicate alone is not enough: without a matching index PostgreSQL still scans the partition and only skips the locking. This change pairs the clause with a partial index over non-acked rows, which stays sized to the live in-flight window instead of to history (48 kB on a 2.7M-row table whose comparable full index is 26 MB). The clause is spelled as an inline literal on purpose. postgres-js sends named prepared statements, so PostgreSQL may switch to a generic plan after five executions, and a generic plan cannot use a bound parameter to prove a partial-index predicate — the scan would silently revert to whole-partition while constant-parameter benchmarks still looked fast. For the same reason `notify` sits in the index key rather than in the predicate, so the index depends on exactly one inlined literal. Also stops relying on `RETURNING`'s undefined row order: `committableIds` is now built in one ascending pass and the updated rows are sorted before `ackedEntryIds` is derived, which the WS in-flight bookkeeping treats as an ascending cursor list. Measured on PostgreSQL 17 against a 150k-notify-row history, timing the whole ACK transaction: incremental ACK 70.8 ms -> 0.068 ms (150001 rows scanned -> 1) duplicate ACK 78.1 ms -> 0.010 ms (150000 rows scanned -> 0) 500-row backlog 80.1 ms -> 4.34 ms (150500 rows scanned -> 500) Refs #1671
Review caught a misattribution in the previous commit's comments. The
retained `status !== "acked"` term in the gap check is unreachable — the
SQL predicate already excludes acked rows — so it is defensive redundancy,
not the thing that keeps a legacy out-of-enum status from being committed
past.
That protection comes from the SQL clause being an exclusion rather than
an allow-list. `status IN ('pending', 'delivered')` selects the same rows
today and matches the same partial index, but it would drop a legacy
`'failed'` row out of the prefix entirely, leaving the gap check blind to
it. Verified both directions: removing the JS term changes no test
outcome, while swapping the clause to the allow-list form makes the
legacy-status case return ok:true instead of prefix_gap.
Comments only; no logic change.
The comment on NOT_ACKED_PREFIX_ROW presented the partial-index fallback as observed production behavior. Driving this service through the real postgres-js path (prepare: true, default plan_cache_mode = auto) against a 60k-row history says otherwise: PostgreSQL 16 and 17 both kept planning custom for 20+ executions, and with the clause written as a bound parameter the index was still used on every one of them — a custom plan substitutes the constant, so either spelling matches. Whether a generic plan gets used at all is a cost-based, dataset-dependent decision. It has been observed on other data, and an operator can force it globally, in which case only the literal keeps the index. That is the real argument for the literal: it removes a dependency on a planner decision the code does not control, not that a regression was observed in production. Comments only; no logic change.
Verified in the driver source rather than inferred from plan_cache_mode experiments: drizzle-orm's postgres-js session issues every query through `client.unsafe(query, params)`, and postgres-js `unsafe()` hardcodes `prepare: false` with no options passed to override it. These statements are therefore unnamed — one-shot plans, always planned as custom, with parameters substituted before planning. `connectDatabase`'s implicit `prepare: true` never reaches a Drizzle query. Two earlier claims fall out as a result. Generic plans are not merely unlikely here, they are unreachable, so "PostgreSQL kept choosing custom plans" described a cost decision that is not being made. And `plan_cache_mode = force_generic_plan` cannot rescue or break this query either, since it only governs cached statements — measured on 16.14 and 17, the bound-parameter spelling keeps the index under it. The literal stays, with the justification it can actually support: it is insurance against this query becoming a named statement (a `.prepare()`, a driver change, a prepare-capable pooler), for the cost of one inlined constant. The scaling guard keeps forcing a generic plan and now says so explicitly — it models that hypothetical, not production. Also separates the exclusion-vs-allow-list argument, which does bite today, from the literal-vs-parameter one, which does not. Comments only; no logic change.
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 #1671 (PERF-008).
Problem
ackThroughEntryIdForBoundAgentsselected — and, underFOR UPDATE, locked — everynotify=truerow in the(inbox_id, chat_id)partition up to the ACK cursor, including rows acked long ago, then materialized them in JavaScript.Two things made that expensive, and only one of them is in the issue text:
id <= cursorrange withinbox_id/chat_id/notifydegraded to filters. On a 150k-row history that is 150,001 locked rows and 304,921 buffer hits per ACK:Per-ACK cost was O(history), lifetime cost O(N²), and a duplicate ACK that commits nothing paid full price.
Why excluding acked rows is exact, not approximate
prefixRowsis consumed in exactly three places, and anackedrow contributes nothing to any of them:status !== 'acked' && …deliveredIdsstatus === 'delivered'resetPendingIdsstatus === 'pending' && deliveredAt !== nullSo pushing
status <> 'acked'into SQL is a row-by-row equivalence. It holds only becauseackedis terminal:recoverUnackedForScopeandresetDeliveredForInboxesresetdeliveredrows, andpruneStaleSilentEntriesonly deletesnotify=falserows — nothing moves a row back out ofacked. That premise is written into the code comments so a future change to recovery can't quietly invalidate it.The clause is an exclusion, not an allow-list — and that is load-bearing
ck_inbox_entries_statusisNOT VALID, so a legacy row can still carry a status outside today's enum.status IN ('pending', 'delivered')would select the same rows today and match the same partial index, but it is an allow-list: it drops such a row out of the prefix entirely, so the gap check never sees it and the commit silently steps over it. Excluding onlyackedkeeps every unexpected status visible to the gap check, which is where it has to be handled. There is a regression test pinning this, and it was verified in both directions — swapping the clause to the allow-list form makes that case returnok: trueinstead ofprefix_gap.The gap check itself keeps its original
status !== "acked" && …shape, but purely as defensive redundancy: that term is unreachable now that the SQL excludes acked rows, and removing it changes no test outcome. The comments say so rather than crediting it with protection it does not provide.Why the predicate alone is not enough
Adding the clause without an index is a fake optimization — the scan volume does not change, only the locking:
So this change ships the clause and a matching partial index:
Partial on
status <> 'acked'because the non-acked set is the live in-flight window: the index stays sized to concurrent traffic rather than to history. Measured 48 kB on a 2.7M-row table whose comparable full index (idx_inbox_chat_silent) is 26 MB.The literal is load-bearing, not stylistic
A partial index applies only when the planner can prove the query implies its predicate, and a bound parameter in a cached (named) statement proves nothing — the scan then filters the whole partition.
That does not happen on today's path, and the PR does not claim it does. Drizzle issues every query through postgres-js
unsafe(), which hardcodesprepare: falseand receives no options, so these statements are unnamed: one-shot plans, always planned as custom, parameter substituted before planning. Measured through the real driver (auto_explain, 60k-row history) on PostgreSQL 16.14 and 17, the bound-parameter spelling kept usingidx_inbox_unacked_cursoron all 41 logged scans — including underplan_cache_mode = force_generic_plan, which only governs cached statements. There is also no named-statement path inpackages/server/srctoday (no.prepare(outside tests).So the literal is insurance, not a fix for an observed regression: it costs one inlined constant and stays correct if this query ever becomes a named statement — a Drizzle
.prepare(), a driver change, or a pooler that prepares. Under that hypothetical the two spellings diverge cleanly, which is what the regression guard pins:status <> 'acked'sql`… <> 'acked'`idx_inbox_unacked_cursor, no sortstatus <> $nne(status, "acked")idx_inbox_chat_silent,Rows Removed by Filter:entire historyWriting this the idiomatic Drizzle way would have made the optimization disappear in production while every benchmark that passes constants still looked fast. For the same reason
notifysits in the index key rather than in the predicate: the service passesnotifyas a parameter, so aWHERE notify = truepredicate would stop matching too. Keeping it in the key means the index depends on exactly one inlined literal — and that literal is the fix itself, so it is self-documenting rather than an easily-"normalized" pipeline detail.Verified on both server versions this repo runs, since CI and self-host differ:
.github/workflows/ci.ymland the test harness usepostgres:17, whiledocker-compose.ymlshipspostgres:16-alpine. Replaying the exact service shape (notifyand the cursor as bind parameters,statusas a literal) underforce_generic_plan:status <> 'acked'idx_inbox_unacked_cursor, all four key columns in the index conditionstatus <> $n(bind parameter)Rows Removed by Filter= the entire chat historySame conclusion on both. Which index the degraded plan falls back to is statistics-dependent, not a version property — on one PG 16.14 instance, changing only the dataset composition moved it between the primary key and
idx_inbox_chat_silent. The invariant across every variation is that the whole partition is scanned; that is the part worth relying on. A green CI run on 17 alone would not have established any of this, since a self-hosted deployment runs 16.The
chat_id IS NULLpartition compiles to a different statement and so plans separately. It uses the same index (chat_id IS NULLenters the index condition) but adds one bounded sort, because PostgreSQL does not derive path ordering through a NullTest. That sort is over the delta, not the history, andLockRowsstill sits above it, so the ascendingFOR UPDATElock order is unchanged.Also fixed:
ackedEntryIdsorderingcommittableIdswas[...deliveredIds, ...resetPendingIds]— two concatenated filters, so not ascending — and the returned order relied onUPDATE … RETURNING's undefined row order happening to come back sorted.ws-client.tsconsumesackedEntryIdsas an ascending cursor list. It is now built in one ascending pass and explicitly sorted. Pinned by a test that interleaves delivered and recovery-reset rows.Results
PostgreSQL 17, 150k notify-row history, median of 7, timing the whole ACK transaction (including the untouched silent drain):
Buffers for the incremental shape:
shared hit=304921→shared hit=4.The backlog case improves least, which is correct — those 500 rows are real work that should not be optimized away.
Write amplification (same table size, drop/create A/B, 50k-row bulk writes): roughly +10%, about 1 µs/row, on a table that already carries six indexes. Run-to-run noise on this microbenchmark is ±15%, so treat it as an order of magnitude rather than a precise figure.
Migration
Generated with
drizzle-kit generate; only the comment header was added by hand, following the existing format in0025_inbox_silent_entries.sqland0064_superb_betty_brant.sql(both index additions to this same table).CREATE INDEXholds aSHARElock that blocks writes toinbox_entries— including message fan-out — for one heap scan: 30 ms at 400k rows, 73 ms at 2.7M rows. Those are warm-cache numbers; a cold table on slower storage will be higher, since the cost is the heap scan rather than the index writes. The migration carries an operator note with theCREATE INDEX CONCURRENTLYescape hatch (Drizzle runs migrations in a transaction, soCONCURRENTLYcannot go in the file itself;IF NOT EXISTSlets a pre-created index be skipped).Tests
packages/server— 254 files / 2856 tests pass. New coverage:inbox-ack-scaling.test.tsne(...);idx_inbox_unacked_cursor— it has to be generic-plan, because under a custom plan every spelling looks fine and the guard would be decorative;inbox-delivery-indexes.test.ts— pins the index key order and the predicate spelling.inbox-ws-push.test.ts— the 11 existing ACK cases are unchanged and serve as the equivalence acceptance set; added the interleaved-ordering case and the legacy out-of-enum status case.Both new guards were verified to actually fail when the fix is reverted (
ne(...)→ literal guard fails; parameterized predicate → plan guard reports 3001 rows touched).Known boundary, recorded in the test rather than left implicit: the plan guard exercises an equivalent statement, not the query object the service builds. Together with the literal guard it pins the clause and the index contract, but a future restructuring of the service's own
WHEREcould stop matching the index without either guard noticing. Closing that would mean exporting the query builder purely for the test, which was judged not worth the coupling.Scope
Only the ACK path changed.
pollInbox,claimAndBuildForPush,claimBacklogForPushFair,collectPrecedingContext,drainPendingSilentRows,recoverUnackedForScope,resetDeliveredForInboxesandpruneStaleSilentEntriesare untouched.QA: this touches the WS/inbox path, so per
AGENTS.mdformal QA is warranted. The matching existing case ispackages/qa/cases/cross-surface/authenticated-ws-inbox-delivery.md; no new case was added because the behavior contract is unchanged.Note on unrelated CI:
packages/webhas 117 failing tests on this branch. They fail identically on a cleanorigin/main@830de274acheckout — pre-existing and unrelated to this change, which touchespackages/serveronly (andpackages/webhas no dependency path to@first-tree/server).One full server run also showed
github-entity-followandme-chat-pin-activityfailing together, andme-chat-pin-activitynever touchesinbox_entriesat all, so this change cannot reach it. Chasing it down found a pre-existing defect worth reporting separately: both tests compare a timestamp written by PostgreSQL (defaultNow()ongithub_entity_chat_mappings.bound_at/chats.activity_at) against one written by Node (new Date()ingithub-entity-follow.ts, theGREATEST(...)argument inchat.ts). Both therefore encode the same inequality — PostgreSQL's clock must not run ahead of Node's — which is why they fail as a pair rather than independently.Sampling that skew across a full suite run (892 tight samples) put it within ±1 ms, far too small to cross either assertion, so routine drift does not explain the original failure and something coarser was involved. Not reproduced since: 13 subsequent full-suite/targeted runs green on this branch, 4/4 on
origin/main. Recorded rather than silently re-run; the cross-clock comparison itself is follow-up material, not something this PR touches.Suggested follow-up (not in this PR): the same partial-index-vs-bound-parameter mismatch likely affects
idx_inbox_pending_notify, whose predicate isstatus = 'pending' AND notify = truewhilepollInboxpasses both as parameters. Worth one issue covering the class rather than fixing it opportunistically here.