Skip to content

Commit 42b796d

Browse files
committed
Make mergeCollectionWithPatches cache-first
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
1 parent e6f1da9 commit 42b796d

2 files changed

Lines changed: 83 additions & 35 deletions

File tree

lib/OnyxUtils.ts

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

1582-
const promises = [];
1583-
1584-
// We need to get the previously existing values so we can compare the new ones
1585-
// against them, to avoid unnecessary subscriber updates.
1586-
const previousCollectionPromise = Promise.all(existingKeys.map((key) => get(key).then((value) => [key, value]))).then(Object.fromEntries);
1587-
1588-
// New keys will be added via multiSet while existing keys will be updated using multiMerge
1589-
// This is because setting a key that doesn't exist yet with multiMerge will throw errors
1590-
// We can skip this step for RAM-only keys as they should never be saved to storage
1591-
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) {
1592-
promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));
1593-
}
1594-
1595-
// We can skip this step for RAM-only keys as they should never be saved to storage
1596-
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) {
1597-
promises.push(Storage.multiSet(keyValuePairsForNewCollection));
1598-
}
1599-
16001582
// finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates
16011583
const finalMergedCollection = {...existingKeyCollection, ...newCollection};
16021584

1603-
// Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache
1604-
// and update all subscribers
1605-
const promiseUpdate = previousCollectionPromise.then((previousCollection) => {
1585+
// Pre-warm cache for any existing storage keys that aren't yet in cache. get() is a no-op
1586+
// (sync-resolved) for cache hits, and on a cache miss it reads from storage and writes the
1587+
// value back to cache. This is required so the subsequent cache.merge() merges the new delta
1588+
// into the real previous storage value (rather than starting from `undefined` and dropping
1589+
// the existing keys).
1590+
return Promise.all(existingKeys.map((key) => get(key))).then(() => {
1591+
// Snapshot previous values from the (now-warm) cache for keysChanged's diff, then update
1592+
// cache and notify subscribers synchronously BEFORE issuing storage writes. This matches
1593+
// the cache-first / storage-second invariant followed by every other Onyx write method
1594+
// (setWithRetry, applyMerge, setCollectionWithRetry, partialSetCollection, clear),
1595+
// ensuring subscribers still reflect the merged data even if the subsequent storage
1596+
// write fails.
1597+
const previousCollection = getCachedCollection(collectionKey, existingKeys);
16061598
cache.merge(finalMergedCollection);
16071599
keysChanged(collectionKey, finalMergedCollection, previousCollection);
1608-
});
16091600

1610-
return Promise.all(promises)
1611-
.catch((error) =>
1612-
retryOperation(
1613-
error,
1614-
mergeCollectionWithPatches,
1615-
{collectionKey, collection: resultCollection as OnyxMergeCollectionInput<TKey>, mergeReplaceNullPatches, isProcessingCollectionUpdate},
1616-
retryAttempt,
1617-
),
1618-
)
1619-
.then(() => {
1620-
sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
1621-
return promiseUpdate;
1622-
});
1601+
const promises = [];
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+
1615+
return Promise.all(promises)
1616+
.catch((error) =>
1617+
retryOperation(
1618+
error,
1619+
mergeCollectionWithPatches,
1620+
{collectionKey, collection: resultCollection as OnyxMergeCollectionInput<TKey>, mergeReplaceNullPatches, isProcessingCollectionUpdate},
1621+
retryAttempt,
1622+
),
1623+
)
1624+
.then(() => {
1625+
sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
1626+
});
1627+
});
16231628
})
16241629
.then(() => undefined);
16251630
}

tests/unit/onyxUtilsTest.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,49 @@ describe('OnyxUtils', () => {
804804
});
805805
});
806806

807+
describe('mergeCollection cache-first ordering', () => {
808+
it('updates cache and notifies subscribers even when Storage.multiMerge rejects', async () => {
809+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
810+
const existingMemberKey = `${collectionKey}1`;
811+
const newMemberKey = `${collectionKey}2`;
812+
813+
// Seed an existing member so the merge path exercises multiMerge (existing) + multiSet (new)
814+
await Onyx.set(existingMemberKey, {value: 'initial'});
815+
816+
const collectionCallback = jest.fn();
817+
Onyx.connect({
818+
key: collectionKey,
819+
waitForCollectionCallback: true,
820+
callback: collectionCallback,
821+
});
822+
await waitForPromisesToResolve();
823+
collectionCallback.mockClear();
824+
825+
// Force Storage.multiMerge to reject with a non-retriable IDB error so the failure
826+
// path is taken without burning the full retry budget and without rejecting the
827+
// outer Onyx.mergeCollection promise.
828+
const nonRetriableIdbError = Object.assign(new Error('Internal error opening backing store for indexedDB.open.'), {name: 'UnknownError'});
829+
StorageMock.multiMerge = jest.fn().mockRejectedValue(nonRetriableIdbError);
830+
831+
await Onyx.mergeCollection(collectionKey, {
832+
[existingMemberKey]: {value: 'merged'},
833+
[newMemberKey]: {value: 'new'},
834+
} as GenericCollection);
835+
836+
// Cache must reflect the merge regardless of the multiMerge rejection. This is the
837+
// cache-first / storage-second invariant that mergeCollectionWithPatches must honor.
838+
const cachedCollection = OnyxCache.getCollectionData(collectionKey);
839+
expect(cachedCollection?.[existingMemberKey]).toEqual({value: 'merged'});
840+
expect(cachedCollection?.[newMemberKey]).toEqual({value: 'new'});
841+
842+
// Subscribers must have been notified with the merged values.
843+
expect(collectionCallback).toHaveBeenCalled();
844+
const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined;
845+
expect(lastBroadcast?.[existingMemberKey]).toEqual({value: 'merged'});
846+
expect(lastBroadcast?.[newMemberKey]).toEqual({value: 'new'});
847+
});
848+
});
849+
807850
describe('storage eviction', () => {
808851
const diskFullError = new Error('database or disk is full');
809852

0 commit comments

Comments
 (0)