Skip to content

Commit 64493ad

Browse files
authored
Merge pull request Expensify#792 from callstack-internal/elirangoshen/fix/retry-side-effects-idempotency
[Do Not Merge] Avoid subscriber re-fire when storage writes retry
2 parents 397f98e + 32b493a commit 64493ad

4 files changed

Lines changed: 358 additions & 32 deletions

File tree

lib/OnyxCache.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,11 +362,18 @@ class OnyxCache {
362362

363363
/**
364364
* Finds the least recently accessed key that can be safely evicted from storage.
365+
* `excludeKeys` skips keys that must not be evicted (e.g. the in-flight write's own keys,
366+
* whose cache value is the merge base the retry depends on).
365367
*/
366-
getKeyForEviction(): OnyxKey | undefined {
368+
getKeyForEviction(excludeKeys?: Set<OnyxKey>): OnyxKey | undefined {
367369
// recentlyAccessedKeys is ordered from least to most recently accessed,
368-
// so the first element is the best candidate for eviction.
369-
return this.recentlyAccessedKeys.values().next().value;
370+
// so the first non-excluded key is the best candidate for eviction.
371+
for (const key of this.recentlyAccessedKeys) {
372+
if (!excludeKeys?.has(key)) {
373+
return key;
374+
}
375+
}
376+
return undefined;
370377
}
371378

372379
/**

lib/OnyxUtils.ts

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,13 @@ function reportStorageQuota(error?: Error): Promise<void> {
809809
* - Non-retriable errors: logs an alert and resolves without retrying
810810
* - Other errors: retries the operation
811811
*/
812-
function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, onyxMethod: TMethod, defaultParams: Parameters<TMethod>[0], retryAttempt: number | undefined): Promise<void> {
812+
function retryOperation<TMethod extends RetriableOnyxOperation>(
813+
error: Error,
814+
onyxMethod: TMethod,
815+
defaultParams: Parameters<TMethod>[0],
816+
retryAttempt: number | undefined,
817+
inFlightKeys?: Set<OnyxKey>,
818+
): Promise<void> {
813819
const currentRetryAttempt = retryAttempt ?? 0;
814820
const nextRetryAttempt = currentRetryAttempt + 1;
815821

@@ -840,8 +846,10 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, on
840846
return onyxMethod(defaultParams, nextRetryAttempt);
841847
}
842848

843-
// Find the least recently accessed evictable key that we can remove
844-
const keyForRemoval = cache.getKeyForEviction();
849+
// Find the least recently accessed evictable key that we can remove. Never evict an in-flight
850+
// key — its cache value is the merge base this retry depends on, so dropping it would truncate
851+
// the write to just the delta and diverge cache from storage.
852+
const keyForRemoval = cache.getKeyForEviction(inFlightKeys);
845853
if (!keyForRemoval) {
846854
// If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case,
847855
// then we should stop retrying as there is not much the user can do to fix this. Instead of getting them stuck in an infinite loop we
@@ -1407,14 +1415,21 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
14071415
// so re-entrant callbacks (e.g. Onyx.set inside a callback) see consistent cache
14081416
// and subscriber state, matching the original per-key notification semantics.
14091417
cache.set(key, value);
1410-
keyChanged(key, value);
1418+
// Skip subscriber notification on retry — already notified on attempt 0.
1419+
// waitForCollectionCallback subscribers re-fire on every keyChanged by contract.
1420+
if (!retryAttempt) {
1421+
keyChanged(key, value);
1422+
}
14111423
}
14121424
}
14131425

14141426
// One keysChanged() per collection — fires each collection-level subscriber once and lets
14151427
// keysChanged() internally decide which individual member subscribers need notification.
1416-
for (const [collectionKey, batch] of collectionBatches) {
1417-
keysChanged(collectionKey as CollectionKeyBase, batch.partial, batch.previous);
1428+
// Skip on retry — already notified on attempt 0 (see same-reason comment above).
1429+
if (!retryAttempt) {
1430+
for (const [collectionKey, batch] of collectionBatches) {
1431+
keysChanged(collectionKey as CollectionKeyBase, batch.partial, batch.previous);
1432+
}
14181433
}
14191434

14201435
const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => {
@@ -1423,8 +1438,10 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
14231438
return !OnyxKeys.isRamOnlyKey(key);
14241439
});
14251440

1441+
const inFlightKeys = new Set<OnyxKey>(keyValuePairsToSet.map(([key]) => key));
1442+
14261443
return Storage.multiSet(keyValuePairsToStore)
1427-
.catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt))
1444+
.catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt, inFlightKeys))
14281445
.then(() => {
14291446
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData);
14301447
});
@@ -1488,16 +1505,22 @@ function setCollectionWithRetry<TKey extends CollectionKeyBase>({collectionKey,
14881505

14891506
for (const [key, value] of keyValuePairs) cache.set(key, value);
14901507

1491-
keysChanged(collectionKey, mutableCollection, previousCollection);
1508+
// Skip subscriber notification on retry — already notified on attempt 0.
1509+
// waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1510+
if (!retryAttempt) {
1511+
keysChanged(collectionKey, mutableCollection, previousCollection);
1512+
}
14921513

14931514
// RAM-only keys are not supposed to be saved to storage
14941515
if (OnyxKeys.isRamOnlyKey(collectionKey)) {
14951516
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
14961517
return;
14971518
}
14981519

1520+
const inFlightKeys = new Set<OnyxKey>(keyValuePairs.map(([key]) => key));
1521+
14991522
return Storage.multiSet(keyValuePairs)
1500-
.catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, {collectionKey, collection}, retryAttempt))
1523+
.catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, {collectionKey, collection}, retryAttempt, inFlightKeys))
15011524
.then(() => {
15021525
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
15031526
});
@@ -1628,12 +1651,17 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
16281651
// write fails.
16291652
const previousCollection = getCachedCollection(collectionKey, existingKeys);
16301653
cache.merge(finalMergedCollection);
1631-
keysChanged(collectionKey, finalMergedCollection, previousCollection);
1654+
// Skip subscriber notification on retry — already notified on attempt 0.
1655+
// waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1656+
if (!retryAttempt) {
1657+
keysChanged(collectionKey, finalMergedCollection, previousCollection);
1658+
}
16321659

16331660
const promises = [];
16341661

1635-
// New keys will be added via multiSet while existing keys will be updated using multiMerge
1636-
// This is because setting a key that doesn't exist yet with multiMerge will throw errors
1662+
// New keys go through multiSet and existing keys through multiMerge. multiMerge on a
1663+
// missing key stores the value just like multiSet across all backends; splitting them lets
1664+
// multiSet strip nested nulls (the merge layer keeps them to delete nested storage keys).
16371665
// We can skip this step for RAM-only keys as they should never be saved to storage
16381666
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) {
16391667
promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));
@@ -1644,13 +1672,16 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
16441672
promises.push(Storage.multiSet(keyValuePairsForNewCollection));
16451673
}
16461674

1675+
const inFlightKeys = new Set<OnyxKey>(Object.keys(finalMergedCollection));
1676+
16471677
return Promise.all(promises)
16481678
.catch((error) =>
16491679
retryOperation(
16501680
error,
16511681
mergeCollectionWithPatches,
16521682
{collectionKey, collection: resultCollection as OnyxMergeCollectionInput<TKey>, mergeReplaceNullPatches, isProcessingCollectionUpdate},
16531683
retryAttempt,
1684+
inFlightKeys,
16541685
),
16551686
)
16561687
.then(() => {
@@ -1707,15 +1738,21 @@ function partialSetCollection<TKey extends CollectionKeyBase>({collectionKey, co
17071738

17081739
for (const [key, value] of keyValuePairs) cache.set(key, value);
17091740

1710-
keysChanged(collectionKey, mutableCollection, previousCollection);
1741+
// Skip subscriber notification on retry — already notified on attempt 0.
1742+
// waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1743+
if (!retryAttempt) {
1744+
keysChanged(collectionKey, mutableCollection, previousCollection);
1745+
}
17111746

17121747
if (OnyxKeys.isRamOnlyKey(collectionKey)) {
17131748
sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);
17141749
return;
17151750
}
17161751

1752+
const inFlightKeys = new Set<OnyxKey>(keyValuePairs.map(([key]) => key));
1753+
17171754
return Storage.multiSet(keyValuePairs)
1718-
.catch((error) => retryOperation(error, partialSetCollection, {collectionKey, collection}, retryAttempt))
1755+
.catch((error) => retryOperation(error, partialSetCollection, {collectionKey, collection}, retryAttempt, inFlightKeys))
17191756
.then(() => {
17201757
sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);
17211758
});

0 commit comments

Comments
 (0)