Skip to content

fix(message-store): a sibling's appends no longer cost a reader the whole slot (#251) - #260

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/251-sibling-append-tail
Oct 19, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/251-sibling-append-tail

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

For #251. A MessageStore re-materialized its whole message slot at its next read after a sibling MessageStore on 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 through MessageStore, as the cache's revalidation already does, and "Changes" says what a write outside it can now do.

Problem

getAllInternal checks the branch and the write version the store's MessageStores 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. With CM_CACHE_DIAG=1, each such read logs a line such as getAllInternal 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 getAllInternal after one sibling append took 202–272 ms on main. Its getAll() 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). getStateSlice of 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

  • getAllInternal reads 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 any MessageStore on the store. bumpWriteVersion's doc now says so. appendedSince reads the new records by point lookup, and getAllInternal pushes them onto the cache and stamps it with the current version and sequence. Anything else reloads as before:
    • a sibling's edit or removal;
    • a branch switch;
    • a slot whose length the versions don't account for;
    • a record that doesn't read;
    • a chronicle without point lookup;
    • a failed revalidation with no version moved.
  • The cached records aren't looked up to confirm them. On chronicle 0.4.0 that lookup would cost the per-item rebuild at every read after a sibling's append.
  • append keeps 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's CM_CACHE_DIAG line now says append write-through deferred (cache wasn't current before the append), and test/manager-diagnostics.test.ts follows it.
  • The contract this relies on. Like the revalidation by count and last record, the change 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 slot's length just what the versions predict. The reader then extends a stale cache, and an edit or remove through its index can reach another record, where main's version check reloaded. Nothing writes the slot otherwise today:
    • context-manager's src writes it only through MessageStore;
    • agent-framework e01a2337 and connectome-host c7137862 write no message slot directly;
    • chronicle 0.4.0 refuses a second open of a store, in the same process or another ("Store is locked by another process"), so the version, kept per store object, counts every write through MessageStore.
  • A changelog fragment, which states the contract too.

This meets #238. Merged with #238 at bce0c8b2, this change conflicts in append's write-through, beside the seven places in src/message-store.ts where #238 already conflicts with main. getAllInternal merges with no conflict marker. There, #238's slotRevision takes the write version's place, so the merged code passes the difference of two slotRevision values to appendedSince as the count of appends. #238 drops the edit version and keeps sharedWriteVersions as slotRevision's fallback for a chronicle without stateHeadOffset, so on chronicle 0.4.0, which has none, that difference is still the count. Against a chronicle with stateHeadOffset (chronicle #40), slotRevision is 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 under stateHeadOffset if 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

  • The length check against the versions moved. It is the test for "only appends since" through 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.
  • The point lookups of the appended records. They read only what's new. After two or more appends, the first lookup pays chronicle's per-item rebuild, and the rest are cheap.
  • The branch check. A cache built on one branch is never extended with another branch's records, even where the lengths happen to agree.
  • Running only when the version moved. With no version moved, a failed revalidation by count and last record reloads, as on main, rather than reading "nothing appended" as a current cache.
  • appendedSince's refusals. A record that doesn't read, or a chronicle without getStateItemJson, reloads. Without the second, getInternal would fall back to getAllInternal and recurse.
  • Restamping the version and sequence after extending. The next read with no new write then returns the cache without a lookup.
  • Keeping a stale cache in append. Without it, a reader's own append after a sibling's drops the cache, and its next read reloads the slot whatever getAllInternal does.
  • The diagnostics. 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.
  • The tests' helpers. siblings() builds the reader and its sibling on one store, and texts() reads a listing's text. pointLookupsDuring() records which records a read looks up, which is what pins the point lookups above.
  • The fragment says what hosts will notice, the contract included.
  • Cut. A separate edit-version check beside the length check. Through 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 outside MessageStore, 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 main 7f963c75, on Linux with Node 24.22.0 and chronicle 0.4.0 from the lockfile (npm ci):

The new tests, in test/message-store-sibling-append.test.ts, count whole-slot reads (getStateJson) and point lookups:

  1. A sibling's two appends are read with no reload, in order, and the read looks up only those two records. The next read neither reloads nor looks anything up, and a lookup by id finds an appended record with no reload.
  2. A reader's own append after a sibling's, its own edit and its own removal, each followed by a sibling's append, cost no reload.
  3. A sibling's edit, then its append, costs one reload, and the reader sees the edit.
  4. A sibling's removal, then its append, costs one reload, and the reader sees the removal.
  5. A slot whose length a write outside MessageStore cut, with a sibling's append, costs one reload and reads as the slot does.
  6. An appended record that doesn't read by point lookup costs one reload, not a short cache.
  7. A chronicle without point lookup reloads without recursing.
  8. With no version moved, a last record that isn't the cached one costs one reload, as the revalidation by count and last record gives it.
  9. A cache built on one branch isn't extended on another branch, where a write outside MessageStore replaced 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 and main's (medians of 11 rounds each):

  • One sibling append between reads. getAllInternal took 0.012–0.022 ms with the change, against 202–272 ms on main. getAll() after it took 0.37–0.62 ms with the change, and 4.3–6.2 ms on main.
  • Three sibling appends between reads. getAllInternal took 74–116 ms with the change, against 136–242 ms on main. 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-framework 8225e4bf, not from a run. The one-append figure comes from these probes, not from a host.

Not verified

  • A host's own read pattern. The timings come from probes of two 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.
  • Size. Only short messages written by the probes, at most 30,111 in a slot.
  • The timed code. The Bun timings ran on this change's logic at an earlier round, whose message-store.ts differs from this head's only in comments and diagnostic text.

Out of scope

  • Chronicle's per-item rebuild after a write, which a read after two or more appends still pays. Chronicle's open release context-manager 0.5.10 #40 describes its per-item cache "advanced by a write instead of dropped".
  • The cost the API docs give for getWindow, getMessageWindow and 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.
  • A bound on how many appended records a read looks up. One lookup per record cost less than the reload, or about the same, at every count measured above, so a cut-over to a reload would add a branch for no measured gain.
  • The id index. A lookup by id after a sibling's write still rebuilds its map from the extended cache, O(slot) in map entries, with no reload.
  • getAll()'s mapping. It still loops over every message after a sibling's write, reusing each earlier mapped object.
  • Guarding edit and remove against 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.
  • Typing getAll()/getTail() as readonly, which the issue also suggests. It isn't a defect, and callers would meet the change.

  • Changelog fragment added: 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-seru account.

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling.

…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>
@greptile-apps

greptile-apps Bot commented Oct 19, 2026

Copy link
Copy Markdown

Required label not found on this PR.

@nissa-seru
nissa-seru merged commit dc4ac86 into anima-research:main Oct 19, 2026
7 checks passed
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>
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