Make mergeCollectionWithPatches cache-first#787
Conversation
Update cache and notify subscribers synchronously before issuing storage writes, matching the cache-first / storage-second invariant followed by every other Onyx write method. Subscribers now still reflect the merged data when the underlying storage write fails (e.g. IDB corruption). `get()` is invoked on existingKeys first to pre-warm cache from storage for cache-miss keys; it is a no-op (sync-resolved) for cache hits. This preserves the previous cold-cache merge semantics while removing the prior race between the cache update and the storage write. Adds a regression test asserting cache + subscribers reflect the merged collection when Storage.multiMerge rejects. Fixes Expensify/App#90634
|
Seems like the checklist is incomplete |
|
@elirangoshen Onyx and E/App PR are lacking manual testing steps and evidence |
Hi, I fixed it, let me know how it is |
| // value back to cache. This is required so the subsequent cache.merge() merges the new delta | ||
| // into the real previous storage value (rather than starting from `undefined` and dropping | ||
| // the existing keys). | ||
| return Promise.all(existingKeys.map((key) => get(key))).then(() => { |
There was a problem hiding this comment.
| return Promise.all(existingKeys.map((key) => get(key))).then(() => { | |
| return multiGet(existingKeys).then(() => { |
NAB but something to explore in the future or follow-up – Use multiGet to return all keys (just one trip to cache/DB) instead of doing N get()s. This should have beneficial performance gains but it made 2 tests failed because now some Onyx.connect fired with empty state before what we expected – it seems because we have more microtasks in the chain and consequently a minor delay that make this happen.
I suggest we don't include this change here, but it's something to explore
There was a problem hiding this comment.
Will create an internal ticket for it
| }); | ||
|
|
||
| describe('mergeCollection cache-first ordering', () => { | ||
| it('updates cache and notifies subscribers even when Storage.multiMerge rejects', async () => { |
There was a problem hiding this comment.
We should also test for Storage.multiSet failure
Adds a parallel test inside the 'mergeCollection cache-first ordering' describe asserting that cache + subscribers reflect the merged data even when Storage.multiSet (the new-keys path) rejects. The existing test only exercises the Storage.multiMerge (existing-keys) path. Also adds an afterEach that restores StorageMock.multiMerge and StorageMock.multiSet to their originals after each test in this block, so the rejecting mocks no longer leak into later describe blocks (eviction, afterInit) whose setup relies on these storage methods working normally. Addresses review feedback on Expensify#787.
| // ensuring subscribers still reflect the merged data even if the subsequent storage | ||
| // write fails. | ||
| const previousCollection = getCachedCollection(collectionKey, existingKeys); | ||
| cache.merge(finalMergedCollection); |
There was a problem hiding this comment.
We always merge finalMergedCollection into the cache; if mergeCollectionWithPatches fails on the first run, all keys will be considered as existingKeys in the next attempt. This causes the Storage.multiMerge function to be called instead of Storage.multiSet for new keys.
There was a problem hiding this comment.
Good catches @dmkt9. I was analysing and actually both pre-exist in main though, just less reliably (depends on whether promiseUpdate resolves before Promise.all(promises) rejects).
#2 is mostly handled by structural sharing + the === dedup in keysChanged. Only waitForCollectionCallback: true re-fires, and that one fires on every change by contract anyway.
#1 is real but benign — multiMerge on a missing key just stores the patch (works like a set in that case), so end state is the same.
I suggest we address this issue as a follow-up as same pattern affects multiSetWithRetry / setCollectionWithRetry / partialSetCollection and possibly all the functions that use retryOperation.
There was a problem hiding this comment.
I suggest we address this issue as a follow-up as same pattern affects multiSetWithRetry / setCollectionWithRetry / partialSetCollection and possibly all the functions that use retryOperation.
Yes, that makes sense to me too.
| // write fails. | ||
| const previousCollection = getCachedCollection(collectionKey, existingKeys); | ||
| cache.merge(finalMergedCollection); | ||
| keysChanged(collectionKey, finalMergedCollection, previousCollection); |
There was a problem hiding this comment.
Similarly, we also call keysChanged for each attempt after mergeCollectionWithPatches failed. This also causes Onyx.connect with waitForCollectionCallback: true to potentially be called more than once with the same payload.
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / Safarichrome.mp4 |
Details
mergeCollectionWithPatcheswas the only Onyx write method that did not follow the cache-first / storage-second invariant. Every other write method (setWithRetry,applyMerge,setCollectionWithRetry,partialSetCollection,clear) updates the in-memory cache synchronously before issuing a storage write — so when storage fails (e.g., the IDB corruption population analyzed in Expensify/App#87862), the cache is already correct and subscribers still see the right data.mergeCollectionWithPatchesinstead pushedStorage.multiMerge/Storage.multiSetfirst and bundled thecache.merge+keysChangedupdate into apreviousCollectionPromise.then(...)chain that ran in parallel with storage. In the warm-cache common case the cache update happened to win the race, but ifpreviousCollectionPromiselost the race (cache miss falling back to a slow storage read), subscribers could observe a state inconsistent with what the merge logically applied.This change restores the invariant:
Promise.all(existingKeys.map((key) => get(key)))runs first to pre-warm the cache from storage for any cache-miss existing keys.get()is a sync-resolved no-op for cache hits, so this is microtask-cheap for the warm-cache common case. The pre-warm is required becausecache.merge'sfastMergeneeds the previous storage value as the merge base — without it, cold-cache merges would start fromundefinedand silently drop existing data (the behavior the oldpreviousCollectionPromisehappened to provide as a side effect viaget()'s cache-population path).cache.merge(finalMergedCollection)andkeysChanged(...)then run synchronously on the now-warm cache, before the storage promises are issued.Storage.multiMerge/Storage.multiSetare pushed afterwards. TheretryOperation/ DevTools chain is unchanged.Result: subscribers receive the merged data before any storage write is attempted, and storage failure no longer races the cache update — matching the behavior of every other Onyx write method.
Related Issues
Expensify/App#90634
Companion PR
Expensify/App#91160 — bumps the App's react-native-onyx pin to this branch's head for end-to-end verification.
Automated Tests
Added a new regression test in `tests/unit/onyxUtilsTest.ts`:
It seeds an existing collection member, mocks `Storage.multiMerge` to reject with a non-retriable IDB backing-store error, and asserts both the cache (via `OnyxCache.getCollectionData`) and a connected collection subscriber receive the merged values regardless of the storage rejection.
All 452 existing unit tests still pass — including the timing-sensitive `mergeCollection` / `Onyx.update` callback-ordering tests that probe `keysChanged` and `retryOperation` semantics.
Manual Tests
The companion App PR (Expensify/App#91160) pulls this branch in via
package.json+ lockfile, so the real end-to-end exercises happen against a running App session. Full step-by-step lives in that PR'sTestssection; the summary below covers the same scenarios.mergeCollectionWithPatchesis reached from these App entry points:Search.ts,IOU/Hold.ts,IOU/MoneyRequest.ts,Report/MarkAllMessageAsRead.tsx,Report/index.ts,Policy/Policy.ts, plus everyOnyx.updatebatch that contains aMERGE_COLLECTIONop (LHN refresh, Pusher events,OpenAppresponse, etc.).Setup
elirangoshen/fix/90634-mergeCollectionWithPatches-cache-first(Make mergeCollectionWithPatches cache-first [No QA] App#91160), which pinsreact-native-onyxto this branch's headnpm installunder Node 20.20.0, thennpm run webFunctional smoke (no regression in
mergeCollection-driven flows)main.reportActions_), confirms via Pusher, persists after reload.Storage-failure simulation — the core regression guard for this PR
The fix protects against a race where a failing
Storage.multiMergecould leave the in-memory cache without the merge's update (and therefore leave subscribers stale). With the fix in place,cache.mergeandkeysChangedalways fire before the storage write, so subscribers stay correct regardless of storage outcome.OnyxDB)mergeCollection-driven action — switch workspaces, send a chat message, or apply a search filterEvidence
Screen recording of the smoke tests (1–7), the IDB-failure simulation, and the cold-cache test will be attached to the App PR (Expensify/App#91160) — since this PR has no UI surface of its own, all video evidence lives there.
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
Screen.Recording.2026-05-20.at.14.53.27.mov
Screen.Recording.2026-05-20.at.14.54.02.mov
Screen.Recording.2026-05-20.at.15.00.50.mov
Screen.Recording.2026-05-20.at.15.06.56.mov
Screen.Recording.2026-05-20.at.15.10.33.mov
Screen.Recording.2026-05-20.at.15.12.19.mov
Screen.Recording.2026-05-20.at.15.14.31.mov
Screen.Recording.2026-05-20.at.15.16.13.mov
Screen.Recording.2026-05-20.at.15.16.36.mov
Screen.Recording.2026-05-20.at.15.17.07.mov
Screen.Recording.2026-05-20.at.15.19.23.mov
Screen.Recording.2026-05-20.at.15.27.05.mov