Skip to content

Reload invalidated cache rows under negated and grouped primary-key conditions, and sort them by their reloaded value - #3923

Merged
kriszyp merged 9 commits into
mainfrom
fix/invalidated-stub-negated-key-and-sort
Nov 6, 2026
Merged

kriszyp merged 9 commits into
mainfrom
fix/invalidated-stub-negated-key-and-sort

Conversation

@kriszyp

@kriszyp kriszyp commented Nov 2, 2026 •

Copy link
Copy Markdown
Member
Utanas, the cache-hole bench: a dog refills each red-tagged stale basket from the grove before Kris's son lines it up by weight.

A caching-table query keeps a row the source must reload under negated or grouped key filters, and reloads it before sorting.

⊙ Problem

An invalidated row should reload from the source when queried (#3772). Two cases broke that:

  • Negated or grouped primary-key conditions (id=not_in=(b), (id=a|id=c)) dropped the row (#3783).
  • Content sorts placed the row by its missing value, so limit/offset cut the wrong page (#3786).

🤔 Is the requirement right?

Yes. The owner accepted reloading every invalidated row in a sorted range.

❓ Your call: Keep that contract, or rank by cached values without reloading?

💡 Solution

With c invalidated, GET /T/?id=ne=b&sort(+body)&limit(1) ranks c by its reloaded body.

🤔 Reloads before a sort run one at a time

500 invalidated rows mean 500 serial source calls; parallel loading is tracked in #3908.

🤔 Not covered here

Nested relationship selections and non-leading key conditions (#3907). Index-supplied order, expired rows and stale-while-revalidate rows off the page keep cached values (#3909). Index-covered ['id'] selects are unchanged.

⚖️ Alternatives

Three approaches set aside
  • Give negations and groups an idFilter. Relationship id mapping reads idFilter, so children.id=not_in=… would match a dangling foreign key.
  • Reload inside getAttributeValue. That skips the materializer's stale-while-revalidate, only-if-cached and resident-load rules, and loads twice.
  • Reload stale-while-revalidate rows before sorting (the first plan). Each would start a background load even far off the page; the hook is now asked first.

🔧 Changes

  • resources/search.ts: keyFilter on primary-key comparators, negations and all-key groups; keyPredicate, which joinFrom also reads.
  • resources/Table.ts:
  • resources/DESIGN.md: the stub rule.

✅ Verification

  • Every row of both issue tables fails on main and passes on RocksDB and LMDB, with one source call per kept stub.
  • HTTP: id=ne=b&sort(+body)&limit(1) returns the right row; 3 integration cases fail on main, 8/8 pass.
  • Performance: a sorted read with no stubs adds a property read per key check and a flag test per row, no allocation; CPU matches main (numbers below). Stub reloads run serially (see the 🤔 above).
  • User-facing docs: none; no API changes.
  • PR QA: Do concurrent sorted, paged reads over many invalidated cache rows return each row once, in fresh order, with one source load per row? — queued @ af653b1, qa-pr-harper-3923-af653b1
Commands and results
  • invalidatedStubKeyAndSortReads.test.js (36 tests): not_in, not_between, not_starts_with, AND/OR groups, limit/offset/descending, metadata and primary-key sorts, deleted at the source, source rejection, a close during a reload, only-if-cached, stale-while-revalidate, row filters. Against main's Table.ts/search.ts, 16 tests fail per engine, including the negated-join cases added to sortedKeyInvalidationReads.test.js. The sorted-read closure tests in residencyReads.test.js now close during the pre-sort reload, or, with a key-led sort, during materialization.
  • pk-filter-invalidated-reads.test.ts: limit(1) and limit(1,2) pages, id=not_in=, a grouped condition.
  • test:unit:resources: core:a-n 2,706 and core:o-z 2,961 passing, 0 failing. :indexes: 761 pass and 4 derived-index durability tests fail; those 4 pass alone (160/160) and are green on main CI.
  • Integration resources/**: 277/278; the rate-limiter smoke that failed passes alone.
  • test:unit:main did not finish: lock-timing tests in components stalled under host load (load average about 26). Left to CI.
  • 5,000-row sorted read, no stubs, median per-query CPU over 4 runs: main 19.0–19.8 ms, this branch 18.8–19.7 ms.

🤖 Generated by Claude Opus 5.5 (Anthropic); posted via @kriszyp.

Framing-Verdict: chosen-approach-sound (eb0a6ae932c1)

Release target: v5.4 — follows up #3772 (milestoned v5.4, not on v5.3); a query-correctness fix on main, no backport requested
Related PRs: #2155 overlaps (same joinFrom joined-filter loop), #3772 overlaps (merged; this follows it up), #3927 overlaps (merged; relationship conditions now existential, this rebased onto it), 7 others independent
Complexity: complicated

Deferred findings

🤔 defer this PR's major performance finding b3de6d8a86f2ce34 (pre-sort stub reloads run serially and scale with the matched set, not the page) to #3908 instead of fixing it here?

Bundle — this PR covers 1 ref.

Closes #3783

Origin — the dispatch brief this PR was written from

Invalidated cache rows: keep them for negated/grouped key filters and sort them after reload

Fix #3786 and #3783 in one PR. Both follow up merged #3772 in the caching-table INVALIDATED-stub keep/reload path. Owner decision (2026-11-02): a query may call the source to reload an invalidated row it would return; that cost is accepted.

Order of work:

  1. Shared fixture: caching table with no @indexed attribute, rows a/b/c with distinct body, invalidate c (_writeInvalidate), source returns { body: 'fresh ' + id }; RocksDB and LMDB. Reproduce both issues' tables and show them failing on origin/main sources (forced rebuild).
  2. Invalidated cache rows are left out when a negated or grouped primary-key condition filters the query #3783: give the negation wrapper in filterByType (resources/search.ts, "negated conditions are filter-only") and AND/OR groups of primary-key conditions in mapConditionsToFilters an idFilter (negated key test, group of key tests), so the stub is kept and reloaded; assert the source is called once per kept stub. A record guard or non-key condition must still drop the stub.
  3. Sorted reads place an invalidated cache row by its missing sort value, before reloading it #3786: in the sorted path, reload kept stubs before computing their sort keys (or defer placement until the reload resolves) so a reloaded row sorts by its fresh value and limit/offset apply to the corrected order; cover id in (...) and id ge a, with and without limit: 1. A sorted read with no stubs must cost nothing extra; measure it.
  4. Update the stub paragraph in resources/DESIGN.md.

Out of scope: computed-resolver retirement (harper#3782), relationship joins (joinTo cannot apply the key rule), eviction semantics. Coordinate with the in-flight index-covered select work (task harper-covering-index-select-projection, branch perf/covering-index-select), which serves ['id']/['id', attr] selects from the index without loading records: if a covered query reaches an invalidated row, note the interaction in the PR body rather than changing that branch. Release target v5.4 (main).

Acceptance

Unit tests for every row in the #3783 and #3786 issue tables fail on origin/main and pass, on both storage engines; the source is called once per kept stub; an HTTP integration test on a caching table (invalidate a row, then GET /T/?id=ne=b&sort(+body)&limit(1)) returns the reloaded row in its correct position.

Dispatch: task harper-invalidated-stub-pk-sort · queued by kris-session · ran by claude/opus/high · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=13; full=7 @ 7056332

Review-Attention: deep ~30m (critical: Table.ts, search.ts; decisions: keyfilter-separate-from-idfilter, reload-before-sort-vs-sort-stale, do-less-alternative, deferred-b3de6d8a) @ 7056332

@kriszyp kriszyp added this to the v5.4 milestone Nov 2, 2026

@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 optimizes and fixes the handling of invalidated or evicted cache rows during primary-key filtered, negated, grouped, and sorted reads. It introduces a prepareForSort mechanism to reload stubs before sorting, uses a unified keyPredicate helper for filtering, and adds comprehensive tests to verify these behaviors. The review feedback recommends a safer check for thenable objects and avoiding variable shadowing in Table.ts.

Comment thread resources/Table.ts Resolved
kriszyp and others added 9 commits November 4, 2026 18:44
…ary-key conditions, and sort them by their reloaded value

A negated primary-key condition (not_equals, not_in, not_between, not_starts_with) and an AND/OR group of
primary-key conditions carried no idFilter, so transformToEntries dropped a null INVALIDATED stub instead of
letting the materializer reload it. They now carry a key-only keyFilter, read through keyPredicate wherever a
stub's admission is decided (transformToEntries and joinFrom); idFilter, which relationship id mapping reads,
is unchanged.

A post-sort read a stub's sort keys before the materializer reloaded it, so the reloaded row sorted by its
missing value and limit/offset cut the wrong order. The materializer now exposes prepareForSort, which
transformToOrderedSelect calls on a stub before reading its sort keys when the ordering reads record
content: it reloads the row (one source load, reused by the materializer), keeps a stale-while-revalidate
row stale and defers its reload to page time, and skips a row deleted at the source. A primary-key sort
clause reads a stub's key.

Fixes #3783, #3786

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-invalidated-stub-pk-sort
…e source

prepareForSort can skip every row of an index-ordered group, and the ordered iterator treated an empty group as
the end of the scan. The closure tests for sorted reads now cover a close during the pre-sort reload and, with a
key-led sort, during materialization of a later row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-invalidated-stub-pk-sort
…ry-key clause, and read a one-element key path from the stub

- prepareForSort treats a load that resolves with no record as absent, so the materializer does not load the
  row a second time, and starts no load once the selection has closed.
- An ordering stops at its first primary-key clause: a primary key never ties, and a later clause would be
  resolved on a stub that was not reloaded.
- getAttributeValue reads a stub's key for a one-element primary-key path, as it does for the bare name.
- The stale-while-revalidate resource helper is built once per table, not per query.
- The sorted-read closure tests cover a close during materialization of the first row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-invalidated-stub-pk-sort
…-row integration loop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-invalidated-stub-pk-sort
…echeck closure after a synchronous preparation

- prepareForSort hands on the loaded entry (or a recordless one) for a row the source no longer has when the
  read does not skip absent rows, instead of the flagged stub the materializer would send to the source again.
- collect rechecks that the read is open after a synchronous prepareForSort, since a stale-while-revalidate
  hook is application code.
- A stub resolves a one-element path (`['id']`, `['$updatedtime']`) as the bare name, and sortReadsRecord treats
  a one-element metadata path as metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-invalidated-stub-pk-sort
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-invalidated-stub-pk-sort
…n a stub only for the key or entry metadata

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-invalidated-stub-pk-sort
…main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-invalidated-stub-pk-sort
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-invalidated-stub-pk-sort
@kriszyp
kriszyp force-pushed the fix/invalidated-stub-negated-key-and-sort branch from 5d21c97 to 7056332 Compare November 5, 2026 01:56
@kriszyp
kriszyp merged commit 2b4d146 into main Nov 6, 2026
73 of 75 checks passed
@kriszyp
kriszyp deleted the fix/invalidated-stub-negated-key-and-sort branch November 6, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant