fix: coalesce redundant GetMissingOnyxMessages while a fetch is in flight#93948
Conversation
…mpede OnyxUpdateManager's gap branch guards on `areDeferredUpdatesQueued || isFetchingForPendingUpdates` but never consults `getMissingOnyxUpdatesQueryPromise()`. When a concurrent update arrives in the window where the deferred queue has been drained but the fetch is still in flight, the guard leaks and fires a redundant GetMissingOnyxMessages. This test injects a duplicate mid-cycle via the beforeApplyUpdates hook and asserts the duplicate is coalesced (2 fetches, not 3). It fails on current HEAD and will pass once the gap-branch guard also checks the query promise. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complements the duplicate-injection regression with the range-coverage half of the guarantee: a brand-new, higher update injected in the drained-but-in-flight window (via beforeApplyUpdates) must not fire its own parallel GetMissingOnyxMessages, yet must not be dropped either. Asserts the fetches stay sequential and in order (1-2, 3-4, 5-6) with no overlapping leak, and that update7 is actually applied (client reaches 7) via the post-fetch recursion. Fails on current HEAD; passes once the gap-branch guard also consults getMissingOnyxUpdatesQueryPromise(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-flight stampede has a second symptom beyond the redundant fetch: the leaked call overwrites the in-flight query promise and attaches a second finalize, so finalizeUpdatesAndResumeQueue runs twice and the SequentialQueue is resumed twice. The first resume can fire while the other fetch is still applying updates, an ordering hazard invisible to a fetch-count assertion. Spies SequentialQueue.unpause and asserts exactly one resume, draining past the first finalize via waitForBatchedUpdates so a leaked second finalize has settled before counting. afterEach restoreAllMocks keeps the spy from leaking into other tests even when an assertion throws. Fails on current HEAD (resumes twice); passes once the gap-branch guard also consults getMissingOnyxUpdatesQueryPromise(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The guard stands down on more than a queued deferred-update list; the comment now states the actual invariant it enforces (a fetch is already in progress) instead of naming only one of the signals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gap branch decided whether a missing-updates fetch was already running by checking only `areDeferredUpdatesQueued`/`isFetchingForPendingUpdates`. Mid-fetch, the deferred queue is drained while the GetMissingOnyxMessages request is still in flight; a concurrent Pusher update arriving in that window saw an empty queue and started a redundant parallel fetch. Under a burst this stampeded (28 fetches in 9s in production), saturating the main thread and causing dead clicks. It also overwrote the in-flight query promise and attached a second finalize, resuming the SequentialQueue twice. The reliable signal — `getMissingOnyxUpdatesQueryPromise()`, the handle on the in-flight fetch — was already authoritative in the adjacent reconnect branch. Add it to the guard so the gap branch stands down when a fetch is in flight. The new update is not lost: it is enqueued just above, so the running fetch sweeps it up in order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@codex review |
|
@mkhutornyi Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppandroid.movAndroid: mWeb ChromeiOS: HybridAppios.moviOS: mWeb SafariMacOS: Chrome / Safariweb.movweb2.mov |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c35e7a430b
ℹ️ 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".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c35e7a430b
ℹ️ 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".
|
@MelvinBot Review |
|
🤖 Review — the core fix is correct and well-targeted, with strong regression coverage. One edge case (matching Codex's P2) is worth confirming before merge. The fix is soundAdding One edge case to confirm (agree with Codex P2)The guard now coalesces against any in-flight promise, but not every in-flight promise drains the deferred queue. The first-time reconnect promise — So in the narrow window where the reconnect response has advanced Why this is likely low-severity, not a blocker
Suggestion: either confirm this is acceptable (recovered via gap detection), or tighten the guard so it only coalesces against promises that drain the queue (e.g. gate on This is an automated review — treat it as a second opinion, not a gate. Reply with |
mkhutornyi
left a comment
There was a problem hiding this comment.
There's one outstanding comment.
Otherwise looks good
…dates are not dropped Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 293075d811
ℹ️ 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".
|
let's wait as I validate the most recent review comment. update: done ✅ |
|
🚧 @mountiny has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/mountiny in version: 9.4.16-1 🚀
Bundle Size Analysis (Sentry): |
|
🤖 Help site review: no changes required. This PR is a purely internal reliability fix to the Onyx update-sync mechanism ( Help site articles under @adhorodyski, if you believe a customer-facing behavior changed that I missed, reply with the specifics and I'll draft the help site update. |
|
🚀 Deployed to production by https://github.com/blimpich in version: 9.4.16-5 🚀
|
Explanation of Change
When the app detects a gap in server update IDs it fetches the missing ones via
GetMissingOnyxMessages(GMOM). The gap-branch guard inOnyxUpdateManagercheckedareDeferredUpdatesQueuedandisFetchingForPendingUpdatesto decide whether a fetch was already running.Mid-fetch, the deferred queue drains while the GMOM request is still in flight. A concurrent Pusher update arriving in that window saw an empty queue and started a second parallel fetch. Under a burst this stampeded. Production saw 28 GMOM requests in 9 seconds, which saturated the main thread and caused dead clicks. The same slip also overwrote the in-flight query promise and attached a second finalize callback, so
SequentialQueueresumed twice and could send requests against half-applied data.getMissingOnyxUpdatesQueryPromise()is the handle on the in-flight fetch, and the reconnect branch already treats it as authoritative. The gap-branch guard now includes it, so the branch stands down when a fetch is already in flight. The concurrent update is not lost: it is enqueued just before the guard, so the running fetch picks it up in order. The guard comment states the invariant it enforces rather than naming a single signal.The first reconnect had the same kind of drop. Before a client has ever applied a reliable update, the gap handler runs a full
reconnectAppthroughfinalReconnectAppAfterActivatingReliableUpdatesinstead of a GMOM fetch. That promise did not drain the deferred queue, so an update that arrived while the reconnect was in flight stayed in the queue untilfinalizeUpdatesAndResumeQueuecleared it without applying it. The reconnect promise now chainsvalidateAndApplyDeferredUpdates, the same way the gap-fetch and pending-fetch paths do, so the queued update is applied in order.Fixed Issues
$ #92541
PROPOSAL:
Tests
npm run test -- tests/unit/OnyxUpdateManagerTest.ts.mainand pass here:Offline tests
QA Steps
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.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
No UI changes.
Android: mWeb Chrome
No UI changes.
iOS: Native
No UI changes.
iOS: mWeb Safari
No UI changes.
MacOS: Chrome / Safari
No UI changes.