Repository navigation
Source fills index the record they store, residency stubs keep their primary key, and writes over a stub no longer fail on computed indexes - #3610
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the handling of residency stubs, source fills, and computed indexes to ensure consistency between stored records and their index entries, particularly during invalidation, refills, and id-based omissions. Key changes include preserving the primary key and creation timestamps on stubs, preventing computed index updates from running resolvers during replica demotions, and ensuring HNSW updates/removals start from the stored node. Feedback on these changes highlights two potential issues: a potential TypeError in residencyStub if the record is null or undefined, and a falsiness bug in HierarchicalNavigableSmallWorld.ts where a nodeId of 0 would be incorrectly treated as falsy.
… never run computed resolvers over a stub - residencyStub(record, id) always returns an object carrying the primary key and skips computed names; _writeUpdate, getFromSource, demoteReplica and _writeRelocate share it. An index-only select over a stub returned id undefined, and a table with no secondary index stored the stub as null. - getFromSource stamps @updatedTime/@createdtime before resolving (the caller, a noCacheStore read and the model hooks see them), and in the commit decides residency on its own recordToStore before updateIndices. A first fill indexed no timestamps, a revalidation deleted them, and a setResidencyById-excluded fill left orphan index entries. - updateIndices(…, existingIsStub) skips computed resolvers on an INVALIDATED existing side. A refill, source miss, put, delete or eviction over a stub threw when a resolver read a non-indexed input, and one such stub aborted its whole RocksDB eviction batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
…s it Skipping the resolver over every stub left the committed entry of a computed index whose inputs the stub does keep (computed from an indexed field) on delete, eviction and refill. Resolve the old value over the stub, and only when the resolver throws — it needs a field the stub lacks — leave the committed entry. The raced-fill copy keeps the record prototype, and DESIGN.md names the caller's record in the raced-first-fill limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
…rker refusal visible A resolver that falls back instead of throwing over a stub yields a guessed old value; when it equalled the new value the add was skipped and only the stale committed entry remained. Over a stub the new value is now always written and the guess removed only when it differs. The replication pool worker's refusal to run application resolvers is rethrown rather than read as "no old value", the raced-fill copy is spread (an own __proto__ key stays data) and registered in entryMap so record time helpers work on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
…lver's missing input A replication pool worker runs schema-expression resolvers too; a missing-input throw from one over a stub is the expected unknown old value, not the refusal to run application code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
…t is raised A schema expression can read another table's application-resolved computed field, so the refusal can come from a dependency of a registered resolver. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
…hrough custom indexes too A delete over a stub whose computed vector cannot be resolved reached HNSW with no old vector; it removed the node without unlinking its neighbours. HNSW now loads its stored node whenever one exists, so removal no longer depends on the caller knowing the old vector, and a write over a stub always reaches the custom index like a plain one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
The fill reorder made a setResidencyById-excluded revalidation of an existing row remove that row's index entries while the row itself stayed (the write stores nothing). Skip index maintenance when nothing is stored, in getFromSource and _writeUpdate, so the retained row and its entries stay together; whether the omission should drop the row is harper#3566. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
… its indexes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
…flags required
An omission stores no row, so staging { value: undefined } made a later delete of the key in the
same transaction diff its indexes against nothing and orphan them. The stored or earlier staged
row now stays the basis. updateIndices' preserveComputed/existingIsStub are required, so a new
caller cannot silently omit the stub flag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
The omission stores no row, but updateRecord still released the stored row's blob files, leaving a row (now still indexed) whose files are gone. Pass the existing entry without its value, as the audit-only branch already does. The id-omission source-fill test counts only its own table's audit records (the test database's audit store is shared). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
A refill reads its created time from the stub; a stub without it made the refill restamp createdAt. The no-index null stub is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
…ores Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
…fter main's additions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-getfromsource-stamp-order-residency-stub
…tay in the DESIGN.md budget Dispatch-Task: pr-maint-3f4f6d4107d010f8fb4c2b44be07f42a Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
3e477d6 to
6331bd6
Compare
⊙ Problem
Source fills and residency stubs in
resources/Table.tsleft wrong indexes, on both engines:@createdTime/@updatedTimeand before deciding residency: first fills indexed no timestamps, revalidations deleted them, id-excluded fills left orphans.residencyStubdropped the primary key (index-only selects returnedid: undefined) and storednullwithout a secondary index.🤔 Is this the requirement?
Yes, as specified: each is a reproducible wrong index or failed write. Put, delete and eviction over a stub are fixed too, since they fail for the same reason as the refill.
💡 Solution
noCacheStorereads get them), decide residency, then index what they store.Existing data and known limits
null: harper#3597.⚖️ Alternatives
Other approaches weighed
_writeUpdate— adds a node-identity tie-break a source fill must avoid.🤔 Raced first fill: should hooks see the stored created time?
When a write lands during a first fill's source call, the stored row keeps that write's created time, as a put would. The caller and
@embed/@decidehooks saw the fill's own. Re-running hooks on that race was rejected: no write path shows hooks the stored stamps, and it needs a retry loop around a synchronous commit. Tracked in harper#3567.🔧 Changes
Files:
resources/Table.ts—residencyStub(record, id)and its callers (_writeRelocate,_writeUpdate, fill,demoteReplica); fill stamping and commit order;updateIndiceswithresolveOverStuband thePoolWorkerResolverRefusalit rethrows; the stub flag at delete, evict and the eviction batch; id-omission staging; the created time on cache-invalidation stubs.HierarchicalNavigableSmallWorld.ts—index()loads the stored node whenever one exists.resources/DESIGN.md,resources/indexes/DESIGN.md,DESIGN.md— fill timing, stub shape, HNSW removal rule.Product and architecture tour
Product and architecture tour
A source fill stores, then indexes, one decided value
What does a fill write, and in what order?
noCacheStorereads saw no stamps.recordToStore, then index and store exactly that.The fill's commit
updatedRecordis already stamped and is never reassigned, so a retried commit starts over from it.setResidencyById.updateIndicessees the stub or record about to be written; an id omission writes no row and touches no index.A residency stub is a keyed projection
What does a node outside a record's residency store?
Example: a stub for a record with
nameindexed and a computedupperThe record
{ id: 'k', name: 'n', body: '…' }is received on a node outside its residency. Main stored{ name: 'n' }, so an index-only select returnedid: undefined; with no secondary index it storednull, the shape of a delete.Outcome: The node stores
{ id: 'k', name: 'n' }, plus the created time when the table has one, and never readsupper.Computed indexes over a stub
How is a computed index maintained when the existing record is a stub?
The stub lacks most resolver inputs, and the committed computed value is stored nowhere. So the old value is resolved over the stub as a guess: exact when the resolver reads only fields the stub keeps, unknown when it throws, possibly wrong when it falls back. The guess is removed when it differs from the new value. The new value is always written. When the resolver throws, the committed entry stays (harper#2370); main threw and the write failed. A replication worker's refusal to run application resolvers still surfaces.
existingIsStubfrom the entry or staged write that suppliedexistingRecord; the parameter is required.Code paths: the fill's commit and
updateIndicesover a stubTable.ts:10596
Table.ts:9589
✅ Verification
origin/mainsources with a forced rebuild, each with its bug signature, and pass here:sourceFillIndexes.test.js(new),residencyReads,writerResidency,residencyReplicas,evictionBatch.cachedComputedAttribute.test.ts(fixture: schema, resources) fail on base (empty timestamp index; the refill logsError committing cache update TypeError) and pass here.Gates and gaps
test:unit:main8185 pass, 0 fail.test:unit:resources: RocksDB 4926 pass, LMDB 3744 pass; the one failure on each is the WebSocket read-gate test, red on main and fixed there by Give the read-gate WebSocket test's fake socket the off() REST calls on close #3562.test:integration:all(3 shards): 9 failures — 4 local Ollama (no models here) and 5 timing tests that pass alone.PR QA: Do concurrent fills, invalidations and writes across HTTP workers keep a sourced table's stored rows and secondary indexes in agreement? — queued @ 3e477d6, qa-pr-harper-3610-3e477d6
Docs: none needed — bug fixes, no API or configuration change.
Refs harper#3566 harper#3567 harper#2370
🤖 Generated by Claude Opus 5.5 (Claude Code dev agent); posted via @kriszyp.
Release target: main — residency stubs, replicas and
preserveComputedexist only on main; v5.3 has only the fill stamping order, a backport only for a customer needRelated PRs: #2155 overlaps (eviction updateIndices calls), #3490 overlaps (fill commit and lock), #3504 overlaps (eviction), #3527 overlaps (residency stub producers), #3529 overlaps (same getFromSource commit block), #3575 overlaps (eviction batcher), #3594 overlaps (@createdtime stamping), 10 others independent
Complexity: complicated
Deferred findings
3a73e745182788dfminor data-integrity —unitTests/resources/residencyReads.test.js— assert computed 'upper' cleanup on delete/evict of a stub — filed Stale index entries for @indexed @computed attributes survive invalidation #2370a11d87cb099c1bb7minor data-integrity —resources/Table.ts— cache invalidate() on a table with no secondary index stores a null stub, so the refill restamps @createdtime — filed A cache invalidation on a table with no secondary index stores null, so the refill restamps @createdTime #359701ef86b77acfdd62nit correctness —unitTests/resources/caching.test.js— assert the resolved fill's createdAt differs from the stored raced createdAt — filed A first source fill raced by a write resolves the caller with its own @createdTime while the row keeps the raced one #3567ecba6b0420fcc032nit maintainability —resources/Table.ts— existingIsStub is a positional boolean each updateIndices caller must remember — filed Replace updateIndices' positional stub booleans with entry-derived state #3598Late Grok findings
4 surviving (highest major) — Grok reviewed ab773eb
resources/Table.ts:10577— In a raced first fill,{ ...updatedRecord, [created]: racedCreatedTime }copies only the record's own fields. That loses thestructPrototypeattached atresources/Table.ts:10486-10488, which holds every computed getter (defined atresources/Table.ts:9059-9069). The copy then goes to `getResiresources/Table.ts:9560— WithexistingIsStub,updateIndicesskips the old-value resolver for every computed index. A stub keeps the primary key and the non-computed indexed fields (resources/Table.ts:11074-11085), so a computed index derived only from those fields could be resolved, and main did resolve it. When a refresources/Table.ts:10463— In a raced first fill, the caller receives the fill's own@createdTime(it resolves at:10490, before the commit runs), while the row and index store the raced one (:10574-10577). The DESIGN note atresources/DESIGN.md:310lists only the model hooks under "Known limit" and still says the calresources/Table.ts:4293— Added comments that restate code or test titles:resources/Table.ts:4293,integrationTests/resources/cachedComputedAttribute.test.ts:273-274, the first sentence ofintegrationTests/resources/cached-computed-attribute/schema.graphql:47, and the "stores its own copy" wording atDESIGN.md:37Not standing: 2 merged. Grok ran 23m (23m from launch to report); the fold adjudication (
cursor-grok@ab773eb24e32-4b4cfc49, claude) took 1m.Origin — the dispatch brief this PR was written from
Two related defects in source fills and residency stubs (resources/Table.ts). (1) residencyStub(record) never copies the primary key, returns null when there are no secondary indexes and no createdTime, and evaluates computed getters (resolvedAttributeNames); callers _writeUpdate, the getFromSource sourceWrite, and demoteReplica (which patches the key by hand). transformEntryForSelect trusts the key on a stub, so an index-only select returns id undefined, and a null stub is stored as value = null. Change to residencyStub(record, id): skip resolvedAttributeNames, always return an object with stub[primaryKey] = id; update the callers and drop the manual patch. (2) getFromSource calls updateIndices before stamping @updatedTime/@createdtime and before the residency decision: a first fill indexes nothing for timestamp indexes, a revalidation deletes the existing entries, an excluded-residency fill leaves orphan index entries, and resolve(resolvedEntry), the noCacheStore return and modelHooksBefore see no timestamps. Stamp both timestamps and decide residency before updateIndices (pass omitLocalRecord), and stamp before resolve/noCacheStore. (3) The refill updateIndices has no preserveComputed, so resolver(stub) throws when a computed resolver reads a non-indexed input: on an INVALIDATED refill skip resolvers on the existing-record side only (stale old computed entries stay with #2370/#3363). Open PR #3529 edits the same sourceWrite.commit block; rebase accordingly.
Acceptance
Tests on rocksdb and lmdb: patch an INVALIDATED stub / REPLICA row then index-only select returns the real id with zero source calls; a table with no secondary indexes stores {id} not null; demote on a @computed(indexed:true) table does not throw; first fill and revalidation keep exactly one correct entry per @createdTime/@updatedTime index; noCacheStore get returns both timestamps; residency-by-id exclusion leaves no index entry; refill of @computed(indexed:true) over a non-indexed input does not throw. Show each new test failing on origin/main sources (forced rebuild) before the fix.
Dispatch: task
harper-getfromsource-stamp-order-residency-stub· queued by finding-triage/wave-40 · ran by claude/opus/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=14; full=7 @ 6331bd6
Review-Attention: deep ~40m (critical: Table.ts, HierarchicalNavigableSmallWorld.ts; decisions: id-based-omission-keeps-stale-row, stub-computed-old-value-is-a-guess, fill-stamps-before-resolve, no-index-invalidation-null-stub, pool-refusal-only-rethrow, do-less-alternative, release-target-vs-evidence) @ 6331bd6