Skip to content

Commit bfd6902

Browse files
elirangoshenclaude
andcommitted
Make retried Onyx writes idempotent on storage retry
Follow-up to Expensify#787. Splits each retriable collection write into an orchestrator (cache + subscriber notify + storage prep) and a small file-private write helper (Storage.multiSet/multiMerge + retryOperation + devtools log). retryOperation re-enters the write helper, not the orchestrator, which fixes two pre-existing bugs flagged in Expensify#787 review: 1. New keys no longer get silently downgraded to multiMerge on retry. The existing/new key split was previously re-derived from getAllKeys against a cache that the first attempt had already mutated, routing brand-new keys through multiMerge instead of multiSet on retry. Benign on IDB but wrong semantics and a crash on backends that require multiSet for missing keys. 2. keysChanged/keyChanged subscribers (notably waitForCollectionCallback ones, which re-fire on every call by contract) are now notified exactly once per logical operation rather than once per retry attempt. Affected paths: mergeCollectionWithPatches, multiSetWithRetry, setCollectionWithRetry, partialSetCollection. setWithRetry was already safe via its hasValueChanged guard. partialSetCollection shares the new persistCollectionWrite helper with setCollectionWithRetry since their storage tails are identical. Adds a 'retry side-effect idempotency' test block covering all four paths. Tests confirmed to fail without the lib changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d674d7c commit bfd6902

3 files changed

Lines changed: 237 additions & 50 deletions

File tree

lib/OnyxUtils.ts

Lines changed: 84 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,6 +1333,21 @@ function setWithRetry<TKey extends OnyxKey>({key, value, options}: SetParams<TKe
13331333
});
13341334
}
13351335

1336+
/**
1337+
* Storage-write tail of multiSetWithRetry, isolated so that retryOperation re-enters only the
1338+
* storage step. Cache and subscriber notifications already happened in the orchestrator, so
1339+
* retries no longer re-fire `waitForCollectionCallback` subscribers with the same payload.
1340+
*/
1341+
function persistMultiSetWrite(params: {keyValuePairsToStore: StorageKeyValuePair[]; newData: OnyxMultiSetInput}, retryAttempt?: number): Promise<void> {
1342+
const {keyValuePairsToStore, newData} = params;
1343+
1344+
return Storage.multiSet(keyValuePairsToStore)
1345+
.catch((error) => OnyxUtils.retryOperation(error, persistMultiSetWrite, params, retryAttempt))
1346+
.then(() => {
1347+
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData);
1348+
});
1349+
}
1350+
13361351
/**
13371352
* Sets multiple keys and values.
13381353
* Serves as core implementation for `Onyx.multiSet()` public function, the difference being
@@ -1411,10 +1426,34 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
14111426
return !OnyxKeys.isRamOnlyKey(key);
14121427
});
14131428

1414-
return Storage.multiSet(keyValuePairsToStore)
1415-
.catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt))
1429+
return persistMultiSetWrite({keyValuePairsToStore, newData}, retryAttempt);
1430+
}
1431+
1432+
/**
1433+
* Storage-write tail of setCollectionWithRetry, isolated so that retryOperation re-enters only the
1434+
* storage step. Cache and subscriber notifications already happened in the orchestrator, so
1435+
* retries no longer re-fire `waitForCollectionCallback` subscribers with the same payload.
1436+
*/
1437+
function persistCollectionWrite<TKey extends CollectionKeyBase>(
1438+
params: {
1439+
collectionKey: TKey;
1440+
keyValuePairs: StorageKeyValuePair[];
1441+
mutableCollection: OnyxInputKeyValueMapping;
1442+
},
1443+
retryAttempt?: number,
1444+
): Promise<void> {
1445+
const {collectionKey, keyValuePairs, mutableCollection} = params;
1446+
1447+
// RAM-only keys never persist to storage.
1448+
if (OnyxKeys.isRamOnlyKey(collectionKey)) {
1449+
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
1450+
return Promise.resolve();
1451+
}
1452+
1453+
return Storage.multiSet(keyValuePairs)
1454+
.catch((error) => OnyxUtils.retryOperation(error, persistCollectionWrite, params, retryAttempt))
14161455
.then(() => {
1417-
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData);
1456+
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
14181457
});
14191458
}
14201459

@@ -1478,20 +1517,46 @@ function setCollectionWithRetry<TKey extends CollectionKeyBase>({collectionKey,
14781517

14791518
keysChanged(collectionKey, mutableCollection, previousCollection);
14801519

1481-
// RAM-only keys are not supposed to be saved to storage
1482-
if (OnyxKeys.isRamOnlyKey(collectionKey)) {
1483-
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
1484-
return;
1485-
}
1486-
1487-
return Storage.multiSet(keyValuePairs)
1488-
.catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, {collectionKey, collection}, retryAttempt))
1489-
.then(() => {
1490-
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
1491-
});
1520+
return persistCollectionWrite({collectionKey, keyValuePairs, mutableCollection}, retryAttempt);
14921521
});
14931522
}
14941523

1524+
/**
1525+
* Storage-write tail of mergeCollectionWithPatches, isolated so that retryOperation re-enters only
1526+
* the storage step. Cache and subscriber notifications already happened in the orchestrator, and
1527+
* the existing/new key split is captured in `params` — so retries don't re-fire subscribers and
1528+
* don't reclassify keys against a cache that was already mutated on the first attempt.
1529+
*/
1530+
function persistMergedCollectionWrite<TKey extends CollectionKeyBase>(
1531+
params: {
1532+
collectionKey: TKey;
1533+
keyValuePairsForExistingCollection: StorageKeyValuePair[];
1534+
keyValuePairsForNewCollection: StorageKeyValuePair[];
1535+
resultCollection: OnyxInputKeyValueMapping;
1536+
},
1537+
retryAttempt?: number,
1538+
): Promise<void> {
1539+
const {collectionKey, keyValuePairsForExistingCollection, keyValuePairsForNewCollection, resultCollection} = params;
1540+
1541+
const promises = [];
1542+
1543+
// New keys are added via multiSet while existing keys are updated using multiMerge; using
1544+
// multiMerge on a key that doesn't exist yet throws on some storage backends. RAM-only keys
1545+
// never persist to storage.
1546+
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) {
1547+
promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));
1548+
}
1549+
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) {
1550+
promises.push(Storage.multiSet(keyValuePairsForNewCollection));
1551+
}
1552+
1553+
return Promise.all(promises)
1554+
.catch((error) => retryOperation(error, persistMergedCollectionWrite, params, retryAttempt))
1555+
.then(() => {
1556+
sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
1557+
});
1558+
}
1559+
14951560
/**
14961561
* Merges a collection based on their keys.
14971562
* Serves as core implementation for `Onyx.mergeCollection()` public function, the difference being
@@ -1613,32 +1678,7 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
16131678
cache.merge(finalMergedCollection);
16141679
keysChanged(collectionKey, finalMergedCollection, previousCollection);
16151680

1616-
const promises = [];
1617-
1618-
// New keys will be added via multiSet while existing keys will be updated using multiMerge
1619-
// This is because setting a key that doesn't exist yet with multiMerge will throw errors
1620-
// We can skip this step for RAM-only keys as they should never be saved to storage
1621-
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) {
1622-
promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));
1623-
}
1624-
1625-
// We can skip this step for RAM-only keys as they should never be saved to storage
1626-
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) {
1627-
promises.push(Storage.multiSet(keyValuePairsForNewCollection));
1628-
}
1629-
1630-
return Promise.all(promises)
1631-
.catch((error) =>
1632-
retryOperation(
1633-
error,
1634-
mergeCollectionWithPatches,
1635-
{collectionKey, collection: resultCollection as OnyxMergeCollectionInput<TKey>, mergeReplaceNullPatches, isProcessingCollectionUpdate},
1636-
retryAttempt,
1637-
),
1638-
)
1639-
.then(() => {
1640-
sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
1641-
});
1681+
return persistMergedCollectionWrite({collectionKey, keyValuePairsForExistingCollection, keyValuePairsForNewCollection, resultCollection}, retryAttempt);
16421682
});
16431683
})
16441684
.then(() => undefined);
@@ -1692,16 +1732,7 @@ function partialSetCollection<TKey extends CollectionKeyBase>({collectionKey, co
16921732

16931733
keysChanged(collectionKey, mutableCollection, previousCollection);
16941734

1695-
if (OnyxKeys.isRamOnlyKey(collectionKey)) {
1696-
sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);
1697-
return;
1698-
}
1699-
1700-
return Storage.multiSet(keyValuePairs)
1701-
.catch((error) => retryOperation(error, partialSetCollection, {collectionKey, collection}, retryAttempt))
1702-
.then(() => {
1703-
sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);
1704-
});
1735+
return persistCollectionWrite({collectionKey, keyValuePairs, mutableCollection}, retryAttempt);
17051736
});
17061737
}
17071738

@@ -1766,12 +1797,15 @@ const OnyxUtils = {
17661797
reduceCollectionWithSelector,
17671798
updateSnapshots,
17681799
mergeCollectionWithPatches,
1800+
persistMergedCollectionWrite,
17691801
partialSetCollection,
17701802
logKeyChanged,
17711803
logKeyRemoved,
17721804
setWithRetry,
17731805
multiSetWithRetry,
1806+
persistMultiSetWrite,
17741807
setCollectionWithRetry,
1808+
persistCollectionWrite,
17751809
};
17761810

17771811
export type {OnyxMethod};

lib/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,8 +371,11 @@ type MergeCollectionWithPatchesParams<TKey extends CollectionKeyBase> = {
371371
type RetriableOnyxOperation =
372372
| typeof OnyxUtils.setWithRetry
373373
| typeof OnyxUtils.multiSetWithRetry
374+
| typeof OnyxUtils.persistMultiSetWrite
374375
| typeof OnyxUtils.setCollectionWithRetry
376+
| typeof OnyxUtils.persistCollectionWrite
375377
| typeof OnyxUtils.mergeCollectionWithPatches
378+
| typeof OnyxUtils.persistMergedCollectionWrite
376379
| typeof OnyxUtils.partialSetCollection;
377380

378381
/**

tests/unit/onyxUtilsTest.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,156 @@ 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 mock that rejects once and then resolves, exercising the retryOperation path
926+
// without burning the full retry budget. Restoring keeps mocks from leaking into the
927+
// storage-eviction describe block below (which depends on these storage methods).
928+
const originalMultiMerge = StorageMock.multiMerge;
929+
const originalMultiSet = StorageMock.multiSet;
930+
931+
afterEach(() => {
932+
StorageMock.multiMerge = originalMultiMerge;
933+
StorageMock.multiSet = originalMultiSet;
934+
});
935+
936+
// A retriable error: not in NON_RETRIABLE_ERRORS, not in STORAGE_ERRORS, so retryOperation
937+
// re-enters the failing method on the next attempt.
938+
const transientError = new Error('Transient storage error');
939+
940+
it('mergeCollection — new keys still route through multiSet on retry, not downgraded to multiMerge', async () => {
941+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
942+
const existingMemberKey = `${collectionKey}1`;
943+
const newMemberKey = `${collectionKey}2`;
944+
945+
// Seed an existing member via setItem so the multiMerge mock below doesn't intercept seeding.
946+
await Onyx.set(existingMemberKey, {value: 'initial'});
947+
948+
const multiMergeSpy = jest.fn(originalMultiMerge).mockRejectedValueOnce(transientError);
949+
StorageMock.multiMerge = multiMergeSpy;
950+
951+
await Onyx.mergeCollection(collectionKey, {
952+
[existingMemberKey]: {value: 'merged'},
953+
[newMemberKey]: {value: 'new'},
954+
} as GenericCollection);
955+
956+
// Before this fix, the retry attempt re-derived the existing/new split against a cache
957+
// already mutated by the first attempt, which silently routed `newMemberKey` through
958+
// multiMerge. Benign on IDB (multiMerge on a missing key behaves like set) but wrong
959+
// semantically and a crash on storage backends that require multiSet for new keys.
960+
const allMultiMergeKeys = multiMergeSpy.mock.calls.flatMap((args) => (args[0] as Array<[string, unknown]>).map(([key]) => key));
961+
expect(allMultiMergeKeys).not.toContain(newMemberKey);
962+
963+
// Sanity: the retry actually happened.
964+
expect(multiMergeSpy).toHaveBeenCalledTimes(2);
965+
});
966+
967+
it('mergeCollection — waitForCollectionCallback subscriber fires once across retries', async () => {
968+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
969+
const existingMemberKey = `${collectionKey}1`;
970+
const newMemberKey = `${collectionKey}2`;
971+
972+
await Onyx.set(existingMemberKey, {value: 'initial'});
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.multiMerge = jest.fn(originalMultiMerge).mockRejectedValueOnce(transientError);
984+
985+
await Onyx.mergeCollection(collectionKey, {
986+
[existingMemberKey]: {value: 'merged'},
987+
[newMemberKey]: {value: 'new'},
988+
} as GenericCollection);
989+
990+
// Before this fix, every retry attempt re-fired keysChanged() — and
991+
// waitForCollectionCallback subscribers fire on every keysChanged() call by contract.
992+
// After the fix, retries re-enter only the storage-write helper, so subscribers are
993+
// notified exactly once per logical operation.
994+
expect(collectionCallback).toHaveBeenCalledTimes(1);
995+
});
996+
997+
it('Onyx.multiSet — collection subscriber fires once across retries', async () => {
998+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
999+
const memberKey1 = `${collectionKey}1`;
1000+
const memberKey2 = `${collectionKey}2`;
1001+
1002+
const collectionCallback = jest.fn();
1003+
Onyx.connect({
1004+
key: collectionKey,
1005+
waitForCollectionCallback: true,
1006+
callback: collectionCallback,
1007+
});
1008+
await waitForPromisesToResolve();
1009+
collectionCallback.mockClear();
1010+
1011+
StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError);
1012+
1013+
await Onyx.multiSet({
1014+
[memberKey1]: {value: 'first'},
1015+
[memberKey2]: {value: 'second'},
1016+
});
1017+
1018+
expect(collectionCallback).toHaveBeenCalledTimes(1);
1019+
});
1020+
1021+
it('Onyx.setCollection — collection subscriber fires once across retries', async () => {
1022+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
1023+
const memberKey1 = `${collectionKey}1`;
1024+
const memberKey2 = `${collectionKey}2`;
1025+
1026+
const collectionCallback = jest.fn();
1027+
Onyx.connect({
1028+
key: collectionKey,
1029+
waitForCollectionCallback: true,
1030+
callback: collectionCallback,
1031+
});
1032+
await waitForPromisesToResolve();
1033+
collectionCallback.mockClear();
1034+
1035+
StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError);
1036+
1037+
await Onyx.setCollection(collectionKey, {
1038+
[memberKey1]: {value: 'first'},
1039+
[memberKey2]: {value: 'second'},
1040+
} as GenericCollection);
1041+
1042+
expect(collectionCallback).toHaveBeenCalledTimes(1);
1043+
});
1044+
1045+
it('OnyxUtils.partialSetCollection — collection subscriber fires once across retries', async () => {
1046+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
1047+
const memberKey1 = `${collectionKey}1`;
1048+
const memberKey2 = `${collectionKey}2`;
1049+
1050+
const collectionCallback = jest.fn();
1051+
Onyx.connect({
1052+
key: collectionKey,
1053+
waitForCollectionCallback: true,
1054+
callback: collectionCallback,
1055+
});
1056+
await waitForPromisesToResolve();
1057+
collectionCallback.mockClear();
1058+
1059+
StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError);
1060+
1061+
await OnyxUtils.partialSetCollection({
1062+
collectionKey,
1063+
collection: {
1064+
[memberKey1]: {value: 'first'},
1065+
[memberKey2]: {value: 'second'},
1066+
} as GenericCollection,
1067+
});
1068+
1069+
expect(collectionCallback).toHaveBeenCalledTimes(1);
1070+
});
1071+
});
1072+
9231073
describe('storage eviction', () => {
9241074
const diskFullError = new Error('database or disk is full');
9251075

0 commit comments

Comments
 (0)