-
Notifications
You must be signed in to change notification settings - Fork 96
Batch collection member removals and coalesce cross-tab sync events #820
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
33dd460
9ecff96
a37d06b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,6 +64,12 @@ function resetDiskPressureLogThrottle(): void { | |
|
|
||
| type OnyxMethod = ValueOf<typeof METHOD>; | ||
|
|
||
| /** Result of `prepareKeyValuePairsForStorage`: pairs to write and keys whose `null` value marks them for removal. */ | ||
| type PreparedKeyValuePairs = { | ||
| pairs: StorageKeyValuePair[]; | ||
| keysToRemove: OnyxKey[]; | ||
| }; | ||
|
|
||
| // Key/value store of Onyx key and arrays of values to merge | ||
| let mergeQueue: Record<OnyxKey, Array<OnyxValue<OnyxKey>>> = {}; | ||
| let mergeQueuePromise: Record<OnyxKey, Promise<void>> = {}; | ||
|
|
@@ -919,21 +925,20 @@ function hasPendingMergeForKey(key: OnyxKey): boolean { | |
| /** | ||
| * Storage expects array like: [["@MyApp_user", value_1], ["@MyApp_key", value_2]] | ||
| * This method transforms an object like {'@MyApp_user': myUserValue, '@MyApp_key': myKeyValue} | ||
| * to an array of key-value pairs in the above format and removes key-value pairs that are being set to null | ||
| * | ||
| * @return an array of key - value pairs <[key, value]> | ||
| * to an array of key-value pairs in the above format, and collects the keys of null values into | ||
| * `keysToRemove` for the caller to delete as one batch (cache drop + notification + batched storage removal). | ||
| */ | ||
| function prepareKeyValuePairsForStorage( | ||
| data: Record<OnyxKey, OnyxInput<OnyxKey>>, | ||
| shouldRemoveNestedNulls?: boolean, | ||
| replaceNullPatches?: MultiMergeReplaceNullPatches, | ||
| isProcessingCollectionUpdate?: boolean, | ||
| ): StorageKeyValuePair[] { | ||
| ): PreparedKeyValuePairs { | ||
| const pairs: StorageKeyValuePair[] = []; | ||
| const keysToRemove: OnyxKey[] = []; | ||
|
|
||
| for (const [key, value] of Object.entries(data)) { | ||
| if (value === null) { | ||
| remove(key, isProcessingCollectionUpdate); | ||
| keysToRemove.push(key); | ||
| continue; | ||
| } | ||
|
|
||
|
|
@@ -944,7 +949,7 @@ function prepareKeyValuePairsForStorage( | |
| } | ||
| } | ||
|
|
||
| return pairs; | ||
| return {pairs, keysToRemove}; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -1408,7 +1413,7 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom | |
| }, {}); | ||
| } | ||
|
|
||
| const keyValuePairsToSet = OnyxUtils.prepareKeyValuePairsForStorage(newData, true); | ||
| const {pairs: keyValuePairsToSet, keysToRemove} = OnyxUtils.prepareKeyValuePairsForStorage(newData, true); | ||
|
|
||
| // Group collection members by their parent collection key so each collection can be notified | ||
| // via a single batched keysChanged() call instead of one keyChanged() per member. For each | ||
|
|
@@ -1456,6 +1461,27 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom | |
| } | ||
| } | ||
|
|
||
| // Null keys join the same per-collection batches (as undefined) and are deleted from storage | ||
| // in one batched call below, so cross-tab sync raises a single event instead of one per key. | ||
| for (const key of keysToRemove) { | ||
| const previousValue = cache.get(key); | ||
| cache.drop(key); | ||
|
|
||
| const collectionKey = OnyxKeys.getCollectionKey(key); | ||
| if (collectionKey && OnyxKeys.isCollectionMemberKey(collectionKey, key)) { | ||
| let batch = collectionBatches.get(collectionKey); | ||
| if (!batch) { | ||
| batch = {partial: {}, previous: {}}; | ||
| collectionBatches.set(collectionKey, batch); | ||
| } | ||
| batch.partial[key] = undefined; | ||
| batch.previous[key] = previousValue; | ||
| } else if (!retryAttempt) { | ||
| // Skip subscriber notification on retry — already notified on attempt 0. | ||
| keyChanged(key, undefined); | ||
| } | ||
| } | ||
|
|
||
| // One keysChanged() per collection — fires each collection-level subscriber once and lets | ||
| // keysChanged() internally decide which individual member subscribers need notification. | ||
| // Skip on retry — already notified on attempt 0 (see same-reason comment above). | ||
|
|
@@ -1470,10 +1496,16 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom | |
| // Filter out the RAM-only key value pairs, as they should not be saved to storage | ||
| return !OnyxKeys.isRamOnlyKey(key); | ||
| }); | ||
| const keysToRemoveFromStorage = keysToRemove.filter((key) => !OnyxKeys.isRamOnlyKey(key)); | ||
|
|
||
| const inFlightKeys = new Set<OnyxKey>(keyValuePairsToSet.map(([key]) => key)); | ||
|
|
||
| return Storage.multiSet(keyValuePairsToStore) | ||
| const storagePromises = [Storage.multiSet(keyValuePairsToStore)]; | ||
| if (keysToRemoveFromStorage.length > 0) { | ||
| storagePromises.push(Storage.removeItems(keysToRemoveFromStorage)); | ||
| } | ||
|
|
||
| return Promise.all(storagePromises) | ||
| .then(() => StorageCircuitBreaker.recordWriteSuccess()) | ||
| .catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt, inFlightKeys)) | ||
| .then(() => { | ||
|
|
@@ -1534,10 +1566,14 @@ function setCollectionWithRetry<TKey extends CollectionKeyBase>({collectionKey, | |
| mutableCollection[key] = null; | ||
| } | ||
|
|
||
| const keyValuePairs = OnyxUtils.prepareKeyValuePairsForStorage(mutableCollection, true, undefined, true); | ||
| const {pairs: keyValuePairs, keysToRemove: removalCandidates} = OnyxUtils.prepareKeyValuePairsForStorage(mutableCollection, true); | ||
| // Removals of keys that are neither cached nor persisted are no-ops and skipped. | ||
| const keysToRemove = removalCandidates.filter((key) => cache.get(key) !== undefined || persistedKeys.has(key)); | ||
| // Snapshot before cache mutations so keysChanged() can diff removed members. | ||
| const previousCollection = OnyxUtils.getCachedCollection(collectionKey); | ||
|
|
||
| for (const [key, value] of keyValuePairs) cache.set(key, value); | ||
| for (const key of keysToRemove) cache.drop(key); | ||
|
|
||
| // Skip subscriber notification on retry — already notified on attempt 0. | ||
| // Collection-root subscribers re-fire on every keysChanged by contract. | ||
|
|
@@ -1553,7 +1589,13 @@ function setCollectionWithRetry<TKey extends CollectionKeyBase>({collectionKey, | |
|
|
||
| const inFlightKeys = new Set<OnyxKey>(keyValuePairs.map(([key]) => key)); | ||
|
|
||
| return Storage.multiSet(keyValuePairs) | ||
| // One batched removal = one cross-tab sync event instead of one per key. | ||
| const storagePromises = [Storage.multiSet(keyValuePairs)]; | ||
| if (keysToRemove.length > 0) { | ||
| storagePromises.push(Storage.removeItems(keysToRemove)); | ||
| } | ||
|
|
||
| return Promise.all(storagePromises) | ||
| .then(() => StorageCircuitBreaker.recordWriteSuccess()) | ||
| .catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, {collectionKey, collection}, retryAttempt, inFlightKeys)) | ||
| .then(() => { | ||
|
|
@@ -1612,10 +1654,14 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>( | |
|
|
||
| return getAllKeys() | ||
| .then((persistedKeys) => { | ||
| // Split to keys that exist in storage and keys that don't | ||
| // Split to keys that exist in storage and keys that don't. Null members are collected | ||
| // for one batched removal below; nulls that are neither cached nor persisted are no-ops and skipped. | ||
| const keysToRemove: OnyxKey[] = []; | ||
| const keys = resultCollectionKeys.filter((key) => { | ||
| if (resultCollection[key] === null) { | ||
| remove(key, isProcessingCollectionUpdate); | ||
| if (cache.get(key) !== undefined || persistedKeys.has(key)) { | ||
| keysToRemove.push(key); | ||
| } | ||
| return false; | ||
| } | ||
| return true; | ||
|
|
@@ -1658,11 +1704,11 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>( | |
| // When (multi-)merging the values with the existing values in storage, | ||
| // we don't want to remove nested null values from the data that we pass to the storage layer, | ||
| // because the storage layer uses them to remove nested keys from storage natively. | ||
| const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection, false, mergeReplaceNullPatches); | ||
| const {pairs: keyValuePairsForExistingCollection} = prepareKeyValuePairsForStorage(existingKeyCollection, false, mergeReplaceNullPatches); | ||
|
|
||
| // We can safely remove nested null values when using (multi-)set, | ||
| // because we will simply overwrite the existing values in storage. | ||
| const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection, true); | ||
| const {pairs: keyValuePairsForNewCollection} = prepareKeyValuePairsForStorage(newCollection, true); | ||
|
|
||
| // finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates | ||
| const finalMergedCollection = { | ||
|
|
@@ -1688,15 +1734,33 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>( | |
| // ensuring subscribers still reflect the merged data even if the subsequent storage | ||
| // write fails. | ||
| const previousCollection = getCachedCollection(collectionKey, existingKeys); | ||
|
|
||
| // Removed members join the same keysChanged() batch (as undefined); previous values | ||
| // are snapshotted before the cache drop so keysChanged() can diff them. | ||
| const removedPreviousValues: OnyxInputKeyValueMapping = {}; | ||
| for (const key of keysToRemove) { | ||
| removedPreviousValues[key] = cache.get(key); | ||
| cache.drop(key); | ||
|
Comment on lines
+1741
to
+1743
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } | ||
|
Comment on lines
+1738
to
+1744
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A key that gets deleted here can wipe out a write that came after it. On So if something writes to one of the deleted keys during that wait, the deletion runs afterwards and throws that write away. Here is the repro. Put const removal = Onyx.mergeCollection('test_', {test_A: null, test_B: {b: 2}});
const concurrent = Onyx.merge('test_A', {y: 2}); // this one is called second
await Promise.all([removal, concurrent]);
The second call should win, since it was made last. Could the |
||
|
|
||
| cache.merge(finalMergedCollection); | ||
| // Skip subscriber notification on retry — already notified on attempt 0. | ||
| // Collection-root subscribers re-fire on every keysChanged by contract. | ||
| if (!retryAttempt) { | ||
| keysChanged(collectionKey, finalMergedCollection, previousCollection); | ||
| const partialForNotify = keysToRemove.length > 0 ? {...finalMergedCollection, ...Object.fromEntries(keysToRemove.map((key) => [key, undefined]))} : finalMergedCollection; | ||
| const previousForNotify = keysToRemove.length > 0 ? {...previousCollection, ...removedPreviousValues} : previousCollection; | ||
| if (Object.keys(partialForNotify).length > 0) { | ||
| keysChanged(collectionKey, partialForNotify, previousForNotify); | ||
| } | ||
| } | ||
|
|
||
| const promises = []; | ||
|
|
||
| // One batched removal = one cross-tab sync event instead of one per key. | ||
| if (!OnyxKeys.isRamOnlyKey(collectionKey) && keysToRemove.length > 0) { | ||
| promises.push(Storage.removeItems(keysToRemove)); | ||
| } | ||
|
|
||
| // New keys go through multiSet and existing keys through multiMerge. multiMerge on a | ||
| // missing key stores the value just like multiSet across all backends; splitting them lets | ||
| // multiSet strip nested nulls (the merge layer keeps them to delete nested storage keys). | ||
|
|
@@ -1777,10 +1841,14 @@ function partialSetCollection<TKey extends CollectionKeyBase>({collectionKey, co | |
| return getAllKeys().then((persistedKeys) => { | ||
| const mutableCollection: OnyxInputKeyValueMapping = {...resultCollection}; | ||
| const existingKeys = resultCollectionKeys.filter((key) => persistedKeys.has(key)); | ||
| const {pairs: keyValuePairs, keysToRemove: removalCandidates} = prepareKeyValuePairsForStorage(mutableCollection, true); | ||
| // Removals of keys that are neither cached nor persisted are no-ops and skipped. | ||
| const keysToRemove = removalCandidates.filter((key) => cache.get(key) !== undefined || persistedKeys.has(key)); | ||
| // Snapshot before cache mutations so keysChanged() can diff removed members. | ||
| const previousCollection = getCachedCollection(collectionKey, existingKeys); | ||
|
elirangoshen marked this conversation as resolved.
|
||
| const keyValuePairs = prepareKeyValuePairsForStorage(mutableCollection, true, undefined, true); | ||
|
|
||
| for (const [key, value] of keyValuePairs) cache.set(key, value); | ||
| for (const key of keysToRemove) cache.drop(key); | ||
|
|
||
| // Skip subscriber notification on retry — already notified on attempt 0. | ||
| // Collection-root subscribers re-fire on every keysChanged by contract. | ||
|
|
@@ -1795,7 +1863,13 @@ function partialSetCollection<TKey extends CollectionKeyBase>({collectionKey, co | |
|
|
||
| const inFlightKeys = new Set<OnyxKey>(keyValuePairs.map(([key]) => key)); | ||
|
|
||
| return Storage.multiSet(keyValuePairs) | ||
| // One batched removal = one cross-tab sync event instead of one per key. | ||
| const storagePromises = [Storage.multiSet(keyValuePairs)]; | ||
| if (keysToRemove.length > 0) { | ||
| storagePromises.push(Storage.removeItems(keysToRemove)); | ||
| } | ||
|
|
||
| return Promise.all(storagePromises) | ||
| .then(() => StorageCircuitBreaker.recordWriteSuccess()) | ||
| .catch((error) => retryOperation(error, partialSetCollection, {collectionKey, collection}, retryAttempt, inFlightKeys)) | ||
| .then(() => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
removeItemsfails, the retry redoes the write but never retries the deletion.removeItemsis now part of the same promise asmultiSet, so if the delete fails, the wholesetCollectionis retried. But by then the keys are already gone from the cache, so the filter on line 1571 comes back empty and the delete is never attempted again. Only themultiSetruns a second time, and it had already succeeded.I saw this with a storage error that Onyx classifies as
UNKNOWN:multiSetran twice,removeItemsran once, and the row was left behind in storage even thoughgetAllKeys()says it is gone.The leftover row is not new. On
maina failedremoveItemwas dropped too, and this PR at least stops it from becoming an unhandled rejection. The pointless second write is the new part.partialSetCollectionandmultiSetdo the same thing.Would it be safer to give the removal its own
.catch()so a failed delete does not re-run the whole write?