Skip to content

Commit 11bc0a6

Browse files
committed
Restore cache state for evicted in-flight keys on retry
Addresses Codex P1 on #792 review: #792 (comment) When `retryOperation()` evicts an LRU key via `remove()` on a storage capacity error, `remove()` immediately drops the key from cache and fires `keyChanged(undefined)`. With the orchestrator/write-helper split introduced in this PR, the retry only re-attempts the storage write -- it does not re-apply cache state. So if the evicted key is part of the in-flight write, cache + subscribers stay in the "removed" state even though the storage retry eventually succeeds, leaving the App's view of the world inconsistent with persisted data. Fix: each orchestrator now constructs a `restoreEvictedKey(key)` closure that re-applies its cache write + subscriber notification for a single in-flight key, and passes it via the write helper's params. `retryOperation` invokes the closure after `remove(keyForRemoval)`, before re-entering the write helper. The closure is a no-op when the evicted key isn't part of this write (the typical case where some unrelated evictable key gets selected). For mergeCollection specifically, the closure snapshots the cache's post-merge state for in-flight keys (rather than just the merge delta) so that previously-merged-in fields are preserved when the eviction's cache.drop forces us to re-populate cache from scratch. Adds 3 regression tests under `storage eviction`: - `multiSet` -- LRU evictable is the in-flight key (persistMultiSetWrite path) - `setCollection` -- same scenario via persistCollectionWrite - `mergeCollection` -- same scenario via persistMergedCollectionWrite, asserts the previously-existing `id` field survives the eviction All 3 tests fail with `Received: undefined` / missing fields if the lib changes are reverted; pass with them in place. All 459 unit tests pass (456 existing + 3 new).
1 parent de62ae5 commit 11bc0a6

2 files changed

Lines changed: 187 additions & 7 deletions

File tree

