perf: batch cold-cache pre-warm in mergeCollectionWithPatches via mul…#5
Conversation
…tiGet Replaces the unconditional Promise.all(existingKeys.map(get)) pre-warm with a hybrid: - Fast path (every existingKey is already in cache): use a sync- resolved Promise — no extra microtask hops, preserving the original promise-chain depth and subscriber-callback timing that dependent tests rely on (Onyx.update batch tests broadcast a single merged callback rather than an `undefined` initial followed by the merged result). - Slow path (at least one cache-miss existingKey): use multiGet — one Storage.multiGet round-trip for the missing keys instead of N parallel get() invocations. Net result: same correctness as before, fewer storage operations on cold-cache merges, identical broadcast timing for warm-cache merges. Addresses follow-up from Expensify#787 review.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| // writes the storage values back to cache before resolving. | ||
| const hasColdExistingKey = existingKeys.some((key) => !cache.hasCacheForKey(key)); | ||
| const prewarmPromise = hasColdExistingKey ? multiGet(existingKeys) : Promise.resolve(); | ||
| return prewarmPromise.then(() => { |
There was a problem hiding this comment.
@fabioh8010 in this one , your suggested code calls multiGet unconditionally while in this one bypasses it when there's nothing to fetch, all test passes.
We can also fix the failing tests and call multiGet unconditionally, just that I go warning from claude that it will introduces a real user-visible behavior change: subscribers can now see an undefined initial callback before the merged data, where they previously saw only the merged value.
Adds 5 tests to the mergeCollection pre-warm describe block: - Fast path: warm cache skips Storage.multiGet/getItem entirely. - Slow path: cold existing keys batch into one Storage.multiGet with no individual getItem calls. - Slow path correctness: cold-cache merge layers delta on top of persisted storage value (no field drops). - Onyx.update timing: warm cache broadcasts a single merged value (no transient undefined) — guards the promise-chain depth invariant. - Equivalence: warm and cold paths converge on identical cache state. Helper notes: - OnyxCache.drop removes the key from storageKeys; re-register via addKey so getAllKeys still sees the key as persisted when other keys remain in cache (the slow-path scenario). - Capture pristine StorageMock methods at file load and restore in beforeEach because the retryOperation describe block mutates setItem and never restores it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-for-each Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fabioh8010
left a comment
There was a problem hiding this comment.
I also got this comment from Claude:
Spotted a subtle inconsistency: the outer check uses
cache.hasCacheForKey, butmultiGetinternally usesif (cacheValue)(truthy check). For a cached falsy value like0,'', orfalse, the outer says "cached" (correct), butmultiGettreats it as missing — re-fetches from storage andcache.merge(temp)ends up overwriting the cached value.Concrete case:
Onyx.set('coll_1', 0)updates cache to0but the storage write is still in flight. Then amergeCollection('coll_', ...)with a cold key hits the slow path →multiGetre-fetchescoll_1and overwrites the cached0with whatever stale value was in storage.Rare for collection members (usually objects), but the inconsistency is real. Could align them in this PR (
!cache.get(key)in the outer check), or fixmultiGetproperly later (touches other callers). Not a blocker — just worth deciding.
I think it's better to check multiGet
| }); | ||
| }); | ||
|
|
||
| describe('mergeCollection pre-warm', () => { |
There was a problem hiding this comment.
All the changes above this part don't seem necessary
|
Closing — this PR was opened against Diff and body carried over verbatim. 456/456 unit tests pass on the rebased branch. |
Inside OnyxUtils.multiGet, the cached-value path was guarded by a truthy
check (`if (cacheValue)`), which treats cached falsy values (0, '',
false, null) as cache misses. Those keys fell through to
missingKeys → Storage.multiGet → cache.merge(temp), which would
overwrite the warm cached value with whatever stale value sat in
storage.
This mattered most after the new perf hybrid pre-warm in
mergeCollectionWithPatches started routing cold-cache merges through
multiGet — the outer check (`!cache.hasCacheForKey(key)`) and the inner
check disagreed for falsy values. Concrete case: `Onyx.set('coll_1', 0)`
updates cache to `0` but the storage write is still in flight; a
mergeCollection over the same collection key would re-fetch `coll_1`
from storage and clobber the cached `0`.
Aligns the inner check with hasCacheForKey so both sides agree, and
adds a regression test that seeds cache with `0` and asserts multiGet
returns it without touching Storage.multiGet / Storage.getItem.
Addresses fabioh8010's review on PR #5
(#5 (review)).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Details
Hybrid pre-warm strategy for
mergeCollectionWithPatchesthat replaces the unconditionalPromise.all(existingKeys.map((key) => get(key))):Fast path (every
existingKeyis already warm in cache): use a sync-resolvedPromise.resolve(). No extra microtask hops, preserving the original promise-chain depth and the subscriber-callback timing that dependent tests rely on (Onyx.updatebatch tests broadcast a single merged callback rather thanundefinedfollowed by the merged value).Slow path (at least one
existingKeyis a cache miss): usemultiGet, which batches the missing keys into a singleStorage.multiGetround-trip instead of N parallelget()invocations and writes the storage values back to cache before resolving.Net result: same correctness as before, fewer storage operations on cold-cache merges, identical broadcast timing for warm-cache merges.
Addresses follow-up from Expensify#787 review.
Related Issues
Expensify/App#90634
Automated Tests
Added a new
describe('mergeCollection pre-warm', ...)block intests/unit/onyxUtilsTest.tswith 5 tests:fast path: skips storage reads entirely when every existing key is warm in cache— Seeds two members viaOnyx.set, spies onStorageMock.multiGetandStorageMock.getItem, then runsOnyx.mergeCollection. Asserts both spies are called 0 times. Confirms the diff'sPromise.resolve()shortcut.slow path: batches cold existing keys into a single Storage.multiGet, with no individual getItem calls— Seeds three members, evicts two from cache (cold), leaves one warm. AssertsStorage.multiGetis called exactly once with only the two cold keys (the warm key is filtered out byOnyxUtils.multiGet), andStorage.getItemis never called during pre-warm.slow path: cold-cache merge layers the new delta on top of existing storage data (no field drops)— Seeds a key with{a:1, b:2}then evicts from cache. Merges{c:3}and asserts the cache holds{a:1, b:2, c:3}. Without the pre-warm reading from storage,cache.mergewould start fromundefinedand drop{a:1, b:2}— this guards the correctness invariant the in-code comment specifically calls out.warm cache: subscriber receives a single merged broadcast for an Onyx.update batch (no transient undefined)— Subscribes to a warm collection key, fires anOnyx.updatewith aMERGE_COLLECTIONop. Asserts subscriber was called exactly once with the final merged value (NOTundefinedthen merged on a later microtask). Guards the promise-chain-depth invariant.equivalence: warm-path and cold-path produce the same final cache state for the same merge— Runs the same effective merge against a warm cache and against a cold cache; asserts the post-merge collection state is identical in both runs.Suite-pollution fix: the
retryOperationdescribe block earlier in the file mutatesStorageMock.setItem(and other methods) without restoring them. This block captures pristine references at file-load time and restores them inbeforeEachso seeding viaOnyx.setactually persists.Helper note:
OnyxCache.dropremoves the key fromstorageKeys, which makesgetAllKeys()miss it when other keys remain in cache. TheevictFromCachehelper callsOnyxCache.addKey(key)afterdropso the key stays "tracked but unloaded" — exactly the cold-but-persisted state the slow path is meant to handle.Local results:
npx jest— 450/450 pass across 16 suites.npx tsc --noEmit— clean.Manual Tests
End-to-end verification against
Expensify/Appvia the companion PR Expensify/App#91585, which pinsreact-native-onyx'spackage.jsonto this branch's head SHA.Setup
react-native-onyxto this PR's head SHA.npm installunder Node 20.20.0, thennpm run web.Functional smoke (same flows as Expensify#787, expect no regression)
For each: open the screen, perform the action, verify UI updates immediately, persist after reload.
Cold-cache merge correctness
MERGE_COLLECTIONagainst one of those keys before the LHN hydrates it.Storage-failure regression (carry-over from Expensify#787)
OnyxDB. Do not reload.MERGE_COLLECTIONaction.Author Checklist
### Related Issuessection aboveTestssectiontoggleReportand notonIconClick)myBool && <MyComponent />.STYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)/** comment above it */thisproperly so there are no scoping issues (i.e. foronClick={this.submit}the methodthis.submitshould be bound tothisin the constructor)thisare necessary to be bound (i.e. avoidthis.submit = this.submit.bind(this);ifthis.submitis never passed to a component event handler likeonClick)Avataris modified, I verified thatAvataris working as expected in all cases)mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
Screen.Recording.2026-05-25.at.12.15.01.mov
Screen.Recording.2026-05-25.at.12.15.33.mov
Screen.Recording.2026-05-25.at.12.21.34.mov
Screen.Recording.2026-05-25.at.12.25.17.mov