Skip to content

Commit f67b94b

Browse files
authored
Merge pull request Expensify#787 from callstack-internal/elirangoshen/fix/90634-mergeCollectionWithPatches-cache-first
Make mergeCollectionWithPatches cache-first
2 parents 9b2638c + 9445bbf commit f67b94b

2 files changed

Lines changed: 135 additions & 35 deletions

File tree

lib/OnyxUtils.ts

Lines changed: 40 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,47 +1594,52 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
15941594
// because we will simply overwrite the existing values in storage.
15951595
const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection, true);
15961596

1597-
const promises = [];
1598-
1599-
// We need to get the previously existing values so we can compare the new ones
1600-
// against them, to avoid unnecessary subscriber updates.
1601-
const previousCollectionPromise = Promise.all(existingKeys.map((key) => get(key).then((value) => [key, value]))).then(Object.fromEntries);
1602-
1603-
// New keys will be added via multiSet while existing keys will be updated using multiMerge
1604-
// This is because setting a key that doesn't exist yet with multiMerge will throw errors
1605-
// We can skip this step for RAM-only keys as they should never be saved to storage
1606-
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) {
1607-
promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));
1608-
}
1609-
1610-
// We can skip this step for RAM-only keys as they should never be saved to storage
1611-
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) {
1612-
promises.push(Storage.multiSet(keyValuePairsForNewCollection));
1613-
}
1614-
16151597
// finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates
16161598
const finalMergedCollection = {...existingKeyCollection, ...newCollection};
16171599

1618-
// Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache
1619-
// and update all subscribers
1620-
const promiseUpdate = previousCollectionPromise.then((previousCollection) => {
1600+
// Pre-warm cache for any existing storage keys that aren't yet in cache. get() is a no-op
1601+
// (sync-resolved) for cache hits, and on a cache miss it reads from storage and writes the
1602+
// value back to cache. This is required so the subsequent cache.merge() merges the new delta
1603+
// into the real previous storage value (rather than starting from `undefined` and dropping
1604+
// the existing keys).
1605+
return Promise.all(existingKeys.map((key) => get(key))).then(() => {
1606+
// Snapshot previous values from the (now-warm) cache for keysChanged's diff, then update
1607+
// cache and notify subscribers synchronously BEFORE issuing storage writes. This matches
1608+
// the cache-first / storage-second invariant followed by every other Onyx write method
1609+
// (setWithRetry, applyMerge, setCollectionWithRetry, partialSetCollection, clear),
1610+
// ensuring subscribers still reflect the merged data even if the subsequent storage
1611+
// write fails.
1612+
const previousCollection = getCachedCollection(collectionKey, existingKeys);
16211613
cache.merge(finalMergedCollection);
16221614
keysChanged(collectionKey, finalMergedCollection, previousCollection);
1623-
});
16241615

1625-
return Promise.all(promises)
1626-
.catch((error) =>
1627-
retryOperation(
1628-
error,
1629-
mergeCollectionWithPatches,
1630-
{collectionKey, collection: resultCollection as OnyxMergeCollectionInput<TKey>, mergeReplaceNullPatches, isProcessingCollectionUpdate},
1631-
retryAttempt,
1632-
),
1633-
)
1634-
.then(() => {
1635-
sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
1636-
return promiseUpdate;
1637-
});
1616+
const promises = [];
1617+
1618+
// New keys will be added via multiSet while existing keys will be updated using multiMerge
1619+
// This is because setting a key that doesn't exist yet with multiMerge will throw errors
1620+
// We can skip this step for RAM-only keys as they should never be saved to storage
1621+
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) {
1622+
promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));
1623+
}
1624+
1625+
// We can skip this step for RAM-only keys as they should never be saved to storage
1626+
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) {
1627+
promises.push(Storage.multiSet(keyValuePairsForNewCollection));
1628+
}
1629+
1630+
return Promise.all(promises)
1631+
.catch((error) =>
1632+
retryOperation(
1633+
error,
1634+
mergeCollectionWithPatches,
1635+
{collectionKey, collection: resultCollection as OnyxMergeCollectionInput<TKey>, mergeReplaceNullPatches, isProcessingCollectionUpdate},
1636+
retryAttempt,
1637+
),
1638+
)
1639+
.then(() => {
1640+
sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
1641+
});
1642+
});
16381643
})
16391644
.then(() => undefined);
16401645
}

tests/unit/onyxUtilsTest.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,101 @@ describe('OnyxUtils', () => {
825825
});
826826
});
827827