lib/OnyxUtils.ts

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -842,8 +842,20 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, on
842842
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`);
843843
reportStorageQuota(error);
844844

845-
// @ts-expect-error No overload matches this call.
846-
return remove(keyForRemoval).then(() => onyxMethod(defaultParams, nextRetryAttempt));
845+
return remove(keyForRemoval).then(() => {
846+
// remove() drops `keyForRemoval` from cache and fires keyChanged(undefined). If that key is
847+
// part of this in-flight write, the upcoming retry would land the value in storage but
848+
// cache + subscribers would stay in the "removed" state. Each orchestrator passes a
849+
// restoreEvictedKey closure via params that re-applies the orchestrator's cache write +
850+
// subscriber notification for the evicted key only — preserving the cache-first invariant
851+
// across eviction-driven retries.
852+
const restore = (defaultParams as {restoreEvictedKey?: (key: OnyxKey) => void}).restoreEvictedKey;
853+
if (typeof restore === 'function') {
854+
restore(keyForRemoval);
855+
}
856+
// @ts-expect-error No overload matches this call.
857+
return onyxMethod(defaultParams, nextRetryAttempt);
858+
});
847859
}
848860

849861
/**
@@ -1338,7 +1350,10 @@ function setWithRetry<TKey extends OnyxKey>({key, value, options}: SetParams<TKe
13381350
* storage step. Cache and subscriber notifications already happened in the orchestrator, so
13391351
* retries no longer re-fire `waitForCollectionCallback` subscribers with the same payload.
13401352
*/
1341-
function persistMultiSetWrite(params: {keyValuePairsToStore: StorageKeyValuePair[]; newData: OnyxMultiSetInput}, retryAttempt?: number): Promise<void> {
1353+
function persistMultiSetWrite(
1354+
params: {keyValuePairsToStore: StorageKeyValuePair[]; newData: OnyxMultiSetInput; restoreEvictedKey?: (evictedKey: OnyxKey) => void},
1355+
retryAttempt?: number,
1356+
): Promise<void> {
13421357
const {keyValuePairsToStore, newData} = params;
13431358

13441359
return Storage.multiSet(keyValuePairsToStore)
@@ -1426,7 +1441,31 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
14261441
return !OnyxKeys.isRamOnlyKey(key);
14271442
});
14281443

1429-
return persistMultiSetWrite({keyValuePairsToStore, newData}, retryAttempt);
1444+
// Capture the in-flight key/value map so retryOperation can re-apply cache state for any
1445+
// evicted-then-retried key (see retryOperation's eviction branch for context).
1446+
const inFlightValueByKey = new Map<OnyxKey, OnyxValue<OnyxKey>>();
1447+
for (const [inFlightKey, inFlightValue] of keyValuePairsToSet) {
1448+
inFlightValueByKey.set(inFlightKey, inFlightValue);
1449+
}
1450+
const restoreEvictedKey = (evictedKey: OnyxKey): void => {
1451+
if (!inFlightValueByKey.has(evictedKey)) {
1452+
return;
1453+
}
1454+
const value = inFlightValueByKey.get(evictedKey) as OnyxValue<OnyxKey>;
1455+
const evictedCollectionKey = OnyxKeys.getCollectionKey(evictedKey);
1456+
cache.set(evictedKey, value);
1457+
if (evictedCollectionKey && OnyxKeys.isCollectionMemberKey(evictedCollectionKey, evictedKey)) {
1458+
keysChanged(
1459+
evictedCollectionKey as CollectionKeyBase,
1460+
{[evictedKey]: value} as Record<string, OnyxValue<OnyxKey>>,
1461+
{[evictedKey]: undefined} as Record<string, OnyxValue<OnyxKey>>,
1462+
);
1463+
} else {
1464+
keyChanged(evictedKey, value);
1465+
}
1466+
};
1467+
1468+
return persistMultiSetWrite({keyValuePairsToStore, newData, restoreEvictedKey}, retryAttempt);
14301469
}
14311470

14321471
/**
@@ -1439,6 +1478,7 @@ function persistCollectionWrite<TKey extends CollectionKeyBase>(
14391478
collectionKey: TKey;
14401479
keyValuePairs: StorageKeyValuePair[];
14411480
mutableCollection: OnyxInputKeyValueMapping;
1481+
restoreEvictedKey?: (evictedKey: OnyxKey) => void;
14421482
},
14431483
retryAttempt?: number,
14441484
): Promise<void> {
@@ -1517,7 +1557,22 @@ function setCollectionWithRetry<TKey extends CollectionKeyBase>({collectionKey,
15171557

15181558
keysChanged(collectionKey, mutableCollection, previousCollection);
15191559

1520-
return persistCollectionWrite({collectionKey, keyValuePairs, mutableCollection}, retryAttempt);
1560+
// Capture the in-flight key/value map so retryOperation can re-apply cache state for any
1561+
// evicted-then-retried key (see retryOperation's eviction branch for context).
1562+
const inFlightValueByKey = new Map<OnyxKey, OnyxValue<OnyxKey>>();
1563+
for (const [inFlightKey, inFlightValue] of keyValuePairs) {
1564+
inFlightValueByKey.set(inFlightKey, inFlightValue);
1565+
}
1566+
const restoreEvictedKey = (evictedKey: OnyxKey): void => {
1567+
if (!inFlightValueByKey.has(evictedKey)) {
1568+
return;
1569+
}
1570+
const value = inFlightValueByKey.get(evictedKey) as OnyxValue<OnyxKey>;
1571+
cache.set(evictedKey, value);
1572+
keysChanged(collectionKey, {[evictedKey]: value} as Record<string, OnyxValue<OnyxKey>>, {[evictedKey]: undefined} as Record<string, OnyxValue<OnyxKey>>);
1573+
};
1574+
1575+
return persistCollectionWrite({collectionKey, keyValuePairs, mutableCollection, restoreEvictedKey}, retryAttempt);
15211576
});
15221577
}
15231578

@@ -1533,6 +1588,7 @@ function persistMergedCollectionWrite<TKey extends CollectionKeyBase>(
15331588
keyValuePairsForExistingCollection: StorageKeyValuePair[];
15341589
keyValuePairsForNewCollection: StorageKeyValuePair[];
15351590
resultCollection: OnyxInputKeyValueMapping;
1591+
restoreEvictedKey?: (evictedKey: OnyxKey) => void;
15361592
},
15371593
retryAttempt?: number,
15381594
): Promise<void> {
@@ -1678,7 +1734,29 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
16781734
cache.merge(finalMergedCollection);
16791735
keysChanged(collectionKey, finalMergedCollection, previousCollection);
16801736

1681-
return persistMergedCollectionWrite({collectionKey, keyValuePairsForExistingCollection, keyValuePairsForNewCollection, resultCollection}, retryAttempt);
1737+
// Snapshot the post-merge cache values for the in-flight keys. The orchestrator's
1738+
// cache.merge already merged the deltas into the previous storage values, so the cache
1739+
// now holds the *full* merged value for each key. Using the snapshot (rather than the
1740+
// delta in `finalMergedCollection`) preserves previously-merged-in fields when the
1741+
// eviction's cache.drop forces us to re-populate cache from scratch on retry.
1742+
const inFlightMergedSnapshot = new Map<OnyxKey, OnyxValue<OnyxKey>>();
1743+
for (const inFlightKey of Object.keys(finalMergedCollection)) {
1744+
inFlightMergedSnapshot.set(inFlightKey, cache.get(inFlightKey));
1745+
}
1746+
1747+
// Closure invoked by retryOperation after an eviction picks a key that's part of this
1748+
// merge. We re-apply the full merged value + a keysChanged notification for that one
1749+
// key so subscribers don't stay in the "removed" state across the imminent storage retry.
1750+
const restoreEvictedKey = (evictedKey: OnyxKey): void => {
1751+
if (!inFlightMergedSnapshot.has(evictedKey)) {
1752+
return;
1753+
}
1754+
const value = inFlightMergedSnapshot.get(evictedKey) as OnyxValue<OnyxKey>;
1755+
cache.set(evictedKey, value);
1756+
keysChanged(collectionKey, {[evictedKey]: value} as Record<string, OnyxValue<OnyxKey>>, {[evictedKey]: undefined} as Record<string, OnyxValue<OnyxKey>>);
1757+
};
1758+
1759+
return persistMergedCollectionWrite({collectionKey, keyValuePairsForExistingCollection, keyValuePairsForNewCollection, resultCollection, restoreEvictedKey}, retryAttempt);
16821760
});
16831761
})
16841762
.then(() => undefined);
@@ -1732,7 +1810,22 @@ function partialSetCollection<TKey extends CollectionKeyBase>({collectionKey, co
17321810

17331811
keysChanged(collectionKey, mutableCollection, previousCollection);
17341812

1735-
return persistCollectionWrite({collectionKey, keyValuePairs, mutableCollection}, retryAttempt);
1813+
// Capture the in-flight key/value map so retryOperation can re-apply cache state for any
1814+
// evicted-then-retried key (see retryOperation's eviction branch for context).
1815+
const inFlightValueByKey = new Map<OnyxKey, OnyxValue<OnyxKey>>();
1816+
for (const [inFlightKey, inFlightValue] of keyValuePairs) {
1817+
inFlightValueByKey.set(inFlightKey, inFlightValue);
1818+
}
1819+
const restoreEvictedKey = (evictedKey: OnyxKey): void => {
1820+
if (!inFlightValueByKey.has(evictedKey)) {
1821+
return;
1822+
}
1823+
const value = inFlightValueByKey.get(evictedKey) as OnyxValue<OnyxKey>;
1824+
cache.set(evictedKey, value);
1825+
keysChanged(collectionKey, {[evictedKey]: value} as Record<string, OnyxValue<OnyxKey>>, {[evictedKey]: undefined} as Record<string, OnyxValue<OnyxKey>>);
1826+
};
1827+
1828+
return persistCollectionWrite({collectionKey, keyValuePairs, mutableCollection, restoreEvictedKey}, retryAttempt);
17361829
});
17371830
}
17381831

tests/unit/onyxUtilsTest.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,6 +1209,93 @@ describe('OnyxUtils', () => {
12091209
expect(logInfoSpy).toHaveBeenCalledWith(`Out of storage. Evicting least recently accessed key (${key1}) and retrying. Error: ${diskFullError}`);
12101210
expect(logInfoSpy).toHaveBeenCalledWith(`Storage Quota Check -- bytesUsed: 0 bytesRemaining: Infinity. Original error: ${diskFullError}`);
12111211
});
1212+
1213+
// Eviction-restoration tests: when retryOperation evicts a key via remove() that happens to be
1214+
// part of the in-flight write, the orchestrator's restoreEvictedKey closure must re-apply
1215+
// cache state for that key. Otherwise cache + subscribers stay in the "removed" state from
1216+
// the eviction's keyChanged(undefined) even after the retry succeeds in storage.
1217+
it('multiSet — restores cache + subscribers when the in-flight key is the LRU evictable', async () => {
1218+
const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`;
1219+
1220+
// Seed memberKey so it enters the recentlyAccessedKeys set. It's the only evictable key,
1221+
// so getKeyForEviction() will return memberKey at the time of the multiSet retry.
1222+
await LocalOnyx.set(memberKey, {id: 1, value: 'original'});
1223+
expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey);
1224+
1225+
const subscriberCalls: Array<unknown> = [];
1226+
LocalOnyx.connect({
1227+
key: memberKey,
1228+
callback: (value) => subscriberCalls.push(value),
1229+
});
1230+
await waitForPromisesToResolve();
1231+
subscriberCalls.length = 0;
1232+
1233+
// Storage.multiSet rejects once with disk-full, then succeeds on retry.
1234+
LocalStorageMock.multiSet = jest.fn(LocalStorageMock.multiSet).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiSet);
1235+
1236+
await LocalOnyx.multiSet({[memberKey]: {id: 1, value: 'updated'}});
1237+
1238+
// Cache must reflect the new value, not undefined from the eviction's remove().
1239+
expect(LocalOnyxCache.get(memberKey)).toEqual({id: 1, value: 'updated'});
1240+
1241+
// Subscriber's last value must be the new value, not undefined.
1242+
expect(subscriberCalls.at(-1)).toEqual({id: 1, value: 'updated'});
1243+
});
1244+
1245+
it('setCollection — restores cache + subscribers when the in-flight key is the LRU evictable', async () => {
1246+
const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`;
1247+
1248+
await LocalOnyx.set(memberKey, {id: 1, value: 'original'});
1249+
expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey);
1250+
1251+
const collectionCalls: Array<unknown> = [];
1252+
LocalOnyx.connect({
1253+
key: ONYXKEYS.COLLECTION.TEST_KEY,
1254+
waitForCollectionCallback: true,
1255+
callback: (value) => collectionCalls.push(value),
1256+
});
1257+
await waitForPromisesToResolve();
1258+
collectionCalls.length = 0;
1259+
1260+
LocalStorageMock.multiSet = jest.fn(LocalStorageMock.multiSet).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiSet);
1261+
1262+
await LocalOnyx.setCollection(ONYXKEYS.COLLECTION.TEST_KEY, {
1263+
[memberKey]: {id: 1, value: 'updated'},
1264+
} as GenericCollection);
1265+
1266+
expect(LocalOnyxCache.get(memberKey)).toEqual({id: 1, value: 'updated'});
1267+
// Last collection callback must include the restored member with its new value.
1268+
const lastBroadcast = collectionCalls.at(-1) as Record<string, unknown> | undefined;
1269+
expect(lastBroadcast?.[memberKey]).toEqual({id: 1, value: 'updated'});
1270+
});
1271+
1272+
it('mergeCollection — restores cache + subscribers when the in-flight key is the LRU evictable', async () => {
1273+
const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`;
1274+
1275+
await LocalOnyx.set(memberKey, {id: 1, value: 'original'});
1276+
expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey);
1277+
1278+
const collectionCalls: Array<unknown> = [];
1279+
LocalOnyx.connect({
1280+
key: ONYXKEYS.COLLECTION.TEST_KEY,
1281+
waitForCollectionCallback: true,
1282+
callback: (value) => collectionCalls.push(value),
1283+
});
1284+
await waitForPromisesToResolve();
1285+
collectionCalls.length = 0;
1286+
1287+
// mergeCollection routes existing keys through multiMerge — fail it once then succeed.
1288+
LocalStorageMock.multiMerge = jest.fn(LocalStorageMock.multiMerge).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiMerge);
1289+
1290+
await LocalOnyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, {
1291+
[memberKey]: {value: 'merged'},
1292+
} as GenericCollection);
1293+
1294+
// Cache must reflect the merged value, not undefined from the eviction's remove().
1295+
expect(LocalOnyxCache.get(memberKey)).toEqual({id: 1, value: 'merged'});
1296+
const lastBroadcast = collectionCalls.at(-1) as Record<string, unknown> | undefined;
1297+
expect(lastBroadcast?.[memberKey]).toEqual({id: 1, value: 'merged'});
1298+
});
12121299
});
12131300

12141301
describe('afterInit', () => {

0 commit comments

Comments
 (0)