perf(bindx): memoize the dirty-entity scan per store write version (#65) - #72
Closed
matej21 wants to merge 2 commits into
Closed
perf(bindx): memoize the dirty-entity scan per store write version (#65)#72matej21 wants to merge 2 commits into
matej21 wants to merge 2 commits into
Conversation
ChangeRegistry.getDirtyEntities() ran a full-store scan — deepEqual of data vs serverData for every snapshot, plus a reachability walk and a per-entity dirtyFields/dirtyRelations pass. usePersist feeds it to useSyncExternalStore, which React runs synchronously inside every store notification, and list reorder helpers emit one notification per reindexed item. A single delete in a large sortable list therefore cost O(N*M) full scans before React rendered. The result is now memoized per store write version. The key is deliberately NOT getVersion(): that is the subscription manager's globalVersion, bumped only inside notifying paths, and several dirtiness-changing writes do not notify — createEntity registers its root AFTER its last notification, registerParentChild un-registers a root silently, commitAllRelations and resetAllRelations never notify, and refreshServerData can skip notifying. Keying on it would serve a stale empty dirty set to the very first read, which happens synchronously inside createEntity's own notification. Instead getDirtyVersion() sums monotonic sub-store mutation counters, the pattern ReachabilityAnalyzer already uses: a new dataWriteVersion on EntitySnapshotStore plus the meta mutation/editable counters, the relation counter and the root registry counter. A sum of monotonic counters is strictly increasing on any bump, so an unchanged sum proves no dirtiness-relevant write happened, independent of whether anything was notified. getDirtyEntitiesNotInFlight() keeps filtering on every call, since in-flight state changes without any store write. This is the memoization fix only. Coalescing the notification storm and incremental dirty tracking are separate; a delete in an N-item list still fires ~N notifications, each now O(1) instead of a full scan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
#65) Review follow-up to the dirty-scan memo. The memo is only as correct as its key, and `dataWriteVersion++` was hand-maintained across 11 call sites in EntitySnapshotStore. A contributor adding a mutating method — or an early-return branch to an existing one — and forgetting the bump would break nothing loudly: the store would serve a stale dirty set for the rest of the session, and the user would see a dead Save button. Writes now go through writeSnapshot/deleteSnapshot, which own the bump, the same shape HasOneStore uses for writeRelation/deleteRelation. bumpVersion is the one documented bypass: it reuses the same data/serverData refs so it cannot change dirtiness, and it runs once per ancestor on every notification, so bumping there would keep the cache permanently cold. The chokepoint makes it hard to get wrong; the new guard test is what enforces it. It classifies every name on the prototype into mutating / non-mutating / internal and fails on anything unclassified, then asserts each mutating method moves the key and each non-mutating one does not — so a new method cannot be added without a deliberate decision about its bump. Also: hasDirtyEntities() shares the memo instead of running its own full scan; getDirtyVersion() is marked @internal and documents the constraint the memo now depends on — snapshot values must be replaced, never mutated in place, since createEntitySnapshot freezes only the top level. The memoization test no longer asserts that a mid-write read sees an empty dirty set. That pinned one of the known missing-notification bugs as expected behaviour and would have handed a red test to whoever fixes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
matej21
force-pushed
the
perf/dirty-entity-scan-memoization
branch
from
August 20, 2026 09:34
0ee113a to
79a2ce7
Compare
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.
Addresses the memoization half of #65. Deliberately does not close it — see Scope.
Problem
ChangeRegistry.getDirtyEntities()runs a full-store scan:deepEqualofdatavsserverDatafor every snapshot, a reachability walk, and a per-entitydirtyFields/dirtyRelationspass.usePersistfeeds it touseSyncExternalStore, which React runs synchronously inside every store notification, and list reorder helpers emit one notification per reindexed item. A single delete in a large sortable list therefore performs O(N·M) full scans before React renders.Why not key the memo on
getVersion()This is the interesting part.
SnapshotStore.getVersion()isSubscriptionManager'sglobalVersion, bumped only inside the notifying paths — and several dirtiness-changing writes do not notify:SnapshotStore.createEntityroots.register()runs after the last notificationSnapshotStore.registerParentChildroots.unregister(), no notificationcommitAllRelations/resetAllRelationsBatchPersisteron every persist success and rollbackrefreshServerData(…, skipNotify=true)HasManyListHandle/HasOneHandleThe first one alone is fatal:
usePersistsubscribes globally, so React runs itsgetSnapshotsynchronously insidecreateEntity's own notification — before the root is registered. A version-keyed memo would be populated with an empty dirty set at a versioncreateEntitynever bumps again, leaving the Save button permanently dead. Today's unconditional rescan hides this.Change
getDirtyVersion()sums monotonic sub-store mutation counters — the patternReachabilityAnalyzeralready uses:A sum of monotonic counters is strictly increasing on any bump, so an unchanged sum proves no dirtiness-relevant write happened — independent of whether anything was notified. The new
dataWriteVersionis required because neither existing counter suffices:mutationVersionmisses value edits, andeditableWriteVersiondeliberately excludes server-baseline writes.getDirtyEntitiesNotInFlight()keeps filtering on every call, since in-flight state changes without any store write.Scope
Memoization only. The issue lists three composable fixes; the other two — coalescing the notification storm, and incremental dirty tracking — are out of scope and the issue should stay open for them. The win is a factor of M (the number of mounted persist hooks), not of N. An earlier version of this description claimed each notification becomes O(1); that was wrong and is corrected below.
The four missing notifications above are real bugs in their own right. This change is correct despite them by construction, and deliberately does not fix them — but any consumer relying on
getVersion()or on a store notification to observe those state changes is still wrong.Tests
tests/unit/persistence/dirtyEntitiesMemoization.test.ts— 7 cases, written failing-first (2 failed before the change: no memo at all, and one full scan per call). The key itself is pinned, not just the presence of a cache: swappinggetDirtyVersion()back togetVersion()turns 3 tests red, including one that reproduces thecreateEntityhole directly.Verified:
tests/unit/store+tests/unit/persistence283 pass,tests/react326 pass, full CI suite 1545 pass, typecheck clean.Not verified
The test counts scans, not wall-clock — it proves the walk runs once per write version and says nothing about latency; there is no benchmark here. The memo also returns the same array instance within a version; all current consumers treat it read-only and the type is
readonly DirtyEntity[], but nothing enforces that at runtime.🤖 Generated with Claude Code
https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
Measured (this was previously asserted without measurement)
Issue #65's scenario: 100 blocks each with a rich-text JSON column,
removeFromHasMany+ 99setFieldValuefrom the order repair, M global subscribers reading inside every notification.cpu-lease run -n 2 --no-smt, 200 rounds, both paths warmed, two independent runs:Scans after are 100 regardless of M. The memo deduplicates the 2nd..Mth read within one notification and nothing else — every
setFieldValuein the repair loop legitimately bumpsdataWriteVersion, so the first read in each of the N notifications still scans. On a page with a single save button the win is ~1.0×, i.e. zero.This is exactly why #65 stays open: the memo is a prerequisite for the notification-coalescing fix, which is where N actually goes away.
Follow-up commit: guard the key
The memo is only as correct as its key, and
dataWriteVersion++was hand-maintained across 11 sites. Forgetting one — a new method, or a new early-return branch — fails silently: a stale dirty set for the rest of the session, surfacing as a dead Save button.writeSnapshot/deleteSnapshot, which own the bump — the shapeHasOneStorealready uses forwriteRelation/deleteRelation.bumpVersionis the one documented bypass (samedata/serverDatarefs, so it cannot change dirtiness, and it runs once per ancestor per notification, so bumping there would keep the cache permanently cold).EntitySnapshotStore.prototypeinto mutating / non-mutating / internal, fails on anything unclassified, then asserts each mutating method moves the key and each non-mutating one does not. Both halves were verified to bite. This is what actually stops the regression; the chokepoint just makes it hard to reach.hasDirtyEntities()now shares the memo instead of running its own full scan.getDirtyVersion()is@internaland documents a real constraint:createEntitySnapshotfreezes only the top level, so a nested value mutated in place changes dirtiness without touching any counter and the memo serves the pre-edit answer. Documented, not enforced — deep-freezing is a separate perf and BC decision.One review suggestion declined
Reusing
ReachabilityAnalyzer.graphVersion()instead of maintaining a second sum was suggested and not taken. It is key-equivalent (its bump set is a strict subset), but it would couple the dirty key to a reachability key that a future author could legitimately narrow, and the new guard test already covers the drift risk directly. MakinggraphVersion()public for this would also widen surface for no behavioural gain.Independent review
Verified by a differential fuzz — 5000 seeds × 60 steps ≈ 296,000 operations including undo/redo with a live journal,
transaction(), rekey,importPartialSnapshotandsweepUnreachableCreated, with a global subscriber callinggetDirtyEntities()from inside every notification to poison the memo mid-write. Zero stale memos, zero non-monotonic key steps. Every counter confirmed++-only, never reset, so the sum is strictly increasing and no two states collide.Branch gated in isolation: typecheck clean,
bun run test1561 pass / 0 fail.