Skip to content

Serve id and id-plus-indexed-attribute selects from the index entry instead of reading every record - #3938

Merged
kriszyp merged 30 commits into
mainfrom
perf/covering-index-select
Nov 5, 2026
Merged

kriszyp merged 30 commits into
mainfrom
perf/covering-index-select

Conversation

@kriszyp

@kriszyp kriszyp commented Nov 2, 2026 •

Copy link
Copy Markdown
Member
Utanas, the cairn trail: Kris's daughter reads each fruit's tag straight off the carved cairns while the dogs nap, fetching only where a cairn has no carving.

⊙ Problem

GET /Product/?score=gt=10&select(id,score) needs only the score index entries, but searchByIndex keeps 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: unvalidated Date/BigInt integers decode per schema, ID/untyped primary keys read in index form, usedReadBytes drops (size-1 db-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
  • Durable certificates and data-history markers: ruled out by Kris; the scalar gate reads only the declared schema.
  • Per-row decision in transformEntryForSelect: rejected. The filter paths treat any {key, value} as a loaded record.
  • multiGet: still decodes every record.
  • pk-only coverage: misses select(id, attr).
  • Queries never refresh expired cached rows: breaks stale-while-revalidate for queries and 11 caching tests.
  • Splitting index keys before a surrogate pair: keys already on disk keep U+FFFD, so the reader must match it anyway.

🔧 Changes

✅ 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_count 0 with no query or GET (resolves 6e991ee3883f9f64, verified by PR QA qa-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.ts polls 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 configured scanInterval is 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) and select(id, attr); declared ID and untyped primary keys stay covered; fixes 382d09518b5dba90 and feb518ca7dd020e2), 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), 6 calibrationStore timeouts and 1 recordLockCoordinator (LMDB). vectorIndex stale-copy test now pins a long scanInterval: 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_table drops eviction (dataLayer/harperBridge/ResourceBridge.ts:165); scanInterval is not stored in the catalog, so a restart scans at the derived interval until the schema is redeclared (#2831); the RocksDB @expiresAt sweep (Fix RocksDB @expiresAt eviction sweeps); per-record expiresAt with 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. deployActivation and backupStreamFailure failed 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 on indexRangeSelect.bench.js), id,score −54% (321 → 69 µs/q), indexed 291 → 59 µs/q, cold −78%. LMDB: −84%, −80%, −75%. 8 interleaved pairs, turbo off, bench-run. The LMDB select4-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,score 23 → 23 µs, 14 → 13 µs.

  • Deferral (#4020): ordered-binary writes -0 as a corrupt key. Fix it at write in getIndexedValues and checkValidId. Repro: put(5, { value: -0 }), then value > 4 with select(id,value).

  • Deferral (#4019): under snapshot: false, a long value rewritten mid-scan can leave select(id,value) but not select(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.getSync without the read transaction).

  • Deferral: the scalar gate trusts validate; source fills and legacy integers written before a Date/BigInt declaration bypass validate (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

Accepted behavior

Behavior the owner ruled intended, so there is nothing to file.

  • 6b832a77a614bf34 major 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 body
  • 6e991ee3883f9f64 major 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 body
6 minor or nit
  • 1946b31e02242172 minor correctness — resources/Table.ts — initial subscription scan hides expired rows that search now returns — accepted: Owner ruling 2026-11-02, in the PR body
  • 108201baa7107167 minor 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 body
  • 37c72520f069db9a minor 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 body
  • 44ccc8a7be2a5987 nit 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 body
  • 8584ce5735cb6052 nit 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 body
  • 2977d59bbfab4c27 nit 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 body
Origin — 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-1

Review-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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread resources/search.ts Resolved
Comment thread resources/search.ts Resolved
Comment thread resources/search.ts Resolved
Comment thread resources/search.ts Resolved
Comment thread resources/Table.ts Resolved
kriszyp and others added 25 commits November 4, 2026 08:51
…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>
kriszyp and others added 5 commits November 4, 2026 08:51
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant