Repository navigation
fix(fetchium): close the snapshot, hydration and event-persistence regressions on the perf stack - #73
Merged
Conversation
abedell-phantom
added this pull request to stack #72
October 6, 2026 05:08
abedell-phantom
marked this pull request as ready for review
October 6, 2026 15:27
…ked entity writes Extends the QueryStore contract with optional methods the client uses to keep write skipping correct. A store without them keeps the pre-0.6 behavior (every apply writes the whole record). - onDelete and the new onPersisted return an unsubscribe. - isSettled(): whether every write handed to the store has been applied; while false the client does not skip entity writes. - hasEntity(key): whether the store holds a record, so a streamed event can refresh the record of an entity that is no longer in memory. - mergeEntity(key, fields, refIds): merges the fields streamed events carried over the stored record (mergeStoredRecord in stores/shared.ts), deriving the references from the merged value. SyncQueryStore shares delete listeners between instances over the same kv, answers hasEntity from the kv and implements mergeEntity. AsyncQueryStore writers acknowledge processed SaveEntity messages, report a failed one as a delete, answer hasEntity from the keys read at start, run purgeStaleQueries through the serial queue, drop malformed channel messages and keep a persisted LRU size for messages without maxCount. Both stores resize a persisted LRU queue into a new buffer instead of throwing (growing) or orphaning entries (shrinking), never evicting the key being activated, and stop loading forever on records that reference each other in a cycle. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…gressions A differential hunt against the tree before the snapshot fast path found 17 client-observable regressions from the snapshot fast path, unchanged- apply skipping and single-write events. This closes them on top of the synchronous hydration and fast delivery work: - Dev snapshots are frozen, so a consumer that mutates one fails at its own call site. A detected snapshot drift is served from the re-read and raised out of band (the client's log.error, then an uncaught error), never from inside a snapshot, where it escaped Signalium's watcher flush and stalled every consumer on the page. The fast path compares with Object.is, pairs rebuilt Sets in linear time, and splits static and dynamic fields per client so two classes sharing a typename stay fresh. - Hydrating a cached query never re-parses an entity already in memory. The in-memory data must structurally satisfy the query's shape; if not, a CachedEntityMismatchError lands in hydrate()'s catch, which drops the cached query, clears updatedAt and fetches. Entities built from streamed events are completed from their record. The parser no longer writes into the objects it is given. - Streamed events write the existing entities they changed and the entities they created once a written record references them (root before routing, children first, deferred writes for back references, unrouted roots evicted with everything they created). Event-built entities merge only the fields their events carried through the store's mergeEntity; records the store holds but that are not in memory are refreshed the same way. - _persisted is set only after a successful write (or the store's acknowledgement), so a write that threw is retried by the next apply, and no write is skipped while an async writer has work queued. - destroy() removes the store listeners it registered; late query extras notify a shared root entity; hot-reloaded proxies are walked; an entity listed twice in one payload is applied once; a shared-typename narrowed array consumes the members it leaves out. 55 tests added (skip-write-and-snapshot-fixes, event-persistence); the entity-snapshot, same-value, entity-write-counts and parsing tests follow the new contract (drift via the handler hook, frozen snapshots, NaN/-0, merges counted as writes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… unused Set Moving the static/dynamic key split to a per-client cache made every entity snapshot re-resolve its key lists through a WeakMap lookup, so a list of N entities paid N lookups per re-render. A cache's entries are never overwritten, and the client replaces the whole cache when a typename gains a class, so comparing the cache's identity is enough to know whether the lists can have changed. applyMutationEvent allocated the set of created entities for every event; only an event whose root is new uses it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- An entity built from events stops being partial once its fields are known to be the whole record: the store held no record of it when it is first written (hasEntity), or its events carried every field of its shape. Until now every later write of such an entity was a merge that read the stored record back and parsed it, roughly halving the apply rate of updates to stream-born rows. An entity over a record the store holds keeps merging. - With an AsyncQueryStore writer, one entity's queued write no longer turns write skipping off for every entity. The store reports only queued work that can delete a record (new optional `hasQueuedDeletes()`), which excludes the client's own entity writes; an entity whose own write is still queued counts as persisted; and an entity a queued write stops referencing is written again, rather than trusted, when it is referenced again before that write lands. A record that turns out stale (a failed or discarded write) is no longer marked persisted by an older write's acknowledgement. - A create event that no live collection routes is routed once (the dry run's), not twice, and the duplicate-slot check's entity lookup is reused instead of repeated. - Dev builds skip the snapshot key-list check for frozen snapshots, which every snapshot handed out in dev is. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…unchanged fetch data and asking the store twice A perf review of the fix found three costs it had added to the happy path. - A query whose result is an entity notified that entity when it attached its extras (__refetch, __hasNext, __isFetchingNext). Every existing consumer of the entity re-ran, and its list's snapshot got a new identity, whenever a detail query for it first applied, even with identical data. The notify is gone. The query's own consumers get the proxy through the relay and snapshot it afresh, so they still see the extras; other consumers see them on their next recompute, as before the fix. - The parser copied every nested t.object and t.record of every fetch (15-21% on a refetch of 1000 entities with nested values). A fetch result or a cached record is now copied only where a parsed value differs from the input, so unchanged nested data costs no allocation. Arrays work the same way (an array of primitives is no longer rebuilt on every fetch); an empty one is always a new array, since a live array grows the array it holds. Event payloads and mutation effects, which the caller may still hold, are still parsed into copies. The parser still never writes into its input: a reified value is written back only when it changed, and a merge into a nested value the adapter froze goes into a copy. - A streamed create asked the store hasEntity even when a live array was about to retain the root, and asked again in save() for an entity built from a partial event. The store is now asked only for a root nothing retains, and save() reuses that answer: routed creates make no kv.has call (a partial one makes one instead of two). An unrouted create keeps its one call; that is what refreshes the record of an entity on disk but not in memory. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ade brings Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d every field of its class Two entity classes can share a typename: a list class, and a detail class that declares more fields. When only the list class was registered in a session, a streamed update for an entity not in memory was parsed with the list class alone. The event carried every field of that class, so the entity was marked whole and written with saveEntity, which replaced its record and deleted the detail class's fields. At the next launch the detail's cached query could not hydrate and waited for the network. The same happened to an entity an event created under a root in memory. Covering every field of a class does not show that the event covers the record: another class, possibly one not registered this session, may have written more fields to it. An event-built entity now stays partial and is merged into its record. It still becomes whole when the store holds no record of it at its first write. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
abedell-phantom
force-pushed
the
fix/perf-stack-regressions
branch
from
October 6, 2026 17:01
3fa63ba to
8be1a63
Compare
…s parent With an AsyncQueryStore writer, the parent's record must not reference a child created by an event until the child's record has been written. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n unsubscribe - A failed entity write asks the delegate whether the previous record is still there, instead of assuming it is gone, so hasEntity() keeps answering true and later events for the entity still reach it. - Store listeners are notified over a copy, so a listener that unsubscribes while being notified no longer makes the next one miss the event. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ore can't answer A failed write whose has() recheck also fails now retries, then leaves the key held. An event refresh is sent as an ifStored merge, which the writer drops and reports when no record exists, so a wrongly held key never writes partial fields as a record. Events for entities not in memory are refreshed during the writer's key scan and from readers again. A throwing store listener is logged instead of stopping the writer or a deletion cascade. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jimmy-phantom
approved these changes
Oct 7, 2026
Comment on lines
+413
to
+417
| `[fetchium] stale entity snapshot: ${source.instance.typename}:${source.instance.id} ${what} while version ` + | ||
| `stayed at ${source.instance.version}. The re-read value is being served, but a production build would keep ` + | ||
| `the stale one. Either the field was mutated without notify(), the shape's static/dynamic split is wrong ` + | ||
| `for it, or a consumer mutated a snapshot it received (dev snapshots are frozen so that fails at the call ` + | ||
| `site; in production they are not). Raised once per field.`, |
Collaborator
There was a problem hiding this comment.
I don't think the error message should carry such a long description with it, although this looks like a pre-existing problem.
| * The write may delete the child's record after the current apply, so it is | ||
| * rewritten rather than trusted. Sync stores report deletes via `onDelete`. | ||
| */ | ||
| private writeDropsRef(child: EntityInstance): void { |
Collaborator
There was a problem hiding this comment.
This sounds like a boolean check and is confusing to me at callsites
E.g.
if (persist) this.writeDropsRef(child);
Maybe something likeinvalidateChildPersistence would be clearer.
| * `ifStored`: a merge only refreshes a stored record, never creates one. | ||
| */ | ||
| save(storeHolds?: boolean, ifStored?: boolean): void { | ||
| const client = this._queryClient; |
Collaborator
There was a problem hiding this comment.
This function now does multiple things and it's hard to figure out exactly what it's doing just by looking at it. The cleanest would be to use named helper functions, but even just having clear section headers like // Save children before their parent (rather than the long and rambling thing the agent wrote), // Defer incomplete entities, etc. would help.
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.
Base:
perf/fast-delivery(#71,73c8aba). Branch:fix/perf-stack-regressions, head3fa63ba. Eight commits.Why
The perf stack (#67 → #64 → #61 → #69 → #70 → #71) gets its speed from the entity snapshot fast path, from skipping notify and persist when applied data is unchanged, and from writing an entity once per streamed event. A differential hunt against the tree before the snapshot fast path found 17 regressions a client can observe, R1 to R17. Some come from those changes and some from how they interact with #69's synchronous hydration. The fix was first written against
main+ #61 + #64. This PR is that fix merged onto #71's head. It keeps every one of the stack's performance mechanisms.The ones most likely to hit an app:
create/updateevents and mutationeffectsstopped refreshing the persisted cache. On a cold start, items vanished or showed old values.Commits
89f54d6fix(fetchium): optional store hooks for acknowledged, merged and checked entity writesonPersisted,isSettled,hasEntity,mergeEntity, and an unsubscribe returned fromonDelete, all optional onQueryStore. A store without them keeps the old behaviour: every apply writes the whole record. Compiles and passes the unit suite on its own.331d9dbfix(fetchium): close the snapshot, hydration and event-persistence regressionsgit apply --3way. 24 files applied cleanly,QueryClient.tsincluded, so #69–#71's lease, stagger and retry code sits next to the fix'sapplyMutationEventrewrite. 3 conflicts were resolved by hand (below).947c88cperf(fetchium): skip the per-snapshot key-cache lookup and an event's unused Set768b137perf(fetchium): take the extra store work back out of streamed eventsmergeEntitythat read the stored record back and parsed it twice: about half the apply rate. It is now written whole once its fields are known to be the whole record: the store held no record when it was first written. (This commit also treated events that carried every field of the entity's class as the whole record; the last commit takes that back, see below.) (2) With theAsyncQueryStorewriter, any queued write turned write skipping off for every entity (!isSettled()), so identical events during a burst all wrote again. The store now reports only queued work that can delete a record (new optionalhasQueuedDeletes(), which excludes the client's own entity writes); an entity whose own write is queued counts as written; an entity a queued write stops referencing is rewritten if referenced again before that write lands; and a stale record can no longer be marked persisted by an older write's acknowledgement. (3) An unrouted create is routed once, not twice, and the duplicate-slot check's lookup is reused. (4) Dev builds skip the snapshot key-list check for frozen snapshots.2c42511perf(fetchium): stop re-running a shared entity's consumers, copying unchanged fetch data and asking the store twicet.object/t.recordof every fetch (refetch of 1000 entities with nested values: −15…−21%). A fetch result or cached record is now copied only where a parsed value differs, arrays included (an array of primitives is no longer rebuilt each fetch; an empty one is always new, because a live array grows the array it holds); event payloads and mutationeffectsstill always are. The parser still never writes into its input: a reified value is written back only when it changed, and a merge into a nested value an adapter froze goes into a copy. (3) A streamed create asked the storehasEntityeven when a live array retained the root, and a partial one asked again insave(). Routed creates now make nokv.hascall (partial: one instead of two); an unrouted create keeps its one.chore(fetchium): major changeset listing the behavior changes an upgrade brings(.changeset/upgrade-behavior-changes.md). No source change.f83dfb5fix(fetchium): keep merging an event-built entity whose events carried every field of its class768b137added, found by the third review. With two classes on one typename (a list class and a detail class with more fields) and only the list class registered in a session, a streamed full update for an entity not in memory was parsed with the list class alone, counted as covering the record and written withsaveEntity, which deleted the detail's fields: at the next launch the detail's cached query could not hydrate and waited for the network. The same happened to an entity an event created under a root in memory. An event-built entity now stays partial and merges into its record unless the store held no record of it at its first write. Covering a class's fields says nothing about fields another class, possibly one not registered this session, wrote to the record.chore(fetchium): shorten this change's changesets for app developers:.changeset/skip-write-and-snapshot-fixes.mdand the major upgrade notes rewritten as short, user-facing bullets. The full detail stays in this description. No source change.chore(fetchium): rewrite this change's changesets for React developers: the same two files, rewritten again for an app developer. The upgrade notes now list breaking changes only, and add one line from #74: code that awaits a query whose last reader unmounts before its first data now waits for the next mount,gcTimecollection ordestroy()instead of getting anAbortError. No source change.Conflicts resolved by hand in
331d9db:QueryResult.ts. perf(fetchium): hydrate synchronously from a sync store #69'shydrate()catch already clearsupdatedAtand discards a corrupt entry, so the fix's own catch hunk was dropped. The fix throwsCachedEntityMismatchErrorfrom insidehydrate()'s try, so a cached query whose in-memory entity doesn't match its shape is dropped and fetched. The R17 notify after query extras are attached applied as is;2c42511removes it.destroy()wording and keep perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71's prefetch/retain table, its "useQuerynever suspends" bullet and itsuseSuspenseQuerysection.What changes, by area
EntityInstance.ts).sort()in render, now fails at its own line (R4).log.error, then as an uncaught error. It no longer throws inside a snapshot (R1).Object.isplus a structural fallback (R3).parseEntities.ts,QueryResult.ts).effectsare parsed into copies. A fetch result or cached record is copied only where a parsed value differs from it, and a later merge into a nested value an adapter froze goes into a copy.applyEntities.ts,QueryClient.ts,EntityStore.ts,LiveCollection.ts).mergeEntity.hasEntity) is refreshed even when its entity isn't in memory (R6, R7)._persistedis set only after the write succeeds (R8).AsyncQueryStorewriters acknowledge their writes and report failed ones (R10).destroy()unsubscribes the store's delete listener (R14).SyncQueryStoreshares delete listeners between instances over one kv (R15).The changesets are
.changeset/skip-write-and-snapshot-fixes.mdand.changeset/upgrade-behavior-changes.md(major: the upgrade notes for the whole stack).Verification
Trees:
base=mainbb255caon Signalium 3.0.3.stack= #71 on Signalium #254.fix= this branch on Signalium #254.2c42511): unit 1454 passed / 2 skipped (+1: a frozen fetch result applied without a write and merged later; the R17 test now asserts the query's own consumer sees the extras and the other consumer is not re-run, and fails with the notify back). Review probes, dev and prod, against768b137: onlyguard-misc› "late-attached query extras…" andhy-shared-entity› H9/R17 change (pass → fail, the R17 revert above).zz-vp2-shared-root: 0 added row runs, 0 added list snapshots, identity kept (was +1/+1/lost).zz-vp2-store-ops: routed create 0kv.has(was 1), partial routed create 1 (was 2), unrouted 1.f83dfb5): unit on Signalium 3.0.3 1456 passed / 2 skipped, browser 114/114; on Signalium #254 unit the same, browser 112/114 (the two #252 counts). New tests inevent-persistence.test.ts(2): an update for an entity not in memory, and an entity an event builds under a root in memory, each with only the list class registered in the session: the record keeps the detail's fields and the detail's cache is served at the next launch. Both fail on2c42511. Review probezz-vc31T4/T5 pass in dev and prod (fail on2c42511); the kit and every earlier probe give the same results as on2c42511.2c42511): a full-payload update for an entity not in memory whose record the store holds, single-class typename, −16…−26% (vp3 257k → 216k, W1 250k → 184k): each such event merges again, reading and parsing the record, instead of replacing it. Two-class typenames and in-memory events are unchanged (within ±3%). The next PR's merge cache takes the repeated read out, and the PR after it writes such an event whole again when no other class of the typename declares more fields.hasEntityand record write, inherent).skip-write-and-snapshot-fixes.test.tsandevent-persistence.test.tspass 55/55. 50 of them fail on perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71.component.test.tsx, the same as on perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71.base, after =stack, fixed = this branch): the output is byte-identical to the reference tables in the fix kit.NaNrule and the frozen-snapshot dev contract.store-h1-subtree9/9,store-h2-baseline8/8,live-event-routing10/13 (the remaining 3 are in the reference's intended list)guard-hydrate6/6. Inhy-shared-entity, H8 and H9 pass. Inrx-shared, S8 and S8b now show no errors and keep updating; their assertions still encode base's "the cache overwrites memory" and its loading frame.store-h11-flaky1/2 (matches the reference)cd-narrowing2/2,store-h4-multiclient3/3,api-module-instances2/2,guard-misc9/9guard-nan7/7,snap-nan6/6 (dev too),guard-shared-typename1/1.snap-consumerandguard-consumer-mutationmatch the frozen-snapshot contract.async-race10/10.async-roles7/9,async-maxcount-upgrade2/3 andapi-async-maxcount1/3 match the reference.tsc, eslint and prettier: clean on every changed file.Bench
npm run bench(snapshot.bench.ts), hz, median of 6 interleaved runs on Apple Silicon:All within noise. The cold rows are ±10–15% rme.
With the default ~100 ms warmup, the first case in the file has read 7–12% below #71 in some sessions. With a 1 s warmup it does not reproduce (+2.7%), and CPU profiles of the two trees have the same shape. It looks like JIT tier-up timing, not steady-state work.
The fix adds this work to the hot path:
Object.isinstead of===in the snapshot compare.Object.isFrozenper in-place nested merge._applyingtry/finally, aparseIdcheck (reusing the entity lookup) and, only when nothing changed, astoreIsSettled()call.for…inover its shape keys. Per created root no live array retains: onehasEntitycall, whose answer the write reuses.Intended changes a client will notice
TypeErrorat its own call site in dev. Prod snapshots are not frozen, but should be treated as read-only.log.error, then an uncaught error) instead of from inside the snapshot.NaNno longer notifies, and-0over0is applied.mergeEntity, an entity built from events writes only the fields its events carried, merged over its record, until a fetch replaces it or the store turns out to hold no record of it. An event that carries every field of the entity's class is still merged, so fields another class of the typename wrote survive. A store without the new hooks writes every apply, as before 0.6.Residuals, deliberately left
hy-shared-entity,hy-react-hydrationandrx-shared. This PR replaces that behaviour with trusting memory, on purpose.hasEntitycall (kv.hason MMKV, one JSI call). That is what lets an event for an entity on disk but not in memory refresh its record (R7). The second perf review measured an unrouted create −25…−28% against perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71, about two-thirds of it that call; for a high-rate stream of creates nothing routes (a price feed), that is the price of R7. A routed create no longer asks the store.__refetch,__hasNext, …) on its next recompute.guard-misc› "late-attached query extras…" andhy-shared-entity› H9/R17 fail again, in dev and prod, and the differential tables differ from the kit's reference by those rows.TopicQueryAdaptertopic's fulfilled data, an adapter caching its last response) can now share an unchanged nested object between the entities that apply it, and a later in-place merge changes that object for all of them, as on perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71 and every earlier version. Event payloads are not affected.t.optionalset tonullby an event for an entity that isn't in memory keeps its stored value.flushWatchersshould still catch errors per signal.useSuspenseQueryholds. They are fixed in the next PR in the stack (fix/stack-timing-and-reactivity).🤖 Generated with Claude Code