Batch collection member removals and coalesce cross-tab sync events - #820
Conversation
Collection writes fanned out null members as one remove() per key, raising one cross-tab SYNC_ONYX event per removed key. On heavy accounts OpenApp nulls ~2k transactionViolations members on every boot, so each refresh flooded the other tab with thousands of single-key events, each triggering a derived-value recompute in its own macrotask until the tab ran out of memory. - prepareKeyValuePairsForStorage now returns the null keys instead of removing them; mergeCollection/multiSet/setCollection/partialSetCollection drop no-op removals (neither cached nor persisted), fold real ones into the single keysChanged batch, and delete them with one Storage.removeItems call. - InstanceSync (web) coalesces incoming SYNC_ONYX storage events into one multiGet + one dispatch per tick, so per-key senders (e.g. tabs on an older bundle during a deploy) can no longer flood the receiving tab.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9ecff96823
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
… subscribers Covers the partialSetCollection path for RAM-only collection members. getAllKeys() returns the cache-augmented key set, so cached-only members are part of existingKeys and their previous values are present in the keysChanged() diff snapshot.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a37d06b5f9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
mountiny
left a comment
There was a problem hiding this comment.
Reviewed this locally on the branch. The core fix looks right and the win is real, so nothing here blocks it.
What I checked and found clean:
npx jest tests/unitpasses (567 tests). The only failing suite isSQLiteProviderTest, which is abetter-sqlite3native binding problem in my environment and has nothing to do with this diff.eslintis clean on all four changed files.- RAM-only keys are guarded on all four write paths.
SQLiteProvider.removeItemschunks by the SQLite variable limit, and the IndexedDB provider does one transaction, so batching a few thousand keys into one call is safe on every platform.keysChanged()does deliverundefinedto member subscribers for a batched removal.- The coalescing in
InstanceSyncuses a fixed one-tick window rather than a debounce, so a steady stream of events cannot delay a dispatch indefinitely. Storage.removeItemsis wired toraiseStorageSyncManyKeysEvent, so the batch really is one cross-tab event.
Two things I could not check locally: the two-tab measurements on a heavy account, and the native platforms.
I left two comments below. The first is a behaviour change I was able to reproduce and I think it is worth fixing. The second is smaller.
A few nits that need no action, just flagging:
multiSetdid not get the same "skip removals that are no-ops" filter the other three paths got.isProcessingCollectionUpdateis now dead. Nothing passes the second argument toremove()any more, so theif (isProcessingCollectionUpdate)branch inkeyChanged()can no longer be reached.setCollectionandpartialSetCollectionnotify withnullfor removed members, whilemergeCollectionandmultiSetnotify withundefined. Same result either way, but it would read better if all four matched.- A
mergeCollectionmade up entirely of nulls that are all no-ops now notifies collection subscribers zero times, wheremainnotified them once. That is where most of the win comes from, so this is intended, but it is worth calling out to the App reviewers in case anything treats a collection callback as a signal that data arrived.
…hed removal from retry Addresses review feedback on Expensify#820: - mergeCollection: cache.drop + Storage.removeItems now issue before the pre-warm multiGet await, so a concurrent write to a removed key during the pre-warm is no longer wiped out by a late drop. - All four collection-write paths: a failed batched removal is logged instead of re-running the whole write via retryOperation (keysToRemove cannot be re-derived after the cache update). - multiSet: skip removals of keys that are neither cached nor persisted. - setCollection/partialSetCollection: notify removed members as undefined, matching mergeCollection/multiSet. - Remove dead isProcessingCollectionUpdate plumbing.
|
@FitseTLT please complete checklist and the testing. Thank you very much |
Reviewer Checklist
Screenshots/VideosAndroid: HybridApp2026-08-12.01-48-14.mp42026-08-12.15-21-31.mp4Android: mWeb Chrome2026-08-12.02-28-05.mp4iOS: HybridAppiOS: mWeb Safariiw.mp42026-08-12.02-21-51.mp4MacOS: Chrome / Safari2026-08-12.01-08-48.mp42026-08-12.14-57-22.mp4 |
|
Test is mostly looking good. But 2026-08-12.02-28-05.mp4Also regarding the heavy onyx state can you provide me with one @elirangoshen which you used to test it. Thx |
I couldn't reproduce the report names issue also on main , but I did notice stale data - some reports that needs attention right after clear cache - happens on main as well so its not related to this pr. this is onyx state you can use onyx-state (6).txt |
mountiny
left a comment
There was a problem hiding this comment.
Thanks for the unit tests added to cover this change
Details
Fixes the two tabs with a heavy account crash both tabs
Root cause. A collection write (
mergeCollection/multiSet/setCollection/partialSetCollection) persists its non-null members in one batchedmultiMerge/multiSetcall (one cross-tabSYNC_ONYXevent), but everynullmember went throughremove(key)individually — one storage call and one cross-tab event per removed key, with no check that the key even exists. On a heavy account, OpenApp'stransactionViolations_mergeCollection contains ~2,000nullmembers on every boot (keys that were never stored, so the removals are pure no-ops). Each refresh therefore flooded the other tab with ~2,000 single-key events; each event is its own macrotask, so the receiving tab ran onekeysChanged→ one derived-value recompute (reportAttributes,reportTransactionsAndViolations) → oneOnyx.setof an MB-sized derived value per event, saturating the main thread for ~1 minute and broadcasting ~4,000 derived-key events back to the first tab, which then performed ~4,000 IndexedDB reads of those MB-sized values. On Applause-sized accounts both tabs run out of memory and crash.Fix (two layers):
prepareKeyValuePairsForStorageno longer removes null keys as a side effect; it returns them askeysToRemove. All four collection-write paths now:keysChanged()batch (asundefined, with previous values snapshotted for the diff);Storage.removeItems()call, which raises one cross-tab event for the whole batch.InstanceSync(web) now buffers incomingSYNC_ONYXstorage events and flushes once per tick (singlemultiGet+ single dispatch). A tab still running an older bundle during a deploy emits one event per key; the flush timer is scheduled on the first event of the burst, so every event already in the task queue joins one batch instead of re-running the notification pipeline per key.Measured on a heavy (Applause) account, two tabs, per boot: cross-tab events 1,971 → 41 (zero per-key
transactionViolations_events); derived-value writes in the other tab 3,891 → 8; no crash; pin/draft/read-unread/message sync, a 19.5 MB state import, and Clear cache and restart all work across tabs without refresh.Related Issues
Expensify/App#94839
Linked E/App PR
Expensify/App#98121
Automated Tests
tests/unit/onyxTest.ts— newdescribe('batched collection member removals'):mergeCollectiondeletes null members from cache and storage via one batchedremoveItemscall (and never per-keyremoveItem);undefineddelivered);setCollectiondeletes missing members via one batchedremoveItemscall;multiSetdeletes null keys via one batchedremoveItemscall.tests/unit/storage/instanceSyncWebTest.ts— new coalescing tests:multiGetand one dispatch (with key dedup);Manual Tests
With this branch pinned in E/App (web dev build), on a heavy account:
Author Checklist
### Related Issuessection above### Linked E/App PRsection above, and verified this change against it (E/App CI passed and manual testing completed)TestssectiontoggleReportand notonIconClick)myBool && <MyComponent />.STYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)/** comment above it */thisproperly so there are no scoping issues (i.e. foronClick={this.submit}the methodthis.submitshould be bound tothisin the constructor)thisare necessary to be bound (i.e. avoidthis.submit = this.submit.bind(this);ifthis.submitis never passed to a component event handler likeonClick)Avataris modified, I verified thatAvataris working as expected in all cases)mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
Screen.Recording.2026-08-10.at.11.23.39.mov
Screen.Recording.2026-08-10.at.11.29.33.mov