828+
describe('mergeCollection cache-first ordering', () => {
829+
// Save originals so we can restore them after each test. The tests below replace
830+
// StorageMock.multiMerge / StorageMock.multiSet with rejecting mocks; without
831+
// restoring, the mock leaks into later describe blocks (e.g. eviction tests) whose
832+
// setup relies on these storage methods working normally.
833+
const originalMultiMerge = StorageMock.multiMerge;
834+
const originalMultiSet = StorageMock.multiSet;
835+
836+
afterEach(() => {
837+
StorageMock.multiMerge = originalMultiMerge;
838+
StorageMock.multiSet = originalMultiSet;
839+
});
840+
841+
it('updates cache and notifies subscribers even when Storage.multiMerge rejects', async () => {
842+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
843+
const existingMemberKey = `${collectionKey}1`;
844+
const newMemberKey = `${collectionKey}2`;
845+
846+
// Seed an existing member so the merge path exercises multiMerge (existing) + multiSet (new)
847+
await Onyx.set(existingMemberKey, {value: 'initial'});
848+
849+
const collectionCallback = jest.fn();
850+
Onyx.connect({
851+
key: collectionKey,
852+
waitForCollectionCallback: true,
853+
callback: collectionCallback,
854+
});
855+
await waitForPromisesToResolve();
856+
collectionCallback.mockClear();
857+
858+
// Force Storage.multiMerge to reject with a non-retriable IDB error so the failure
859+
// path is taken without burning the full retry budget and without rejecting the
860+
// outer Onyx.mergeCollection promise.
861+
const nonRetriableIdbError = Object.assign(new Error('Internal error opening backing store for indexedDB.open.'), {name: 'UnknownError'});
862+
StorageMock.multiMerge = jest.fn().mockRejectedValue(nonRetriableIdbError);
863+
864+
await Onyx.mergeCollection(collectionKey, {
865+
[existingMemberKey]: {value: 'merged'},
866+
[newMemberKey]: {value: 'new'},
867+
} as GenericCollection);
868+
869+
// Cache must reflect the merge regardless of the multiMerge rejection. This is the
870+
// cache-first / storage-second invariant that mergeCollectionWithPatches must honor.
871+
const cachedCollection = OnyxCache.getCollectionData(collectionKey);
872+
expect(cachedCollection?.[existingMemberKey]).toEqual({value: 'merged'});
873+
expect(cachedCollection?.[newMemberKey]).toEqual({value: 'new'});
874+
875+
// Subscribers must have been notified with the merged values.
876+
expect(collectionCallback).toHaveBeenCalled();
877+
const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined;
878+
expect(lastBroadcast?.[existingMemberKey]).toEqual({value: 'merged'});
879+
expect(lastBroadcast?.[newMemberKey]).toEqual({value: 'new'});
880+
});
881+
882+
it('updates cache and notifies subscribers even when Storage.multiSet rejects', async () => {
883+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
884+
const newMemberKey1 = `${collectionKey}1`;
885+
const newMemberKey2 = `${collectionKey}2`;
886+
887+
// No keys are seeded, so every merged key is a "new" key. This forces the merge path
888+
// to use Storage.multiSet (existing keys would go through Storage.multiMerge).
889+
const collectionCallback = jest.fn();
890+
Onyx.connect({
891+
key: collectionKey,
892+
waitForCollectionCallback: true,
893+
callback: collectionCallback,
894+
});
895+
await waitForPromisesToResolve();
896+
collectionCallback.mockClear();
897+
898+
// Force Storage.multiSet to reject with a non-retriable IDB error so the failure
899+
// path is taken without burning the full retry budget and without rejecting the
900+
// outer Onyx.mergeCollection promise.
901+
const nonRetriableIdbError = Object.assign(new Error('Internal error opening backing store for indexedDB.open.'), {name: 'UnknownError'});
902+
StorageMock.multiSet = jest.fn().mockRejectedValue(nonRetriableIdbError);
903+
904+
await Onyx.mergeCollection(collectionKey, {
905+
[newMemberKey1]: {value: 'first'},
906+
[newMemberKey2]: {value: 'second'},
907+
} as GenericCollection);
908+
909+
// Cache must reflect the merge regardless of the multiSet rejection. This is the
910+
// cache-first / storage-second invariant that mergeCollectionWithPatches must honor.
911+
const cachedCollection = OnyxCache.getCollectionData(collectionKey);
912+
expect(cachedCollection?.[newMemberKey1]).toEqual({value: 'first'});
913+
expect(cachedCollection?.[newMemberKey2]).toEqual({value: 'second'});
914+
915+
// Subscribers must have been notified with the merged values.
916+
expect(collectionCallback).toHaveBeenCalled();
917+
const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined;
918+
expect(lastBroadcast?.[newMemberKey1]).toEqual({value: 'first'});
919+
expect(lastBroadcast?.[newMemberKey2]).toEqual({value: 'second'});
920+
});
921+
});
922+
828923
describe('storage eviction', () => {
829924
const diskFullError = new Error('database or disk is full');
830925

0 commit comments

Comments
 (0)