Repository navigation
Serve id and id-plus-indexed-attribute selects from the index entry instead of reading every record - #3938
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request implements covered selects, allowing select queries to be answered directly from secondary index entries without loading full records from the primary store. It includes updates to documentation, benchmarks, and extensive unit tests. The review feedback highlights several critical issues in resources/search.ts and resources/Table.ts, including handling asynchronous promises during entry projection and deduplication, removing redundant setImmediate latency in the overflow resolver, preventing double-counting of database reads for recovered overflow values, and adding defensive optional chaining to prevent potential TypeErrors when accessing primaryStore.encoder.
This was referenced Nov 4, 2026
…nstead of reading every record A search whose select names only the primary key, or it and the attribute its one condition scans, now builds each row from the secondary-index entry and skips the primary-store read and record decode. It applies to every table, including TTL and caching tables. It stays off where the index entry cannot answer the query: local-only (`replicateFrom: false`) reads, @Sharded tables, post-ordering, row/vector filters, any other condition, and resolver or nested selects. Covered rows have three intended differences from loaded rows, documented in resources/DESIGN.md: - an expired row is returned until eviction removes it; - an undeclared bigint id comes back as the number the index decodes; - a value is what the index holds (an array attribute yields the matching element, an undeclared Date its time). Declared Date and BigInt attributes are decoded exactly. Index-local condition filters (ends_with, contains, in, ne, an index-aligned sort) are covered too. Each covered row records one size-1 db-read. Bench host, 2,000-row range: -53% to -55% on RocksDB, -66% to -84% on LMDB; cold record cache -78% (RocksDB). Uncovered selects unchanged within noise. Dispatch-Task: harper-covering-index-select-projection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…annot answer instead of loading them Dispatch-Task: harper-covering-index-select-projection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t one, and keep filtered scans closure-free Dispatch-Task: harper-covering-index-select-projection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…0 row, and read overflow rows asynchronously in async scans Dispatch-Task: harper-covering-index-select-projection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…against the condition Dispatch-Task: harper-covering-index-select-projection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m test narration Dispatch-Task: harper-covering-index-select-projection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eeds an admission stage, and recover sorted overflow values Dispatch-Task: harper-covering-index-select-projection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… overflow rejection before a declared array's dedupe Dispatch-Task: harper-covering-index-select-projection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tributes, and return expired records from every query - overflowedValue compares the stored prefix in the form its UTF-8 index key holds, so a surrogate pair cut at unit 256 (stored as U+FFFD) still matches its row (QA F-1116). - coverableAttribute covers an attribute only when its declared type is scalar; arrays, Any, ID and undeclared attributes take the record path, so select(id, tags) returns the stored array. - transformEntryForSelect no longer hides an expired record: a query returns it as stored until eviction, and a caching table still refreshes it unless only-if-cached or includeExpired. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3938-covered-select-fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3938-covered-select-fixes
…do while the scan skips runtime-created tables Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3938-covered-select-fixes
…n expired row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3938-covered-select-fixes
… in the covered-read rule Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3938-covered-select-fixes
A worker that learns of a table from the catalog (a table created at runtime on another thread, or any table after a restart) arms its TTL with the number form of setTTLExpiration. That form kept the cleanup interval it already had, which starts at a day, so the store-maintenance owner scanned a runtime-created TTL table once a day. Both forms now derive the interval from scanInterval, or a quarter of expiration + eviction. An eviction-only table loaded from the catalog also arms the scan now, as a declared one does. Un-todo storage-reclamation.test.ts and add a 4-worker runtime-created TTL table test on RocksDB and LMDB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3938-runtime-ttl-eviction
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3938-runtime-ttl-eviction
…test The number form now derives a 2.5ms scan for a 10ms TTL, which could evict the row before the search ran and pass the test without reaching the stale-copy path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3938-runtime-ttl-eviction
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3938-runtime-ttl-eviction
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3938-runtime-ttl-eviction
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3938-runtime-ttl-eviction
Wait to cover a primary key until coverableAttribute confirms the primary
key's declared type is scalar, 'ID', or omitted, and has no 'elements'.
Tables whose primary key is declared as an array or composite ('elements')
now take the record path for select(id) and select(id, attr) so single-element
and composite array primary keys are returned as arrays on both RocksDB and
LMDB.
Dispatch-Task: harper-3938-pk-scalar-gate
Co-Authored-By: Gemini <noreply@google.com>
Clarify the covered-select design notes for caching-table expiry, scheduleCleanup eviction, ID/untyped single-element array ids, and unvalidated stored values. Re-check the read transaction guard after setImmediate yields in overflowResolver, and clean up TTL timers and analytics state in the unit tests. Dispatch-Task: harper-3938-pk-scalar-gate Co-Authored-By: Gemini <noreply@google.com>
…le.ts - Note in resources/DESIGN.md:203 that declaredTypeDecoder (resources/search.ts:1047-1053) decodes integer keys under Date/BigInt declarations even on unvalidated writes (844ff53e63f9). - In resources/Table.ts:7710 and resources/DESIGN.md:195, covered-select-returns-expired-rows (6b832a77a614bf34) has its contract narrowed since 88f231b in resources/DESIGN.md:195 and pinned in unitTests/resources/indexRangeSelect.test.js:431-438 (rule kept at nit for narrowed contract, like covered-select-single-element-array-id at resources/Table.ts:12228, not an accepted-known major deferral). - In resources/Table.ts:8707 and resources/DESIGN.md:195, expired-rows-visible-without-eviction (6e991ee3883f9f64) is addressed since 88f231b by resources/Table.ts:3597,11441-11445 and tested in unitTests/resources/ttlCleanupInterval.test.js:50-124 and integrationTests/database/runtime-table-ttl-eviction.test.ts:1-85, with the contract narrowed in resources/DESIGN.md:195 and pre-existing sweep gaps (harper#2155, harper#1339) outside this diff (rule resolved or kept at nit, not an accepted-known major deferral). Dispatch-Task: harper-3938-pk-scalar-gate Co-Authored-By: Gemini <noreply@google.com>
Dispatch-Task: harper-3938-pk-scalar-gate Co-Authored-By: Gemini <noreply@google.com>
Dispatch-Task: harper-3938-pk-scalar-gate Co-Authored-By: Gemini <noreply@google.com>
Dispatch-Task: harper-3938-pk-scalar-gate Co-Authored-By: Gemini <noreply@google.com>
Dispatch-Task: harper-3938-pk-scalar-gate Co-Authored-By: Gemini <noreply@google.com>
Fixes TS2339 on Context | SourceContext at resources/Table.ts:7709 (matching line 7691) and restores formatting on line 8706. Dispatch-Task: harper-3938-pk-scalar-gate Co-Authored-By: Gemini <noreply@google.com>
Dispatch-Task: harper-3938-pk-scalar-gate Co-Authored-By: Gemini <noreply@google.com>
…ttribute guards Dispatch-Task: harper-3938-pk-scalar-gate Co-Authored-By: Gemini <noreply@google.com>
Dispatch-Task: pr-maint-b502b8291e7fda477ce21af7e4cf51be Co-Authored-By: Gemini <noreply@google.com>
kriszyp
force-pushed
the
perf/covering-index-select
branch
from
November 4, 2026 16:33
fc11235 to
2446385
Compare
kriszyp
marked this pull request as ready for review
November 5, 2026 00:39
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.
⊙ Problem
GET /Product/?score=gt=10&select(id,score)needs only thescoreindex entries, butsearchByIndexkeeps only each entry's primary key, so every row is read and decoded anyway.🤔 Is that the right requirement?
Yes, per Kris (2026-11-02/03): cover declared-scalar attributes and scalar/
ID/untyped primary keys, include caching tables, and return expired rows until eviction. Accepted: unvalidatedDate/BigIntintegers decode per schema,ID/untyped primary keys read in index form,usedReadBytesdrops (size-1db-read), subscription initial scans hide expired rows, and covered caching selects return expired or invalidated-stub entries without refresh.💡 Solution
Index entries now answer a select of the primary key, alone or with the scalar attribute its lone condition scans. 2,000 rows: 54–56% faster on RocksDB, 66–84% on LMDB.
The TTL scan now arms on catalog load and derives its interval from the table's TTL, not a one-day default. Partly addresses #3935 (eviction-only catalog scan arming and non-daily catalog scan intervals) and #2831 (leg 2 fixed; leg 1 visible in queries under
threads: 0).⚖️ Alternatives
Options considered
transformEntryForSelect: rejected. The filter paths treat any{key, value}as a loaded record.select(id, attr).🔧 Changes
resources/Table.ts:coveredSelectAttributedecides eligibility. Coverage stays off forreplicateFrom: false(invalidated stubs keep index entries),@shardedtables (live baseline holds refuse rows one at a time), post-ordering, and row or vector guards.recordCoveredReadrecords the analytics.coverableAttributerequires a declaredSCALAR_TYPEStype on secondary indexes and covers primary keys only when declared scalar,ID, or untyped;IDis excluded on secondary indexes because it admits an array of strings, and primary keys declared with an array type orelements(composite) take the record path (fixes382d09518b5dba90andfeb518ca7dd020e2).scanInterval, or a quarter of expiration + eviction; a catalog-loaded eviction-only table arms the scan.transformEntryForSelectno longer hides an expired record; a caching table still refreshes it unless only-if-cached orincludeExpired.resources/search.ts:executeConditionsforwards coverage only to a lone AND lead.admitCoveredEntriesruns only with a filter or a declared-array dedupe. It settles overflow and-0entries beforedistinctRecords, keeping main's per-entry yield. Look hardest here.coverIndexEntriesprojects;declaredTypeDecoderrestores declaredDateandBigIntvalues.overflowedValuematches the prefix in its stored UTF-8 form (toWellFormed), so a pair split by the 256-unit cut still matches.tsconfig.jsonaddsES2024.String.resources/DESIGN.md: expiry and covered-read rules; root index entry.resources/MAP.md: a pointer.✅ Verification
New tests were red before their fixes and pass on both engines.
Tests, suites, A/B, follow-ups
Runtime-created TTL table, 4 workers, RocksDB and LMDB: 10 rows reach
record_count0 with no query or GET (resolves6e991ee3883f9f64, verified by PR QAqa-pr-harper-3938-8574f32/ P-6719: 100/100 rows evicted across 4 workers and after restart on RocksDB and LMDB). Red on 8a6f816 (10/10 stored after 30 s), green now.storage-reclamation.test.tspolls for eviction and passes; it shows TTL eviction, not pressure-driven reclamation.ttlCleanupInterval.test.js: a catalog-loaded table and the number form evict on schedule (red before the fix); an expired row stays queryable and stored until the scan evicts it; a configuredscanIntervalis kept.expiresAtAttribute's comment no longer says hydration skips eviction-only arming.The expiry, array-attribute and primary-key scalar-gate (composite and one-element array primary keys return arrays on
select(id)andselect(id, attr); declaredIDand untyped primary keys stay covered; fixes382d09518b5dba90andfeb518ca7dd020e2), and split-surrogate tests were red on the previous head; all pass on RocksDB and LMDB.Resources (RocksDB): 6414 passing, 0 failing. LMDB: 4884 + 795 passing.
After the eviction fix (37e80cc): resources RocksDB 6417 passing; LMDB 4881 passing. Failures, all passing alone and unrelated to TTL:
sourceApplyConflictRetry(RocksDB), 6calibrationStoretimeouts and 1recordLockCoordinator(LMDB).vectorIndexstale-copy test now pins a longscanInterval: the faster scan could evict its row first and pass it vacuously.Other per-table maintenance: audit and txn-log retention are scheduled per store when the owner opens it (
resources/auditStore.ts:474), so they were not affected.Follow-ups, not fixed here:
create_tabledropseviction(dataLayer/harperBridge/ResourceBridge.ts:165);scanIntervalis not stored in the catalog, so a restart scans at the derived interval until the schema is redeclared (#2831); the RocksDB@expiresAtsweep (Fix RocksDB @expiresAt eviction sweeps); per-recordexpiresAtwith no table TTL is best-effort (harper#1339); subscription initial scans still hide expired rows; an invalidated and expired row is a stub to a covered select but evicted by a wide one.Main unit, per directory: server 2344, security 1618, utility 1039, dataLayer 467, config 410, validation 346, sqlEngine 119, sqlTranslator 72.
deployActivationandbackupStreamFailurefailed in one long single-process run and pass alone (75/75).Integration: resources + apiTests 1232 pass, 0 fail; database + server 1155 pass. Fails: 4 Ollama tests (need a local model).
Integration not run: components, deploy, mqtt, mcp, upgrade.
Updated visible-change pins:
readOnlyModeTtl,nestedRelationshipConstraints(a covered caching-table select against the record path),onlyIfCachedExpiry,read-only-mode-ttl,apiTests/ttl.A/B (bench cases), RocksDB: covered
id−56% (286 → 59 µs/q onindexRangeSelect.bench.js),id,score−54% (321 → 69 µs/q),indexed291 → 59 µs/q, cold −78%. LMDB: −84%, −80%, −75%. 8 interleaved pairs, turbo off,bench-run. The LMDBselect4-cold+15% in the full run is a case-ordering artifact: run alone it is −4% (CI includes 0).Single row (6 RocksDB / 4 LMDB pairs, 1 µs resolution): covered
id,score23 → 23 µs, 14 → 13 µs.Deferral (#4020): ordered-binary writes
-0as a corrupt key. Fix it at write ingetIndexedValuesandcheckValidId. Repro:put(5, { value: -0 }), thenvalue > 4withselect(id,value).Deferral (#4019): under
snapshot: false, a long value rewritten mid-scan can leaveselect(id,value)but notselect(id), and async overflow recovery after a yield reads through the snapshot (61571056b45cc528). Both go with the follow-up "overflow-key recheck reads latest state, not the query's snapshot" (primaryStore.getSyncwithout the read transaction).Deferral: the scalar gate trusts
validate; source fills and legacy integers written before aDate/BigIntdeclaration bypassvalidate(2977d59bbfab4c27,e89a67d47855fd83).PR QA: Does a runtime-created TTL table keep evicting after a restart, with no query touching it? — passed @ 8574f32, qa-pr-harper-3938-8574f32
Docs: owner's separate task (querying, resource API and schema pages): scalar gate, expired query rows, one-element array ids.
Release note: TTL tables created at runtime or loaded from the catalog now scan every
scanInterval, else (expiration + eviction) / 4, not daily.Release target: main — a new query optimization with documented semantic changes, not a fix to backport
Related PRs: #3927 overlaps, #2155 independent, #3137 independent, #3307 independent, 3 others independent
🤖 Generated with Claude Code — Claude Opus 5.5
Deferred findings
b74e1b3f336d1c25minor performance —resources/search.ts— covered index-order sort yields a setImmediate and promise per row for a no-op filter — filed Async search iteration yields one event-loop turn per row #3241cb4e62a0c71f96f6minor correctness —resources/search.ts— covered filter tests the raw -0 key shape before delegating to the record path — filed -0 is written to indexes and primary keys as a malformed ordered-binary key #4020c0867d378bb34ce5minor correctness —resources/Table.ts— catalog-loaded tables scan at the derived quarter interval until a declaration applies scanInterval — filed Cleanup scans of loaded tables: none under threads: 0 until a write, and only daily on threads that load the table #28317086d236550d9817minor correctness —resources/search.ts— uncovered overflow filter reads the latest committed value with getSync and no transaction — filed Long-string index filter reads the latest record instead of the query's snapshot #4019d0ba2ea426ba3a1aminor documentation —resources/DESIGN.md— no user-docs or release-note link for search returning expired rows while get hides them — NOT FILED2c0b04e0a74564c0nit documentation —resources/Table.ts— number-form tables now scan every (expiration+eviction)/4 instead of daily, with no operator note — NOT FILED754573e389f1733enit test-coverage —integrationTests/database/runtime-table-ttl-eviction.test.ts— runtime TTL test never asserts rows stay queryable between expiry and eviction — NOT FILED648699a6d3a682f8nit performance —resources/search.ts— HNSW pushdown lead does not receive covered — NOT FILED2bc9766e48640550nit performance —resources/Table.ts— Number/Any-typed primary keys never covered — NOT FILED61571056b45cc528nit correctness —resources/search.ts— unfiltered overflow under snapshot:false: select(id) skips recovery while select(id,value) drops a deleted or rewritten row — filed Long-string index filter reads the latest record instead of the query's snapshot #4019Accepted behavior
Behavior the owner ruled intended, so there is nothing to file.
6b832a77a614bf34major data-integrity —resources/Table.ts— covered select on a caching table returns the expired index value without refresh — accepted: Owner ruling 2026-11-03 option (a), in the PR body6e991ee3883f9f64major correctness —resources/Table.ts— expired rows on sourceless tables stay queryable where the cleanup scan never runs — accepted: Owner ruling 2026-11-02, in the PR body6 minor or nit
1946b31e02242172minor correctness —resources/Table.ts— initial subscription scan hides expired rows that search now returns — accepted: Owner ruling 2026-11-02, in the PR body108201baa7107167minor correctness —resources/Table.ts— ID/untyped primary key returned in index form by covered selects, stored form by loaded selects — accepted: Owner ruling 2026-11-02 and 2026-11-03, in the PR body37c72520f069db9aminor correctness —resources/Table.ts— invalidated+expired row evicted by wide select, stub from covered select — accepted: Owner ruling 2026-11-02 and 2026-11-03, in the PR body44ccc8a7be2a5987nit correctness —resources/search.ts— untyped bigint id returned as number from covered rows and as bigint from -0-shaped record-path rows in one response — accepted: Owner ruling 2026-11-02, in the PR body8584ce5735cb6052nit reliability —resources/search.ts— overflow rows that read their record still record a size-1 db-read; SKIP recoveries record none — accepted: Owner ruling 2026-11-02, in the PR body2977d59bbfab4c27nit correctness —resources/search.ts— covered select decodes unvalidated legacy integers to Date/BigInt while the record path returns them as stored — accepted: Owner ruling 2026-11-02, in the PR bodyOrigin — the dispatch brief this PR was written from
searchByIndex's index branch discards the decoded index key (resources/search.ts ~1319, ({ value }) => value), so select: ['id'] or ['id', indexedAttr] over an index range still does N primaryStore.getEntry + record decodes (transformEntryForSelect / loadLocalRecord). Serve those selects from the index entry. Hazards to handle: (1) the candidate patch tracks hasNonScalarValues only at write time, so after a restart a table that already holds bigint/array/object/-0 values under an untyped indexed attribute would wrongly count as scalar: derive it at index open or restrict coverage to schema-typed scalar attributes; (2) check Date/Float/wrapper types round-trip through the index key; (3) keep residency and expiration gating (invalidated/expired rows must not be served from the index); (4) exercise LMDB as well as RocksDB. Report the A/B in the PR.
Acceptance
indexRangeSelect test (covered selects return identical rows to the uncovered path on rocksdb and lmdb, including after restart with non-scalar values present, expired and invalidated rows) red-without-fix where it asserts the read count; bench numbers in the PR body. Show each new test failing on origin/main sources (forced rebuild) before the fix.
Dispatch: task
harper-covering-index-select-projection· queued by finding-triage/wave-43 · ran by claude/opus/xhigh · worker kzyp-xps-1Review-Coverage: authored=gemini; ran=cursor-composer,codex,cursor-grok,claude; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=29; full=5 @ 2446385
Review-Attention: deep ~45m (critical: Table.ts, search.ts; decisions: search-returns-expired-rows, couple-expiry-change-with-covering, number-form-interval-derived, covered-gate-scope, bench-evidence, do-less-alternative) @ 2446385