perf(search): restore index use for bank-scoped ANN and unit-date lookups - #24
Merged
Conversation
…kups Two hot query shapes had each lost their index in production (measured 2026-09-03, ~1.05M rows in memory_units), together burning 5-6 CPU cores for minutes at a time. 1. Bank-scoped ANN search fell off the per-bank partial HNSW indexes. Those indexes are partial on (fact_type, bank_id). fact_type is inlined as a literal, but bank_id is bind $2, so under a *generic* plan the planner cannot prove `bank_id = $2` implies `bank_id = '<literal>'` and every per-bank index drops out. asyncpg prepares and caches statements per connection, so a hot search connection crosses Postgres' generic-plan threshold (5 executions) and keeps the generic plan -- degrading the ANN arms to a sequential scan over ~1M rows / the unused global embedding index. Observed: 131s, 72s, 63s, 22s per search. Fixed by running the three bank-scoped ANN executions under `SET LOCAL plan_cache_mode = force_custom_plan`, so Postgres re-plans with the actual parameter values and the partial-index predicate becomes provable again. bank_id is deliberately left as a bind: it is free-form tenant text (paperclip::<uuid>::<uuid>), not a UUID or a closed enum, so it cannot be validated into a safe literal and interpolating it would put caller-controlled text into SQL. SET LOCAL keeps the scope to the search rather than the database role. The helper is Postgres-gated; Oracle re-optimizes on bind values on its own. 2. fetch_unit_dates used `WHERE id::text = ANY($1)`, casting the indexed column and so discarding the uuid primary-key index: a seq scan that removed 1,052,293 rows by filter to return 10, at 5.4s per call. Fixed by casting the parameter instead. This is a port of upstream's own fix, vectorize-io/hindsight@b475f5cca (vectorize-io#2968, first released in v0.8.6), which filters to canonical UUID text before the cast. A bare `id = ANY($1::uuid[])` would fix the index but change behavior -- the text comparison silently ignored malformed, uppercase, braced and unhyphenated ids, while a uuid[] cast raises on them. Adds tests/test_partial_index_plans.py: asserts the SET LOCAL is issued inside a transaction and before the query it plans, that non-Postgres backends are untouched, and that fetch_unit_dates casts the parameter and keeps the malformed-id guard. Supersedes two hand-applied DB mitigations, now removable: the `plan_cache_mode` setting on the hindsight role, and the idx_memory_units_id_text expression index. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
kkroo
force-pushed
the
fix/partial-index-plans
branch
from
September 3, 2026 11:41
9a4a04f to
f5107b3
Compare
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.
The incident
2026-09-03 08:30-10:35Z, omar vault seat-harvest container. The Anthropic fleet went dark:
80 successes against 547 client errors per 10 min. An operator fixed it by hand and parked
the loop (
mode = offin the switch file).During a fleet-wide five-hour cap wave (every measured bench alias at
5h=100with weeklyheadroom intact)
seat_float.decide()found no measured target, so its fallback promotedUNMEASURED rows (
no_usage_evidence/carry_too_old) — and the defaultunmeasured_donor_scope = UNMEASURED_ANY_EXHAUSTEDlet a merely 5h-capped seat pay for them.What the harvest log records (swaps 371-379), assign leg tagged
[seat_refused]-less:bot41@blockcast.net,bot42@blockcast.net,bot43@blockcast.net, thenbot41/42/43@blockcast.network— the accounts that were CARRYING the fleet; their liverows later read
5h=32/80/12,7d=3/8/1— thenbot10,bot22,bot36, andbot44@blockcast.netminutes after an operator seated it (no captured credential →no_proven_credential, an unprovisioned donor: that part is by design).ally@blockcast.netfour times andbot10/bot12repeatedly (seated → freed nextround as exhausted → re-seated as unmeasured),
bot6@blockcast.netandbot9@blockcast.netinto
team_standardseats although their carry saidlast_util_7d=100(aged pastCARRY_MAX_AGE_SECS, so_carry_util_7dreturnedNone, the 100 was ignored →carry_too_old→ unmeasured → promoted), andbot11-team@nwhose last reading — four hoursold — was
5h=100.Net effect: every seat held a capped account while the accounts with headroom sat unseated; only
one seat (
bot44@blockcast.network) served.A second observation at 11:30Z: the control plane's utilisation-equalising selector burns a
Team seat's whole 5h window in ~45 min, so every seated alias caps together, every bench alias
reads spent, and the loop holds while seats could be pre-positioned.
The rules, all in the pure
decide()1. An aged carry is still evidence of EXHAUSTION even when it is too old to prove HEADROOM.
seat_float.py: _carry_exhaustion_refusal—last_util_7d >= EXHAUSTED_AT(any age) withreset_7d_atabsent or ahead →target_carry_exhausted;last_util_5h >= 100(any age) with aknown future
reset_5h_at→target_carry_five_hour_spent. Nothing clearslast_util_*when a member is unseated (the vault carries it outside the seat gate, core vectorize-io#1682), so a carried
100 with no reset behind it is a live cap. The one-way "an unseated account only decays" argument
licenses trusting an old low number, never ignoring an old high one.
_carry_util_7d/_carry_util_5hkeep their headroom semantics untouched; a live reading of a window stillwins over its carry; and the two established refusals
target_already_exhausted(usable carry atthe ceiling) and
target_five_hour_spent(reset-proved carry under the floor) keep their names —the new ones cover only what those two had to discard.
2. Under
UNMEASURED_ANY_EXHAUSTED, an unmeasured target may displace a merely-5h-capped donoronly with a positive serving signal.
credential_layer == "seat_refused"(the provider answeredon the token and refused only the seat — the one class where a seat becomes serving capacity
immediately) and it passed rule 1. Every other unmeasured row is priced as
UNMEASURED_WORTHLESS_DONOR— it may still fill a free seat or displace a_worthless_seatdonor, both of which cost nothing — and is reported
unmeasured_needs_worthless_donor. Scopeconstant names and the CLI flag are unchanged, and the cold-start/dark-fleet deadlock the arm
exists to break stays broken.
3. Anti-thrash. New
decide(recently_released=...), default empty — so every existing callerkeeps today's behaviour exactly. A member the ledger freed inside the release grace may not be
promoted again as an unmeasured target (
recently_released); a measured target with provenheadroom is unaffected, because that is real capacity. Supporting wiring:
SeatLedger.recent_releases/recent_release_times(new, symmetric withrecent_assignments,reading the FREED leg and skipping fills / external records that free nothing);
harvest_released_aliasesexpands the ledger's one alias to the whole member (thesibling-alias bypass the donor-side grace was fixed for on 2026-08-28);
HARVEST_RELEASE_GRACE_SECS = HARVEST_GRACE_MAX_SECS(the ceiling, not the floor — a freed memberdiscloses no utilisation, so no evidence can lift this grace early); read in
_harvestfrom thesame ledger each invocation, so the loop driver's back-to-back changes see each other's frees.
4. Every new refusal is in the operator summary with a distinct reason string, documented in
the vocabulary block at the top of
seat_float.py:target_carry_exhausted,target_carry_five_hour_spent,unmeasured_needs_worthless_donor,recently_released,target_reset_imminent,reset_imminent_donor_resets_sooner._harvestalso printswithheld, freed recently : N (...)and the pre-position window, so a shrunk candidate pool isnever silent.
5. Pre-positioning onto an imminent recovery (the 11:30Z observation). A bench row with fresh
telemetry, a proven credential, weekly headroom, live
util_5h >= 100and areset_5h_atinsideRESET_IMMINENT_TARGET_SECS(default 40 min — the upper bound of observed seat propagation;env
PENSTOCK_HARVEST_RESET_IMMINENT_TARGET_S,0disables the lane) is an eligible 5h-passtarget (
target_reset_imminent) instead of being refused. It ranks behind everymeasured-headroom target and behind
stale_rejected, is ordered by soonest reset (the weeklysort is skipped for that pool), and may only displace a donor recovering more than one window
later than itself — never one recovering sooner, never one that still has 5h headroom, never one
on an unknown clock (
reset_imminent_donor_resets_sooner). A_worthless_seatdonor is exemptfrom the comparison; a vacancy needs no donor at all.
One vocabulary note for rule 5's (b) case: a live
util_5h = 100bench row is refused by_bench_exhaustedastarget_already_exhausted— that is the gate this lane hooks, and itpredates this PR.
target_five_hour_spentnames the rows below the ceiling but under the floor(99, or a reset-proved carry). Both are asserted, so the distinction cannot drift.
Connection shapes
retrieve_semantic_bm25_combined/retrieve_temporal_combinedare called with either aDatabaseConnectionwrapper or a raw asyncpg connection (acquire_with_retryaccepts aDatabaseBackendor a rawasyncpg.Pool, and the recall path still passes the latter). The helper therefore:getattr(conn, "backend_type", "postgresql")rather than the attribute directly — a raw asyncpg connection has nobackend_typeand is Postgres by definition;transaction()— the wrapper yields the connection, but raw asyncpg yields aTransaction, which has no query methods — and runs its statements onconn.Both shapes are covered by tests; an earlier revision of this branch got both wrong and
tests/test_hnsw_indexes.py::test_retrieve_semantic_bm25_grouped_by_fact_typecaught it.Tests
Added to
scripts/seat-float.test.py(a dedicated THE 2026-09-03 INCIDENT section replaying allfour shapes with the incident's own numbers, plus a RULE 5 section),
scripts/seat-ledger.test.py(
recent_releaseson the FREED leg, org scoping, ageing, fills release nothing) andscripts/claude-org.test.py(alias→member expansion, theseat_refusedspelling agreeing acrossboth modules, the env knob's fail-safe fallbacks, and two end-to-end
cmd_harvestruns whose onlydifference is a FREED leg in the ledger / a capped candidate minutes from its reset).
Also required: a
seat_refusedtarget with clean carries does displace a 5h-capped donor, anda measured target with proven headroom is unaffected by the release window.
Four existing fixtures that encoded the incident-causing behaviour were updated in place, each
with the reasoning written next to it: the "unmeasured target IS promoted against a donor whose
week is open" pair, the live 2026-08-25 fleet (now holds, and swaps once one
seat_refusedrow ison the bench), and the two donor-ranking fixtures, which are given the serving signal so the round
still reaches the donor sort they are about.
Mutation-checked: each new guard was reverted in turn (rule 1's two clauses, rule 2's signal
test, rule 3's release test, rule 5's eligibility / donor comparison / pool ordering, the ledger's
FREED leg, the member expansion) — every one failed its own test, and all suites were restored to
green afterwards.
Verification: the exact
reloginjob list from.github/workflows/ci.yml(40 invocations) —exit 0; re-run under python 3.10 (the pod's interpreter) — exit 0;
python3 scripts/ci-change-filter.test.py— 58 invocations across 8 gated jobs, all reachable;gitleaks detect --no-git --redact --source .— 1 finding, the pre-existingoauthHcaptchaSitekey:line incharts/penstock-vault-node/environments/omar.values.yaml.🤖 Generated with Claude Code