Skip to content

Commit c45d574

Browse files
authored
Merge pull request #793 from callstack-internal/elirangoshen/perf/mergeCollection-multiGet-prewarm-v2
perf: batch cold-cache pre-warm in mergeCollectionWithPatches via multiGet
2 parents 8810340 + 98ddd13 commit c45d574

2 files changed

Lines changed: 299 additions & 9 deletions

File tree

lib/OnyxUtils.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,10 @@ function multiGet<TKey extends OnyxKey>(keys: CollectionKeyBase[]): Promise<Map<
347347
continue;
348348
}
349349

350-
const cacheValue = cache.get(key) as OnyxValue<TKey>;
351-
if (cacheValue) {
352-
dataMap.set(key, cacheValue);
350+
// hasCacheForKey catches cached falsy values (0, '', false, null) as cache hits, which
351+
// a truthy check on the value would miss.
352+
if (cache.hasCacheForKey(key)) {
353+
dataMap.set(key, cache.get(key) as OnyxValue<TKey>);
353354
continue;
354355
}
355356

@@ -401,6 +402,13 @@ function multiGet<TKey extends OnyxKey>(keys: CollectionKeyBase[]): Promise<Map<
401402
}
402403
}
403404

405+
// Prefer cache over stale storage if a concurrent write populated it during
406+
// the read — otherwise cache.merge(temp) below would resurrect dropped fields.
407+
if (cache.hasCacheForKey(key)) {
408+
dataMap.set(key, cache.get(key) as OnyxValue<TKey>);
409+
continue;
410+
}
411+
404412
dataMap.set(key, value as OnyxValue<TKey>);
405413
temp[key] = value as OnyxValue<TKey>;
406414
}
@@ -1597,12 +1605,17 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
15971605
// finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates
15981606
const finalMergedCollection = {...existingKeyCollection, ...newCollection};
15991607

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(() => {
1608+
// Pre-warm cache for cache-miss existingKeys so cache.merge() merges the new delta into
1609+
// the real previous storage value. Fast path (all warm) skips the pre-warm to preserve
1610+
// promise-chain depth; slow path batches the misses into one Storage.multiGet.
1611+
const hasColdExistingKey = existingKeys.some((key) => !cache.hasCacheForKey(key));
1612+
// Swallow pre-warm read failures so a transient Storage.multiGet rejection doesn't
1613+
// skip the cache.merge() + keysChanged() below. Subscribers still see the merge even
1614+
// when storage reads fail.
1615+
const prewarmPromise = hasColdExistingKey
1616+
? multiGet(existingKeys).catch((err) => Logger.logInfo(`mergeCollectionWithPatches pre-warm failed; proceeding with cache-only merge. Error: ${err}`))
1617+
: Promise.resolve();
1618+
return prewarmPromise.then(() => {
16061619
// Snapshot previous values from the (now-warm) cache for keysChanged's diff, then update
16071620
// cache and notify subscribers synchronously BEFORE issuing storage writes. This matches
16081621
// the cache-first / storage-second invariant followed by every other Onyx write method

tests/unit/onyxUtilsTest.ts

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,283 @@ describe('OnyxUtils', () => {
920920
});
921921
});
922922

923+
describe('mergeCollection pre-warm', () => {
924+
// retryOperation tests above replace StorageMock methods without restoring them, leaving
925+
// rejecting mocks behind. Capture pristine refs at file-load time and restore in beforeEach
926+
// so our Onyx.set seeding actually reaches the in-memory storage provider.
927+
const pristineSetItem = StorageMock.setItem;
928+
const pristineMultiSet = StorageMock.multiSet;
929+
const pristineMultiGet = StorageMock.multiGet;
930+
const pristineGetItem = StorageMock.getItem;
931+
const pristineMultiMerge = StorageMock.multiMerge;
932+
933+
beforeEach(() => {
934+
StorageMock.setItem = pristineSetItem;
935+
StorageMock.multiSet = pristineMultiSet;
936+
StorageMock.multiGet = pristineMultiGet;
937+
StorageMock.getItem = pristineGetItem;
938+
StorageMock.multiMerge = pristineMultiMerge;
939+
});
940+
941+
// Make a key "cold" — value evicted from cache but still tracked as persisted. OnyxCache.drop
942+
// also removes the key from `storageKeys`, so we re-register it afterwards to reliably hit
943+
// the cold-but-persisted state regardless of getAllKeys()'s fallback path.
944+
const evictFromCache = (...keys: string[]) => {
945+
for (const key of keys) {
946+
OnyxCache.drop(key);
947+
OnyxCache.addKey(key);
948+
}
949+
};
950+
951+
it('fast path: skips storage reads entirely when every existing key is warm in cache', async () => {
952+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
953+
const existingKey1 = `${collectionKey}1`;
954+
const existingKey2 = `${collectionKey}2`;
955+
956+
// Seed both members so they are both warm in cache and present in storage.
957+
await Onyx.set(existingKey1, {value: 'initial-1'});
958+
await Onyx.set(existingKey2, {value: 'initial-2'});
959+
960+
const multiGetSpy = jest.spyOn(StorageMock, 'multiGet');
961+
const getItemSpy = jest.spyOn(StorageMock, 'getItem');
962+
963+
await Onyx.mergeCollection(collectionKey, {
964+
[existingKey1]: {value: 'merged-1'},
965+
[existingKey2]: {value: 'merged-2'},
966+
} as GenericCollection);
967+
968+
// With every existingKey warm, the diff swaps Promise.all(get) for Promise.resolve(),
969+
// so no storage reads should happen during the pre-warm.
970+
expect(multiGetSpy).not.toHaveBeenCalled();
971+
expect(getItemSpy).not.toHaveBeenCalled();
972+
973+
// Cache still reflects the merge.
974+
const cached = OnyxCache.getCollectionData(collectionKey);
975+
expect(cached?.[existingKey1]).toEqual({value: 'merged-1'});
976+
expect(cached?.[existingKey2]).toEqual({value: 'merged-2'});
977+
});
978+
979+
it('slow path: batches cold existing keys into a single Storage.multiGet, with no individual getItem calls', async () => {
980+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
981+
const coldKey1 = `${collectionKey}1`;
982+
const coldKey2 = `${collectionKey}2`;
983+
const warmKey = `${collectionKey}3`;
984+
985+
// Seed all three in storage, then evict two from cache so they are cold-but-persisted.
986+
await Onyx.set(coldKey1, {value: 'persisted-1'});
987+
await Onyx.set(coldKey2, {value: 'persisted-2'});
988+
await Onyx.set(warmKey, {value: 'persisted-3'});
989+
evictFromCache(coldKey1, coldKey2);
990+
991+
// Reset spies AFTER seeding so we only count calls made during mergeCollection itself.
992+
const multiGetSpy = jest.spyOn(StorageMock, 'multiGet').mockClear();
993+
const getItemSpy = jest.spyOn(StorageMock, 'getItem').mockClear();
994+
995+
await Onyx.mergeCollection(collectionKey, {
996+
[coldKey1]: {value: 'merged-1'},
997+
[coldKey2]: {value: 'merged-2'},
998+
[warmKey]: {value: 'merged-3'},
999+
} as GenericCollection);
1000+
1001+
// OnyxUtils.multiGet filters to cache-missing keys before issuing Storage.multiGet, so we
1002+
// expect exactly one batched read containing only the cold keys (the warm key is skipped).
1003+
expect(multiGetSpy).toHaveBeenCalledTimes(1);
1004+
const requestedKeys = multiGetSpy.mock.calls[0][0] as string[];
1005+
expect(requestedKeys.sort()).toEqual([coldKey1, coldKey2].sort());
1006+
1007+
// No individual Storage.getItem calls during pre-warm. Old code path would have fired one
1008+
// get() per existing key, each potentially landing in Storage.getItem on cache miss.
1009+
expect(getItemSpy).not.toHaveBeenCalled();
1010+
});
1011+
1012+
it('slow path: cold-cache merge layers the new delta on top of existing storage data (no field drops)', async () => {
1013+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
1014+
const coldKey = `${collectionKey}1`;
1015+
1016+
// Seed an object with multiple fields in storage, then evict from cache so the merge base
1017+
// must come from a storage read — not from `undefined`.
1018+
await Onyx.set(coldKey, {a: 1, b: 2});
1019+
evictFromCache(coldKey);
1020+
1021+
await Onyx.mergeCollection(collectionKey, {
1022+
[coldKey]: {c: 3},
1023+
} as GenericCollection);
1024+
1025+
// If the pre-warm did NOT populate the cache from storage, fastMerge would treat the
1026+
// previous value as undefined and the result would drop {a:1, b:2}. With the pre-warm
1027+
// running multiGet on the cold key, the merge layers {c:3} on top of {a:1, b:2}.
1028+
const cached = OnyxCache.getCollectionData(collectionKey);
1029+
expect(cached?.[coldKey]).toEqual({a: 1, b: 2, c: 3});
1030+
});
1031+
1032+
it('warm cache: subscriber receives a single merged broadcast for an Onyx.update batch (no transient undefined)', async () => {
1033+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
1034+
const existingKey = `${collectionKey}1`;
1035+
1036+
await Onyx.set(existingKey, {value: 'initial'});
1037+
1038+
const collectionCallback = jest.fn();
1039+
Onyx.connect({
1040+
key: collectionKey,
1041+
waitForCollectionCallback: true,
1042+
callback: collectionCallback,
1043+
});
1044+
await waitForPromisesToResolve();
1045+
collectionCallback.mockClear();
1046+
1047+
await Onyx.update([
1048+
{
1049+
onyxMethod: Onyx.METHOD.MERGE_COLLECTION,
1050+
key: collectionKey,
1051+
value: {
1052+
[existingKey]: {value: 'merged'},
1053+
} as GenericCollection,
1054+
},
1055+
]);
1056+
1057+
// The fast path resolves the pre-warm synchronously (Promise.resolve()), preserving the
1058+
// original promise-chain depth. The Onyx.update batch must therefore broadcast exactly
1059+
// one merged value — not undefined first and the merged value on a later microtask.
1060+
const broadcasts = collectionCallback.mock.calls.map((c) => c[0]);
1061+
expect(broadcasts).toHaveLength(1);
1062+
expect(broadcasts[0]?.[existingKey]).toEqual({value: 'merged'});
1063+
});
1064+
1065+
it('equivalence: warm-path and cold-path produce the same final cache state for the same merge', async () => {
1066+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
1067+
const memberKey = `${collectionKey}1`;
1068+
const delta = {value: 'after'} as const;
1069+
1070+
// Warm-path run.
1071+
await Onyx.set(memberKey, {value: 'before', extra: 'kept'});
1072+
await Onyx.mergeCollection(collectionKey, {
1073+
[memberKey]: delta,
1074+
} as GenericCollection);
1075+
const warmResult = OnyxCache.getCollectionData(collectionKey)?.[memberKey];
1076+
1077+
// Reset and replay with a cold cache before the merge.
1078+
await Onyx.clear();
1079+
await Onyx.set(memberKey, {value: 'before', extra: 'kept'});
1080+
evictFromCache(memberKey);
1081+
await Onyx.mergeCollection(collectionKey, {
1082+
[memberKey]: delta,
1083+
} as GenericCollection);
1084+
const coldResult = OnyxCache.getCollectionData(collectionKey)?.[memberKey];
1085+
1086+
expect(warmResult).toEqual(coldResult);
1087+
expect(coldResult).toEqual({value: 'after', extra: 'kept'});
1088+
});
1089+
1090+
it('preserves cache-first invariant when Storage.multiGet rejects on the slow path', async () => {
1091+
// A Storage.multiGet rejection during pre-warm must not skip the cache.merge() +
1092+
// keysChanged() that follow. Without the .catch() at the pre-warm call site,
1093+
// subscribers would miss the merge and Onyx.mergeCollection would reject.
1094+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
1095+
const coldMemberKey = `${collectionKey}1`;
1096+
const newMemberKey = `${collectionKey}2`;
1097+
1098+
// Seed an existing member, then evict it from cache so it's "tracked but unloaded" —
1099+
// the slow path will try to multiGet it.
1100+
await Onyx.set(coldMemberKey, {value: 'persisted'});
1101+
evictFromCache(coldMemberKey);
1102+
1103+
// Connect and flush the subscriber's initial load BEFORE installing the rejecting mock —
1104+
// otherwise the connect's own multiGet (no .catch) consumes the mockRejectedValueOnce and
1105+
// leaks an unhandled rejection instead of exercising the merge pre-warm path.
1106+
const collectionCallback = jest.fn();
1107+
Onyx.connect({
1108+
key: collectionKey,
1109+
waitForCollectionCallback: true,
1110+
callback: collectionCallback,
1111+
});
1112+
await waitForPromisesToResolve();
1113+
collectionCallback.mockClear();
1114+
1115+
// The subscriber's connect re-populated cache, so re-evict to force the merge into
1116+
// the slow (cold-key) path. Then reject the next Storage.multiGet so the pre-warm
1117+
// read fails.
1118+
evictFromCache(coldMemberKey);
1119+
const transientError = new Error('Transient IndexedDB read error');
1120+
StorageMock.multiGet = jest.fn(pristineMultiGet).mockRejectedValueOnce(transientError);
1121+
1122+
// Outer promise must resolve, not reject, even when the pre-warm read fails.
1123+
let outerRejected: unknown = null;
1124+
const result = await Onyx.mergeCollection(collectionKey, {
1125+
[coldMemberKey]: {merged: true},
1126+
[newMemberKey]: {value: 'new'},
1127+
} as GenericCollection).catch((e: unknown) => {
1128+
outerRejected = e;
1129+
});
1130+
expect(outerRejected).toBeNull();
1131+
expect(result).toBeUndefined();
1132+
1133+
// cache.merge() + keysChanged() must still fire so subscribers see the merge. Use
1134+
// toMatchObject because a concurrent read may have re-populated the persisted value;
1135+
// what matters is that the new {merged: true} delta is applied on top.
1136+
expect(collectionCallback).toHaveBeenCalled();
1137+
const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined;
1138+
expect(lastBroadcast?.[coldMemberKey]).toMatchObject({merged: true});
1139+
expect(lastBroadcast?.[newMemberKey]).toEqual({value: 'new'});
1140+
});
1141+
});
1142+
1143+
describe('multiGet cache hit consistency', () => {
1144+
// Same suite-pollution guard as the pre-warm block above — capture pristine StorageMock
1145+
// refs at file-load time and restore them in beforeEach, since retryOperation tests leak.
1146+
const pristineSetItem = StorageMock.setItem;
1147+
const pristineMultiGet = StorageMock.multiGet;
1148+
const pristineGetItem = StorageMock.getItem;
1149+
1150+
beforeEach(() => {
1151+
StorageMock.setItem = pristineSetItem;
1152+
StorageMock.multiGet = pristineMultiGet;
1153+
StorageMock.getItem = pristineGetItem;
1154+
});
1155+
1156+
it('does not re-fetch a cached falsy value from storage', async () => {
1157+
const falsyKey = ONYXKEYS.TEST_KEY;
1158+
1159+
// Seed cache with the falsy value 0 (a number, but the same logic applies to '',
1160+
// false, and null). Using `Onyx.set` ensures the value lands in cache and storage.
1161+
await Onyx.set(falsyKey, 0);
1162+
1163+
// Spy on Storage methods to confirm multiGet does NOT round-trip to storage for
1164+
// the cached falsy value.
1165+
const multiGetSpy = jest.spyOn(StorageMock, 'multiGet');
1166+
const getItemSpy = jest.spyOn(StorageMock, 'getItem');
1167+
1168+
const result = await OnyxUtils.multiGet([falsyKey]);
1169+
1170+
// The cached value must be returned without any storage read. Before this fix,
1171+
// `if (cacheValue)` treated the cached 0 as a miss and triggered Storage.multiGet,
1172+
// which would then overwrite the warm value via cache.merge().
1173+
expect(multiGetSpy).not.toHaveBeenCalled();
1174+
expect(getItemSpy).not.toHaveBeenCalled();
1175+
expect(result.get(falsyKey)).toBe(0);
1176+
});
1177+
1178+
it('prefers cache when a concurrent write lands during the storage read', async () => {
1179+
// Concurrent write during multiGet's storage read must not be overwritten by the
1180+
// stale snapshot via cache.merge.
1181+
const key = `${ONYXKEYS.COLLECTION.TEST_KEY}race`;
1182+
1183+
OnyxCache.drop(key);
1184+
OnyxCache.addKey(key);
1185+
1186+
// Set cache inside the mock so it lands before Storage.multiGet's promise resolves —
1187+
// multiGet's .then() then sees a populated cache and skips writing the stale value.
1188+
StorageMock.multiGet = jest.fn().mockImplementation(() => {
1189+
OnyxCache.set(key, {fresh: 'data'});
1190+
return Promise.resolve([[key, {stale: 'a', alsoStale: 'b'}]]);
1191+
});
1192+
1193+
const result = await OnyxUtils.multiGet([key]);
1194+
1195+
expect(OnyxCache.get(key)).toEqual({fresh: 'data'});
1196+
expect(result.get(key)).toEqual({fresh: 'data'});
1197+
});
1198+
});
1199+
9231200
describe('storage eviction', () => {
9241201
const diskFullError = new Error('database or disk is full');
9251202

0 commit comments

Comments
 (0)