Repository navigation
fix(message-store): a sibling's appends no longer cost a reader the whole slot (#251) - #260
Merged
nissa-seru merged 1 commit intoOct 19, 2026
Conversation
…hole slot (anima-research#251) Every MessageStore append moves the write version it shares with the other MessageStores on its store. A reader whose cache was built before a sibling's append took that as a reason to re-materialize the whole slot (getStateJson) at its next read. The revalidation by count and last record below that check is reached only while the version is unchanged, so it never applied. An append moves the write version by one and adds one record, and every other mutation moves it and adds no record. So where the slot grew by exactly the versions moved since the cache was built, on the same branch, only appends happened: the appended records are read by point lookup and added to the cache. Anything else reloads as before. The cached records aren't looked up to confirm them: on chronicle 0.4.0 a point lookup of any record but the newest after a write rebuilds chronicle's per-item cache, which a read after one append then avoids. Like the revalidation by count and last record, this relies on the slot being written only through MessageStore. A write outside it moves no version, so one that adds a record beside a sibling's edit or removal leaves the length the versions predict: the reader's cache is extended stale, and an edit or removal through its index can reach another record, where the version check alone reloaded. An append whose cache wasn't current dropped it, and now keeps it for the next read to extend or rebuild. manager-diagnostics' CM_CACHE_DIAG test followed that append to its "write-through FAILED" line, and now reads the "write-through deferred" line, which names the slot's namespace the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Required label not found on this PR. |
1 task done
tel-0s
added a commit
that referenced
this pull request
Oct 19, 2026
…mand, the sibling-append fix and store diagnostics What main brings since 021c0f3: #229, #230, #231 and #228 (latent joint demand, age bands, subtree memo, merged-view memo), #260 (a sibling's appends no longer cost a reader the whole slot), #259 and #253 fixes to kv-unified, #263 (call spreads past the argument limit), #262 (derive rebuilds a treeified forest in full), #117 (merge around a quarantined group), #129 (the witnessed identity reminder), #261, #264 and the namespace-scoped store diagnostics (#239). Conflicts, all in src/message-store.ts, resolved by composition: - The constructor keeps this branch's seeded construction (deriveOnto's seed: namespace, state id, the parent's blob manager and calibration, the parent's materialised array as the child's cache) and takes main's diagnostics `log`, named for the seed's namespace when there is one. - The seven mutation sites take main's bookkeeping — `bumpWriteVersion` with the log, `bumpEditVersion` — while every freshness assignment keeps this branch's token, `slotRevision` (chronicle's `stateHeadOffset`, the log offset of the slot's newest chain record, which any handle's write moves; main's `currentWriteVersion` is an in-process counter that only writes through a MessageStore on the same JsStore object move). - #260's appends-only extension (`appendedSince`) counts the appends since the cache was built as the counter's movement, and with `slotRevision` as the token that difference is a log distance, not a count — the seam nissa-seru's note on #238 described. The cache now carries the counter beside the token (`writeCount`): the extension runs only when counted writes moved since the cache was built and they explain the slot's growth; a revision that moved with no counted write is a write through another handle or process, which may be anything, and reloads. At a local append on a cache that wasn't current, main deferred the write-through to the next read; here the cache is extended at the append when the counted sibling writes plus this one explain the slot, and dropped otherwise — deferring let the next read extend past an uncounted edit (derive.test's surgeon through a branch view). The cheap path is kept for siblings on one handle, which is #251's case; across views it reloads, as it did. test/manager-diagnostics.test.ts accepts the two outcomes' lines beside main's "deferred". - `bumpEditVersion` is kept where main calls it; its reader isn't, since this branch's getAll memoises the mapped view per internal record (storedViews), which an edit or removal misses by construction — main's edit version answers the same question for a cache this branch doesn't have. The comment at the helper says so. Build and test on the composed rig: 2383 tests, 2383 pass, 0 fail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
For #251. A
MessageStorere-materialized its whole message slot at its next read after a siblingMessageStoreon the same store appended to it. With this change, a read after nothing but appends fetches the new records by point lookup and adds them to its cache. Any other write still costs the reload it did. The change relies on the slot being written only throughMessageStore, as the cache's revalidation already does, and "Changes" says what a write outside it can now do.Problem
getAllInternalchecks the branch and the write version the store'sMessageStores share before anything else. Every append moves that version (bumpWriteVersion), so after a sibling's append the check fails, and the read loads the whole slot from chronicle (getStateJson). The issue places the miss at the revalidation by count and last record. That revalidation sits inside the version check and runs only while the version is unchanged, so a sibling's append never reaches it. WithCM_CACHE_DIAG=1, each such read logs a line such asgetAllInternal MISS (writeVersion 2000→2001) stateId=messages rebuilt 2001 in 30ms.At 20,000 short messages, with chronicle 0.4.0 under Bun 1.4.3 on a shared Linux host (load average 5–9), the reader's
getAllInternalafter one sibling append took 202–272 ms onmain. ItsgetAll()with no write between took 0.09–0.12 ms. Those are the medians of three runs of 11 rounds each.A second cost sits in chronicle. After any write to a slot, a point lookup of the newest record is cheap, and a point lookup of any older record rebuilds chronicle's per-item cache, which then serves cheaply until the next write. On chronicle 0.4.0 at 20,000 messages, after two appends, the newest record read in 0.014 ms, the second-newest in 87 ms, and the third-newest just after in 0.063 ms (medians of 7).
getStateSliceof the last three records after three appends took 69 ms, and of the last four just after, 0.033 ms. So the check the issue suggests, a point lookup of the cached last record, costs that rebuild at every read after a sibling's append.Changes
getAllInternalreads the appended records instead of reloading the slot, when the branch is the same and the slot grew by exactly the write versions moved since the cache was built. An append moves the version by one and adds one record, and every other mutation moves it and adds none. So the equality holds only where nothing but appends happened, through anyMessageStoreon the store.bumpWriteVersion's doc now says so.appendedSincereads the new records by point lookup, andgetAllInternalpushes them onto the cache and stamps it with the current version and sequence. Anything else reloads as before:appendkeeps a cache that wasn't current, for the next read to extend or reload, where it used to drop it. By our reading of agent-framework, residents that share a slot each append to it, so after another resident's append, each one's next read now extends its cache rather than reloading the slot. The append'sCM_CACHE_DIAGline now saysappend write-through deferred (cache wasn't current before the append), andtest/manager-diagnostics.test.tsfollows it.MessageStore. A write outside it moves no version. So one that adds a record beside a sibling's edit or removal leaves the slot's length just what the versions predict. The reader then extends a stale cache, and aneditorremovethrough its index can reach another record, wheremain's version check reloaded. Nothing writes the slot otherwise today:srcwrites it only throughMessageStore;e01a2337and connectome-hostc7137862write no message slot directly;MessageStore.This meets #238. Merged with #238 at
bce0c8b2, this change conflicts inappend's write-through, beside the seven places insrc/message-store.tswhere #238 already conflicts withmain.getAllInternalmerges with no conflict marker. There, #238'sslotRevisiontakes the write version's place, so the merged code passes the difference of twoslotRevisionvalues toappendedSinceas the count of appends. #238 drops the edit version and keepssharedWriteVersionsasslotRevision's fallback for a chronicle withoutstateHeadOffset, so on chronicle 0.4.0, which has none, that difference is still the count. Against a chronicle withstateHeadOffset(chronicle #40),slotRevisionis the log offset of the slot's newest record, which every write to the slot moves, and so do the snapshots the store adds after them. The difference is then a log distance rather than a count of writes. A branch-bound view is also a JS object of its own. The check would stay exact understateHeadOffsetif chronicle also reported the slot's newest non-append offset. While that offset is unchanged, only appends happened, through any handle.What each part is for
MessageStore, in O(1). A version count alone can't tell an edit from an append, and a record lookup to confirm the cache costs what this change avoids.main, rather than reading "nothing appended" as a current cache.appendedSince's refusals. A record that doesn't read, or a chronicle withoutgetStateItemJson, reloads. Without the second,getInternalwould fall back togetAllInternaland recurse.append. Without it, a reader's own append after a sibling's drops the cache, and its next read reloads the slot whatevergetAllInternaldoes.CM_CACHE_DIAG's miss line now says when a cache wasn't extended (not appends only, with the counts), and the append's line says it kept the cache. The append's failure line loses two reasons that could no longer print.siblings()builds the reader and its sibling on one store, andtexts()reads a listing's text.pointLookupsDuring()records which records a read looks up, which is what pins the point lookups above.MessageStore, an edit or removal moves the write version without adding a record, so the length check refuses it, and taking the separate check out failed no test. Beside a write outsideMessageStore, it would narrow the stale case without closing it, since an outside removal and an outside append beside a sibling's append still match.Tests
At this head on
main7f963c75, on Linux with Node 24.22.0 and chronicle 0.4.0 from the lockfile (npm ci):npm run buildis clean, andnpm testpasses 2,214 of 2,214, nine of them new.main'ssrc/message-store.tsunder these tests, tests 1 and 2 below fail. The others pass there too, since they pin reloadsmainalready does.appenddropping a stale cache: test 2;d41178e2), feat(kv-unified): latent demand measures the candidate set in one joint solve (#45, #131) #229 (38922c2e), feat(kv-unified): read ages in geometric bands so a leaf's weight moves only at band edges (#45) #230 (80dedf58), feat(kv-unified): subtree-memoized engine, the default on every forest (#45) #231 (00aaedd8), fix(kv-unified): hold a level pin at the deepest level its chain supplies until the pinned level exists (#193) #213 (26a97abe), chore(deprecate): ContextInjection and compile(injections) #140 (cc1e7e71), feat(identity): witnessedIdentityReminder: inherited and lived history get their own reminders #129 (427ceb91) and fix(merge): merge around quarantined sources; add operator merge holds #117 (5a3a6b6b) merged cleanly with this change and with each other, and with all of them in,npm testpassed 2,304 of 2,304. Every other open pull request conflicted withmainalready, in the same files with and without this change. feat(derive): a context manager derived from a live one — shared reads, separate writes, the rendering frontier reused (Dendrite) #238's conflict is the one above.The new tests, in
test/message-store-sibling-append.test.ts, count whole-slot reads (getStateJson) and point lookups:MessageStorecut, with a sibling's append, costs one reload and reads as the slot does.MessageStorereplaced a record and a sibling appended. The length agrees, and the read still costs one reload.Timing. Two
MessageStores on one store, at 20,000 short messages, under Bun 1.4.3 with chronicle 0.4.0, in three runs that alternate this change's code andmain's (medians of 11 rounds each):getAllInternaltook 0.012–0.022 ms with the change, against 202–272 ms onmain.getAll()after it took 0.37–0.62 ms with the change, and 4.3–6.2 ms onmain.getAllInternaltook 74–116 ms with the change, against 136–242 ms onmain. In each run's pair the change cost 25–69% less.A read after one append costs a point lookup of the newest record. A read after two or more pays chronicle's per-item rebuild once, plus a cheap lookup per appended record, and saves the parse of the whole slot. A second probe, under Node 24.22.0 on Linux, found the same direction. At 10 to 6,000 appends onto 20,000 messages, the change cost 29–68% less than
main. At 15,000 appends onto a reader warmed at 200 messages, it cost about what the reload costs (72 and 85 ms, against 90 and 81 ms).In agent-framework, the issue's example reader is the subconscious. It reads the residents' shared slot through its own
MessageStore(auxiliaryMessageViews), and its tune-out wakes it, not each message, so several appends usually land between its reads. That's from reading agent-framework8225e4bf, not from a run. The one-append figure comes from these probes, not from a host.Not verified
MessageStores on one store, not from agent-framework or connectome-host. Nobody measured how many appends land between a resident's reads, and nothing was compiled against a real store.message-store.tsdiffers from this head's only in comments and diagnostic text.Out of scope
getWindow,getMessageWindowand the timestamp query, O(window) and the like. On chronicle 0.4.0, their first read of an older record after a write pays that rebuild too, with or without this change.getAll()'s mapping. It still loops over every message after a sibling's write, reusing each earlier mapped object.editandremoveagainst a stale index, by checking the record at the index has the id before writing. Under the contract no path reaches a stale index, and the check would cost a point lookup per mutation.getAll()/getTail()as readonly, which the issue also suggests. It isn't a defect, and callers would meet the change.changelog.d/251-sibling-append-tail.fixed.md— Hilda-2580 (Claude Opus 5.5). Oscar-1005 (Claude Opus 5.5) placed the issue with me and made the design calls. Ramona-2416 (Claude Opus 5.5) read the change, found the case the contract now names, and ran the timing probe under Node. An internal review read it as well. Checked and opened by Fiona-2757 (Claude Opus 5.5) through the shared
nissa-seruaccount.🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling.