Skip to content

Commit b001767

Browse files
elirangoshenclaude
andcommitted
fix: don't evict in-flight keys on retry; correct stale multiMerge-new-key comment
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bfb3c28 commit b001767

4 files changed

Lines changed: 82 additions & 21 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: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -777,14 +777,9 @@ function getCollectionDataAndSendAsObject<TKey extends OnyxKey>(matchingKeys: Co
777777
/**
778778
* Remove a key from Onyx and update the subscribers
779779
*/
780-
function remove<TKey extends OnyxKey>(key: TKey, isProcessingCollectionUpdate?: boolean, skipNotify?: boolean): Promise<void> {
780+
function remove<TKey extends OnyxKey>(key: TKey, isProcessingCollectionUpdate?: boolean): Promise<void> {
781781
cache.drop(key);
782-
// skipNotify is used by retryOperation's eviction branch — the imminent retry's cache.set
783-
// will re-populate cache, so firing keyChanged(undefined) here would only strand subscribers
784-
// in the "removed" state across the retry.
785-
if (!skipNotify) {
786-
keyChanged(key, undefined as OnyxValue<TKey>, undefined, isProcessingCollectionUpdate);
787-
}
782+
keyChanged(key, undefined as OnyxValue<TKey>, undefined, isProcessingCollectionUpdate);
788783

789784
if (OnyxKeys.isRamOnlyKey(key)) {
790785
return Promise.resolve();
@@ -851,8 +846,10 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(
851846
return onyxMethod(defaultParams, nextRetryAttempt);
852847
}
853848

854-
// Find the least recently accessed evictable key that we can remove
855-
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);
856853
if (!keyForRemoval) {
857854
// If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case,
858855
// 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
@@ -865,12 +862,10 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(
865862
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`);
866863
reportStorageQuota(error);
867864

868-
// Only suppress keyChanged(undefined) when the evicted key is part of the in-flight
869-
// write — then cache.set on retry will restore it. For unrelated keys, eviction is a
870-
// genuine loss and subscribers must see the removed state.
871-
const willBeRestored = inFlightKeys?.has(keyForRemoval) ?? false;
865+
// The evicted key is never in-flight (excluded above), so this is a genuine removal —
866+
// subscribers must see the removed state.
872867
// @ts-expect-error No overload matches this call.
873-
return remove(keyForRemoval, undefined, willBeRestored).then(() => onyxMethod(defaultParams, nextRetryAttempt));
868+
return remove(keyForRemoval).then(() => onyxMethod(defaultParams, nextRetryAttempt));
874869
}
875870

876871
/**
@@ -1666,8 +1661,9 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
16661661

16671662
const promises = [];
16681663

1669-
// New keys will be added via multiSet while existing keys will be updated using multiMerge
1670-
// This is because setting a key that doesn't exist yet with multiMerge will throw errors
1664+
// New keys go through multiSet and existing keys through multiMerge. multiMerge on a
1665+
// missing key stores the value just like multiSet across all backends; splitting them lets
1666+
// multiSet strip nested nulls (the merge layer keeps them to delete nested storage keys).
16711667
// We can skip this step for RAM-only keys as they should never be saved to storage
16721668
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) {
16731669
promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));

tests/unit/onyxUtilsTest.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,13 +1566,66 @@ describe('OnyxUtils', () => {
15661566

15671567
await LocalOnyx.multiSet({[memberKey]: {value: 'updated'}});
15681568

1569-
// The in-flight key was evicted then restored by the retry's cache.set. Subscriber's
1570-
// last value must be the new value, never a transient undefined from the eviction.
1569+
// The in-flight key is excluded from eviction, so its cache value (the merge base) is
1570+
// never dropped. Subscriber's last value is the new value, never a transient undefined.
15711571
expect(LocalOnyxCache.get(memberKey)).toEqual({value: 'updated'});
15721572
expect(subscriberCalls.at(-1)).toEqual({value: 'updated'});
15731573
// Subscriber should never have seen undefined in the middle of the eviction-retry cycle.
15741574
expect(subscriberCalls).not.toContain(undefined);
15751575
});
1576+
1577+
it('mergeCollection — evicts an unrelated key, not the in-flight key, so its fields survive', async () => {
1578+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
1579+
const memberKey = `${collectionKey}1`;
1580+
const unrelatedKey = `${collectionKey}2`;
1581+
1582+
// Seed the in-flight member with extra fields, plus a separate evictable key. The merge
1583+
// only touches memberKey; the unrelated key is the genuine eviction target.
1584+
await LocalOnyx.set(memberKey, {id: 1, value: 'orig'});
1585+
await LocalOnyx.set(unrelatedKey, {value: 'evict-me'});
1586+
1587+
const memberCalls: unknown[] = [];
1588+
LocalOnyx.connect({key: memberKey, callback: (value) => memberCalls.push(value)});
1589+
await waitForPromisesToResolve();
1590+
memberCalls.length = 0;
1591+
1592+
// Storage.multiMerge rejects once with disk-full, then succeeds on retry.
1593+
LocalStorageMock.multiMerge = jest.fn(LocalStorageMock.multiMerge).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiMerge);
1594+
1595+
await LocalOnyx.mergeCollection(collectionKey, {[memberKey]: {value: 'merged'}} as GenericCollection);
1596+
1597+
// The old code evicted the in-flight key and re-ran the merge against an empty cache,
1598+
// collapsing {id: 1, value: 'orig'} + {value: 'merged'} to just {value: 'merged'}. Now
1599+
// the in-flight key is protected, so its pre-existing {id: 1} survives.
1600+
expect(LocalOnyxCache.get(memberKey)).toEqual({id: 1, value: 'merged'});
1601+
expect(memberCalls.at(-1)).toEqual({id: 1, value: 'merged'});
1602+
expect(memberCalls).not.toContain(undefined);
1603+
// The unrelated key was the genuine eviction target.
1604+
expect(LocalOnyxCache.hasCacheForKey(unrelatedKey)).toBe(false);
1605+
});
1606+
1607+
it('mergeCollection — does not truncate the in-flight key when it is the only evictable key', async () => {
1608+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
1609+
const memberKey = `${collectionKey}1`;
1610+
1611+
await LocalOnyx.set(memberKey, {id: 1, value: 'orig'});
1612+
expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey);
1613+
1614+
const memberCalls: unknown[] = [];
1615+
LocalOnyx.connect({key: memberKey, callback: (value) => memberCalls.push(value)});
1616+
await waitForPromisesToResolve();
1617+
memberCalls.length = 0;
1618+
1619+
// The only evictable key is the in-flight one, which is now excluded — so retryOperation
1620+
// finds no acceptable key and reports the quota instead of dropping (and truncating) it.
1621+
LocalStorageMock.multiMerge = jest.fn(LocalStorageMock.multiMerge).mockRejectedValue(diskFullError);
1622+
1623+
await LocalOnyx.mergeCollection(collectionKey, {[memberKey]: {value: 'merged'}} as GenericCollection);
1624+
1625+
expect(LocalOnyxCache.get(memberKey)).toEqual({id: 1, value: 'merged'});
1626+
expect(memberCalls.at(-1)).toEqual({id: 1, value: 'merged'});
1627+
expect(memberCalls).not.toContain(undefined);
1628+
});
15761629
});
15771630

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

tests/unit/storage/providers/IDBKeyvalProviderTest.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ describe('IDBKeyValProvider', () => {
172172
),
173173
).toEqual(expectedEntries.map((e) => (e[1] === null ? undefined : e[1])));
174174
});
175+
176+
it('should insert a new record when key does not exist', async () => {
177+
await IDBKeyValProvider.multiMerge([[ONYXKEYS.TEST_KEY_2, {fresh: true}]]);
178+
expect(await IDBKeyValProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual({fresh: true});
179+
});
175180
});
176181

177182
describe('mergeItem', () => {

0 commit comments

Comments
 (0)