perf(store): cheaper detail storage and cached Detail.get()#2283
Merged
Conversation
S.Detail was the biggest post-decode amplifier of subscription data:
every relation of every record got its own makeObservable-instrumented
{relationKey, value, isDeleted} wrapper with deep-wrapped values (~1500
instrumented objects for a 50-record page), and get() rebuilt + re-ran
the layout mapper from scratch on every call from ~246 call sites at
render rate.
- Store raw values in a shallow observable map per object (one map
entry per relation instead of a wrapper object + two boxed
observables + deep value proxies). Per-key reactivity granularity is
preserved by the map entries. The dead Detail.isDeleted flag (never
set to true anywhere) is dropped.
- get() with withKeys now iterates the requested key set instead of all
stored relations: O(requested) instead of O(total), and reactive
callers subscribe to exactly the entries they use (absent keys are
tracked via has(), so objects appearing later invalidate correctly).
- In reactive contexts get() results are cached in per-args computeds
that self-evict via onBecomeUnobserved; outside reactions it builds
directly, same as before (computeds would not cache there anyway).
Callers get a shallow copy, preserving the old fresh-object-per-call
mutation semantics. translate()/date formatting read observable
S.Common state, so language/format changes invalidate cached objects.
Differential test vs the old implementation (2000 records x 34
relations): set() 118ms -> 11ms and ~118MB -> ~0MB heap growth;
60 simulated re-renders of a 100-row list reading 5 keys 184ms -> 80ms;
6000 gets inside a live reaction 24ms -> 4.6ms. Output equivalence
verified across layouts/args (result key order changes from store
insertion order to requested-keys order); fine-grained re-render
semantics verified equivalent or better.
Review findings: Number(undefined) stringifies to 'NaN' in the cache key, so get(root, id, keys) and get(root, id, keys, false) created duplicate computeds for identical logical args — use (forceKeys ? 1 : 0). Also add a size bound on getCache: entries created while a suspended computed evaluates inside a batch never fire onBecomeUnobserved (no in-repo trigger today, but the failure mode is silent); clearing the map is safe since live observers keep their computed instances.
Contributor
Author
|
Independent adversarial review completed (differential harness vs the old implementation: 13 layouts × 7 arg combos, 12 write scenarios, in/out of reactive contexts — zero mismatches). Verdict: sound, no blockers/majors. Verified properties:
Two minor findings fixed in 2144168's follow-up commit:
|
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
What
Follow-up to #2282. Tracing a subscription record end-to-end showed the expensive copies live downstream of protobuf decode, in
S.Detail:{relationKey, value, isDeleted}wrapper with its ownmakeObservablecall (2 boxed observables each) and deep-wrapped values (arrays/objects proxied) — ~1,500 MobX-instrumented objects for a 50-record × 30-relation page.S.Detail.get()rebuilt the plain object and re-ran the layout mapper (mapCommon+mapType/mapSpaceView/… coercions,translate()calls) from scratch on every call from ~246 call sites, at render rate (S.Record.getRecordsalone calls it once per row per render). It also iterated all stored relations even when 3 keys were requested.Changes
Storage — one shallow
observable.mapper object holding raw values:Detail.isDeletedflag — nothing anywhere ever set it totrue.get()— requested-keys iteration + per-args computed cache:withKeys, iterates the requested key set: O(requested) instead of O(total stored), and reactive callers subscribe to exactly the entries they use. Absent keys are tracked viahas(), so an object/relation appearing later invalidates correctly (previously new-key adds relied on map-level key tracking).(rootId, id, args)computeds that self-evict viaonBecomeUnobserved— cached between renders, recomputed only when an underlying observable actually changes. Outside reactive contexts it builds directly (a computed wouldn't cache there anyway, and no cache entries leak).translate()and date formatting read observableS.Commonstate (config→ computed,showRelativeDates/dateFormat→ computed), so language/format switches invalidate cached objects automatically.Numbers (differential harness, old implementation vs new, 2,000 records × 34 relations)
set()writeget()calls inside a live reactionVerification
withKeys/forceKeys/skipLayoutFormatcombos, amend/clear updates, key/object deletes,getKeys._empty_→ populated invalidates.develop(verified baseline), zero new.Behavioral notes (intentional)
get()returns plain arrays/objects. All legitimate updates flow throughS.Detail.set/update(middleware events), which replace values and notify; in-place mutation of a stored value from a component was never a supported pattern.