Skip to content

Commit 8b18535

Browse files
elirangoshenclaude
andcommitted
fix: notify subscribers when evicting unrelated keys during write retry
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bd0b1e9 commit 8b18535

2 files changed

Lines changed: 81 additions & 7 deletions

File tree

lib/OnyxUtils.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,13 @@ function reportStorageQuota(error?: Error): Promise<void> {
802802
* - Non-retriable errors: logs an alert and resolves without retrying
803803
* - Other errors: retries the operation
804804
*/
805-
function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, onyxMethod: TMethod, defaultParams: Parameters<TMethod>[0], retryAttempt: number | undefined): Promise<void> {
805+
function retryOperation<TMethod extends RetriableOnyxOperation>(
806+
error: Error,
807+
onyxMethod: TMethod,
808+
defaultParams: Parameters<TMethod>[0],
809+
retryAttempt: number | undefined,
810+
inFlightKeys?: Set<OnyxKey>,
811+
): Promise<void> {
806812
const currentRetryAttempt = retryAttempt ?? 0;
807813
const nextRetryAttempt = currentRetryAttempt + 1;
808814

@@ -847,10 +853,12 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, on
847853
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`);
848854
reportStorageQuota(error);
849855

850-
// skipNotify=true: retry's orchestrator skips keysChanged on retryAttempt > 0, so we
851-
// must not let remove() fire keyChanged(undefined) — cache.set on retry restores the value.
856+
// Only suppress keyChanged(undefined) when the evicted key is part of the in-flight
857+
// write — then cache.set on retry will restore it. For unrelated keys, eviction is a
858+
// genuine loss and subscribers must see the removed state.
859+
const willBeRestored = inFlightKeys?.has(keyForRemoval) ?? false;
852860
// @ts-expect-error No overload matches this call.
853-
return remove(keyForRemoval, undefined, true).then(() => onyxMethod(defaultParams, nextRetryAttempt));
861+
return remove(keyForRemoval, undefined, willBeRestored).then(() => onyxMethod(defaultParams, nextRetryAttempt));
854862
}
855863

856864
/**
@@ -1425,8 +1433,10 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
14251433
return !OnyxKeys.isRamOnlyKey(key);
14261434
});
14271435

1436+
const inFlightKeys = new Set<OnyxKey>(keyValuePairsToSet.map(([key]) => key));
1437+
14281438
return Storage.multiSet(keyValuePairsToStore)
1429-
.catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt))
1439+
.catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt, inFlightKeys))
14301440
.then(() => {
14311441
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData);
14321442
});
@@ -1502,8 +1512,10 @@ function setCollectionWithRetry<TKey extends CollectionKeyBase>({collectionKey,
15021512
return;
15031513
}
15041514

1515+
const inFlightKeys = new Set<OnyxKey>(keyValuePairs.map(([key]) => key));
1516+
15051517
return Storage.multiSet(keyValuePairs)
1506-
.catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, {collectionKey, collection}, retryAttempt))
1518+
.catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, {collectionKey, collection}, retryAttempt, inFlightKeys))
15071519
.then(() => {
15081520
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
15091521
});
@@ -1649,13 +1661,16 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
16491661
promises.push(Storage.multiSet(keyValuePairsForNewCollection));
16501662
}
16511663

1664+
const inFlightKeys = new Set<OnyxKey>(Object.keys(finalMergedCollection));
1665+
16521666
return Promise.all(promises)
16531667
.catch((error) =>
16541668
retryOperation(
16551669
error,
16561670
mergeCollectionWithPatches,
16571671
{collectionKey, collection: resultCollection as OnyxMergeCollectionInput<TKey>, mergeReplaceNullPatches, isProcessingCollectionUpdate},
16581672
retryAttempt,
1673+
inFlightKeys,
16591674
),
16601675
)
16611676
.then(() => {
@@ -1723,8 +1738,10 @@ function partialSetCollection<TKey extends CollectionKeyBase>({collectionKey, co
17231738
return;
17241739
}
17251740

1741+
const inFlightKeys = new Set<OnyxKey>(keyValuePairs.map(([key]) => key));
1742+
17261743
return Storage.multiSet(keyValuePairs)
1727-
.catch((error) => retryOperation(error, partialSetCollection, {collectionKey, collection}, retryAttempt))
1744+
.catch((error) => retryOperation(error, partialSetCollection, {collectionKey, collection}, retryAttempt, inFlightKeys))
17281745
.then(() => {
17291746
sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);
17301747
});

tests/unit/onyxUtilsTest.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1181,6 +1181,63 @@ describe('OnyxUtils', () => {
11811181
expect(logInfoSpy).toHaveBeenCalledWith(`Out of storage. Evicting least recently accessed key (${key1}) and retrying. Error: ${diskFullError}`);
11821182
expect(logInfoSpy).toHaveBeenCalledWith(`Storage Quota Check -- bytesUsed: 0 bytesRemaining: Infinity. Original error: ${diskFullError}`);
11831183
});
1184+
1185+
it('multiSet — eviction of an UNRELATED key still notifies its subscribers (codex regression guard)', async () => {
1186+
const evictableKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`;
1187+
const writeKey = `${ONYXKEYS.COLLECTION.TEST_KEY}2`;
1188+
1189+
// Seed the evictable key first so it becomes the LRU evictable. The subsequent multiSet
1190+
// writes a DIFFERENT key, so the evicted key is unrelated to the in-flight write.
1191+
await LocalOnyx.set(evictableKey, {value: 'will-be-evicted'});
1192+
expect(LocalOnyxCache.getKeyForEviction()).toBe(evictableKey);
1193+
1194+
const subscriberCalls: unknown[] = [];
1195+
LocalOnyx.connect({
1196+
key: evictableKey,
1197+
callback: (value) => subscriberCalls.push(value),
1198+
});
1199+
await waitForPromisesToResolve();
1200+
subscriberCalls.length = 0;
1201+
1202+
// Storage.multiSet rejects once with disk-full, then succeeds on retry.
1203+
LocalStorageMock.multiSet = jest.fn(LocalStorageMock.multiSet).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiSet);
1204+
1205+
await LocalOnyx.multiSet({[writeKey]: {value: 'new'}});
1206+
1207+
// evictableKey was the LRU evictable, so retryOperation evicted it. It's not in the
1208+
// in-flight write's keys, so the retry's cache.set won't restore it — subscribers MUST
1209+
// see keyChanged(undefined) so they reflect the genuine removal (not stale value).
1210+
expect(LocalOnyxCache.hasCacheForKey(evictableKey)).toBe(false);
1211+
expect(subscriberCalls.at(-1)).toBeUndefined();
1212+
});
1213+
1214+
it('multiSet — eviction of an IN-FLIGHT key does not strand its subscriber', async () => {
1215+
const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`;
1216+
1217+
// Seed memberKey so it becomes the LRU evictable. The multiSet below writes to the SAME
1218+
// key, so eviction picks an in-flight key.
1219+
await LocalOnyx.set(memberKey, {value: 'original'});
1220+
expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey);
1221+
1222+
const subscriberCalls: unknown[] = [];
1223+
LocalOnyx.connect({
1224+
key: memberKey,
1225+
callback: (value) => subscriberCalls.push(value),
1226+
});
1227+
await waitForPromisesToResolve();
1228+
subscriberCalls.length = 0;
1229+
1230+
LocalStorageMock.multiSet = jest.fn(LocalStorageMock.multiSet).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiSet);
1231+
1232+
await LocalOnyx.multiSet({[memberKey]: {value: 'updated'}});
1233+
1234+
// The in-flight key was evicted then restored by the retry's cache.set. Subscriber's
1235+
// last value must be the new value, never a transient undefined from the eviction.
1236+
expect(LocalOnyxCache.get(memberKey)).toEqual({value: 'updated'});
1237+
expect(subscriberCalls.at(-1)).toEqual({value: 'updated'});
1238+
// Subscriber should never have seen undefined in the middle of the eviction-retry cycle.
1239+
expect(subscriberCalls).not.toContain(undefined);
1240+
});
11841241
});
11851242

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

0 commit comments

Comments
 (0)