Skip to content

Commit bd0b1e9

Browse files
elirangoshenclaude
andcommitted
fix: avoid subscriber re-fire when storage writes retry
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b506f32 commit bd0b1e9

2 files changed

Lines changed: 157 additions & 9 deletions

File tree

lib/OnyxUtils.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -769,9 +769,14 @@ function getCollectionDataAndSendAsObject<TKey extends OnyxKey>(matchingKeys: Co
769769
/**
770770
* Remove a key from Onyx and update the subscribers
771771
*/
772-
function remove<TKey extends OnyxKey>(key: TKey, isProcessingCollectionUpdate?: boolean): Promise<void> {
772+
function remove<TKey extends OnyxKey>(key: TKey, isProcessingCollectionUpdate?: boolean, skipNotify?: boolean): Promise<void> {
773773
cache.drop(key);
774-
keyChanged(key, undefined as OnyxValue<TKey>, undefined, isProcessingCollectionUpdate);
774+
// skipNotify is used by retryOperation's eviction branch — the imminent retry's cache.set
775+
// will re-populate cache, so firing keyChanged(undefined) here would only strand subscribers
776+
// in the "removed" state across the retry.
777+
if (!skipNotify) {
778+
keyChanged(key, undefined as OnyxValue<TKey>, undefined, isProcessingCollectionUpdate);
779+
}
775780

776781
if (OnyxKeys.isRamOnlyKey(key)) {
777782
return Promise.resolve();
@@ -842,8 +847,10 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, on
842847
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`);
843848
reportStorageQuota(error);
844849

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.
845852
// @ts-expect-error No overload matches this call.
846-
return remove(keyForRemoval).then(() => onyxMethod(defaultParams, nextRetryAttempt));
853+
return remove(keyForRemoval, undefined, true).then(() => onyxMethod(defaultParams, nextRetryAttempt));
847854
}
848855

849856
/**
@@ -1395,14 +1402,21 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
13951402
// so re-entrant callbacks (e.g. Onyx.set inside a callback) see consistent cache
13961403
// and subscriber state, matching the original per-key notification semantics.
13971404
cache.set(key, value);
1398-
keyChanged(key, value);
1405+
// Skip subscriber notification on retry — already notified on attempt 0.
1406+
// waitForCollectionCallback subscribers re-fire on every keyChanged by contract.
1407+
if (!retryAttempt) {
1408+
keyChanged(key, value);
1409+
}
13991410
}
14001411
}
14011412

14021413
// One keysChanged() per collection — fires each collection-level subscriber once and lets
14031414
// keysChanged() internally decide which individual member subscribers need notification.
1404-
for (const [collectionKey, batch] of collectionBatches) {
1405-
keysChanged(collectionKey as CollectionKeyBase, batch.partial, batch.previous);
1415+
// Skip on retry — already notified on attempt 0 (see same-reason comment above).
1416+
if (!retryAttempt) {
1417+
for (const [collectionKey, batch] of collectionBatches) {
1418+
keysChanged(collectionKey as CollectionKeyBase, batch.partial, batch.previous);
1419+
}
14061420
}
14071421

14081422
const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => {
@@ -1476,7 +1490,11 @@ function setCollectionWithRetry<TKey extends CollectionKeyBase>({collectionKey,
14761490

14771491
for (const [key, value] of keyValuePairs) cache.set(key, value);
14781492

1479-
keysChanged(collectionKey, mutableCollection, previousCollection);
1493+
// Skip subscriber notification on retry — already notified on attempt 0.
1494+
// waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1495+
if (!retryAttempt) {
1496+
keysChanged(collectionKey, mutableCollection, previousCollection);
1497+
}
14801498

14811499
// RAM-only keys are not supposed to be saved to storage
14821500
if (OnyxKeys.isRamOnlyKey(collectionKey)) {
@@ -1611,7 +1629,11 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
16111629
// write fails.
16121630
const previousCollection = getCachedCollection(collectionKey, existingKeys);
16131631
cache.merge(finalMergedCollection);
1614-
keysChanged(collectionKey, finalMergedCollection, previousCollection);
1632+
// Skip subscriber notification on retry — already notified on attempt 0.
1633+
// waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1634+
if (!retryAttempt) {
1635+
keysChanged(collectionKey, finalMergedCollection, previousCollection);
1636+
}
16151637

16161638
const promises = [];
16171639

@@ -1690,7 +1712,11 @@ function partialSetCollection<TKey extends CollectionKeyBase>({collectionKey, co
16901712

16911713
for (const [key, value] of keyValuePairs) cache.set(key, value);
16921714

1693-
keysChanged(collectionKey, mutableCollection, previousCollection);
1715+
// Skip subscriber notification on retry — already notified on attempt 0.
1716+
// waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1717+
if (!retryAttempt) {
1718+
keysChanged(collectionKey, mutableCollection, previousCollection);
1719+
}
16941720

16951721
if (OnyxKeys.isRamOnlyKey(collectionKey)) {
16961722
sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);

tests/unit/onyxUtilsTest.ts

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

923+
describe('retry side-effect idempotency', () => {
924+
// Save originals so each test can replace StorageMock.multiMerge / StorageMock.multiSet
925+
// with a one-shot rejecting mock that triggers retryOperation's transient-error path.
926+
// Restoring keeps mocks from leaking into the storage-eviction describe block below.
927+
const originalMultiMerge = StorageMock.multiMerge;
928+
const originalMultiSet = StorageMock.multiSet;
929+
930+
afterEach(() => {
931+
StorageMock.multiMerge = originalMultiMerge;
932+
StorageMock.multiSet = originalMultiSet;
933+
});
934+
935+
// A retriable error: not in NON_RETRIABLE_ERRORS, not in STORAGE_ERRORS, so retryOperation
936+
// re-enters the failing method on the next attempt.
937+
const transientError = new Error('Transient storage error');
938+
939+
it('mergeCollection — waitForCollectionCallback subscriber fires once across retries', async () => {
940+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
941+
const existingMemberKey = `${collectionKey}1`;
942+
const newMemberKey = `${collectionKey}2`;
943+
944+
await Onyx.set(existingMemberKey, {value: 'initial'});
945+
946+
const collectionCallback = jest.fn();
947+
Onyx.connect({
948+
key: collectionKey,
949+
waitForCollectionCallback: true,
950+
callback: collectionCallback,
951+
});
952+
await waitForPromisesToResolve();
953+
collectionCallback.mockClear();
954+
955+
StorageMock.multiMerge = jest.fn(originalMultiMerge).mockRejectedValueOnce(transientError);
956+
957+
await Onyx.mergeCollection(collectionKey, {
958+
[existingMemberKey]: {value: 'merged'},
959+
[newMemberKey]: {value: 'new'},
960+
} as GenericCollection);
961+
962+
// Before this fix, every retry attempt re-fired keysChanged() — and
963+
// waitForCollectionCallback subscribers fire on every keysChanged() call by contract.
964+
// After the fix, retries skip the keysChanged re-fire, so subscribers are notified
965+
// exactly once per logical operation.
966+
expect(collectionCallback).toHaveBeenCalledTimes(1);
967+
});
968+
969+
it('Onyx.multiSet — collection subscriber fires once across retries', async () => {
970+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
971+
const memberKey1 = `${collectionKey}1`;
972+
const memberKey2 = `${collectionKey}2`;
973+
974+
const collectionCallback = jest.fn();
975+
Onyx.connect({
976+
key: collectionKey,
977+
waitForCollectionCallback: true,
978+
callback: collectionCallback,
979+
});
980+
await waitForPromisesToResolve();
981+
collectionCallback.mockClear();
982+
983+
StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError);
984+
985+
await Onyx.multiSet({
986+
[memberKey1]: {value: 'first'},
987+
[memberKey2]: {value: 'second'},
988+
});
989+
990+
expect(collectionCallback).toHaveBeenCalledTimes(1);
991+
});
992+
993+
it('Onyx.setCollection — collection subscriber fires once across retries', async () => {
994+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
995+
const memberKey1 = `${collectionKey}1`;
996+
const memberKey2 = `${collectionKey}2`;
997+
998+
const collectionCallback = jest.fn();
999+
Onyx.connect({
1000+
key: collectionKey,
1001+
waitForCollectionCallback: true,
1002+
callback: collectionCallback,
1003+
});
1004+
await waitForPromisesToResolve();
1005+
collectionCallback.mockClear();
1006+
1007+
StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError);
1008+
1009+
await Onyx.setCollection(collectionKey, {
1010+
[memberKey1]: {value: 'first'},
1011+
[memberKey2]: {value: 'second'},
1012+
} as GenericCollection);
1013+
1014+
expect(collectionCallback).toHaveBeenCalledTimes(1);
1015+
});
1016+
1017+
it('OnyxUtils.partialSetCollection — collection subscriber fires once across retries', async () => {
1018+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
1019+
const memberKey1 = `${collectionKey}1`;
1020+
const memberKey2 = `${collectionKey}2`;
1021+
1022+
const collectionCallback = jest.fn();
1023+
Onyx.connect({
1024+
key: collectionKey,
1025+
waitForCollectionCallback: true,
1026+
callback: collectionCallback,
1027+
});
1028+
await waitForPromisesToResolve();
1029+
collectionCallback.mockClear();
1030+
1031+
StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError);
1032+
1033+
await OnyxUtils.partialSetCollection({
1034+
collectionKey,
1035+
collection: {
1036+
[memberKey1]: {value: 'first'},
1037+
[memberKey2]: {value: 'second'},
1038+
} as GenericCollection,
1039+
});
1040+
1041+
expect(collectionCallback).toHaveBeenCalledTimes(1);
1042+
});
1043+
});
1044+
9231045
describe('storage eviction', () => {
9241046
const diskFullError = new Error('database or disk is full');
9251047

0 commit comments

Comments
 (0)