Count in-flight Pusher applies as applied when detecting update gaps - #99774
Conversation
|
@codex review |
|
@MelvinBot review this PR please |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 290ff85452
ℹ️ 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".
ReviewThe core change is sound. One test-quality problem should be fixed before merge. Two of the four tests you list as "confirmed red before the fix was kept" are green on
Both are expected to pass on They only looked red because the file cascades. In That's the real risk: any future regression in the first test costs ~8 minutes of CI and reports a misleading failure. The afterEach(() => {
releaseApply?.();
jest.restoreAllMocks();
});Once failures stay local, the ordering comment can go. Rest of the review — what I verified, and two minor notesVerified
The logic itself looks rightAll three exits from the pending marker are covered: the watermark catching up (line 206), the apply failing (line 212), and sign-out (line 44). Two things I specifically checked that hold up:
Note 1: say why the reset is unconditional
Note 2: Airship has the same shape
Agreed that the Web smoke testResult: partial. No UI surface here, so this only checks that nothing obvious broke.
One observation rather than a finding: the network dump showed 5x The high-traffic-account test you called out as the one that matters most is still the gap I can't close — I can't reproduce the concurrent-writer conditions that produce the gap detections you measured. |
e2587cf to
9a564f7
Compare
applyPusherOnyxUpdates serializes every event through one module-level promise, and the watermark only advances once that chain settles. So the next event, chained on an update the client already holds, reads as a gap: the client pauses its own write queue and refetches data it is part-way through writing. Measured over 2026-08-26 production logs, 24.5% of gap detections had the missing update delivered before the gap fired and applied after it, at p50 209ms arrival to detection. lastUpdateIDPendingApply records the highest update ID accepted into the Pusher apply chain, and getLastUpdateIDForGapCheck counts it as applied. It is deliberately absent from getEffectiveLastUpdateID, which is the lower bound of the catch-up fetch range. An accepted update is not a written one. If the apply then rejects, a range fetched from it skips that update for good: the catch-up response is exempt from gap detection, so it advances the persisted watermark past the hole and nothing detects it again. Fetching from the persisted watermark refetches the in-flight range instead, which only costs bandwidth. The test named "keeps a Pusher update that is still applying out of the catch-up fetch range" holds that line. lastUpdateIDPendingFlush already carries the same hazard for the WRITE path, since it does sit in getEffectiveLastUpdateID. Left as-is here; it wants its own issue. Internal tracking: callstack-internal/expensify-issues#2882 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9a564f7 to
a77a3ce
Compare
applyPusherOnyxUpdates reassigns the module-level pusherEventsPromise from itself, so a held apply blocks every later Pusher apply in the file. The held promise was only released on each test's happy path, after its assertions. A failing assertion threw first, the mocked handler never settled, and the following tests hit the 240s Jest timeout instead of asserting -- the file reported a timeout, not a diff, and --testTimeout did not bound it. Releasing it from afterEach turns that into one local failure: with the first Pusher assertion inverted the suite now reports 1 failed, 16 passed in 1.5s. The rejection case still has to run last. afterEach releases a held apply, it cannot un-reject a rejected chain, so that test keeps its name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Agreed, the shape is identical: |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0d70c5192c
ℹ️ 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".
The reset in the shared catch was bare, and a reviewer read it as an oversight and asked for it to be scoped to Pusher failures. Scoping it that way is a data-loss path: the marker is read as a flat max, so a lower-ID non-Pusher failure would stay masked once the in-flight Pusher apply advances the watermark, whereas today it costs one redundant GetMissingOnyxMessages. Name the trade at the reset, and pin it with a test that fails an unrelated Airship apply while a Pusher apply is held. Carry the rest by naming: getLastUpdateIDForGapCheck said when it is called, not how it differs from getEffectiveLastUpdateID, so the difference needed a comment on the marker to explain it. Renamed to getLastUpdateIDIncludingInFlightApplies, which states the difference and leaves the declaration to speak for itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both tests rejected an apply whose promise chain lives in module scope, so the rejection outlived them and only the next test's position kept the file green. Adding an Airship or Pusher test below either one would have failed on a poisoned chain rather than on a diff, which is the fragility that already had to be fixed once for the held-apply helper. Fail a READ request's apply instead of an Airship one: applyHTTPSOnyxUpdates returns a fresh promise per call, so nothing leaks, and failing it at update 15 under a Pusher apply held at 20 is the case that argues for the unconditional reset — the marker masks 15 if the reset is scoped by type. The Pusher chain has no such escape, so that test loads its own copy of the module and passes the watermark in, keeping it independent of both the chain and the Onyx instance the fresh copy connects to. Verified by deleting the reset: these two go red, the other sixteen stay green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The describe now says the event chain is module-scoped, which is the whole reason the block resets modules and imports its own copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@MelvinBot this PR is aimed to close the gap on excess |
ReviewThe core idea is right and the premise checks out: nothing at the Pusher ingress serializes the gap check, so update B genuinely arrives while update A's apply is still in flight and falsely reads as a gap on Two things to fix before this ships. 1. The marker suppresses gaps for HTTPS too, but the thing that makes that safe is Pusher-only
Verified empirically against your branch: watermark 10, Pusher A (10→20) held mid-apply, then an HTTPS READ response Since the 24.5% you measured is Pusher-chained-on-Pusher, scoping the marker's use to the Pusher call site ( 2. The
|
| Step | Status |
|---|---|
| ✅ | |
| ✅ | |
Reload and confirm messages persistNot run — a page reload drops client auth in this harness and can hit an unrecoverable magic-code wall. |
|
Report JS console errors and any gap-detection log linesCould not verify — the web harness does not expose browser console output, so I cannot confirm or deny thelastUpdateIDFromClient is less than the previousUpdateID / [OnyxUpdateManagerError] lines. No error banners or toasts appeared and no request failed. |
One observation: two GetMissingOnyxMessages calls still fired during the 3-message burst (both 200). On a brand-new onboarding account that is likely legitimate, but it's a reminder that this closes one specific source of the excess calls rather than all of them.
|
@MelvinBot van you create an issue for this or and link it in the pr body. Assign mountiny, pr author to the issue and make it Daily, Task |
|
Created #100174 and linked it in the PR body under "Known issue found while writing this, not fixed here".
I filed it for the The issue body records the ordering constraint, which is the part that matters: this bug is currently load-bearing for this PR. Fixing If you meant a different item — finding #1 (the cross-transport hole) or the Airship marker |
|
@MelvinBot can you make sure the linked issue reflects what this PR is about #100174 |
|
No — #100174 is a different bug, and the issue itself says so. What the issue is about: a single rejected Pusher apply leaves the module-scoped What this PR is about: adding Nothing in the diff touches Recommendation: follow the precedent from the sibling change — #98777 has its own issue, #98984, titled the same as the PR. Create a dedicated issue for this PR, point Separate problem: the PR description no longer matches its own diffChecked against head
Reads like the description was written ahead of the code. Worth pushing those commits or trimming the description back to what's actually there, so a reviewer isn't reviewing a version that doesn't exist. |
An in-flight Pusher apply is only safe to count as applied for callers serialized behind it on pusherEventsPromise. HTTPS and Airship applies run on their own chains, so reading the marker there could advance the watermark past updates the held apply has not written, leaving no gap to recover from. Also clear the marker once its apply settles, so it stops standing for an update that is no longer in flight and cannot override a lower clientLastUpdateID that a caller passed explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@DylanDylann 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] |
|
Consider updating getEffectiveLastUpdateID to include |
Reviewer Checklist
Screenshots/VideosScreen.Recording.2026-09-08.at.13.51.14.mov |
|
@adhorodyski Some minor NITs |
|
re: |
|
🚧 mountiny has triggered a test Expensify/App build. You can view the workflow run here. |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🧪🧪 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.75-0 🚀
|
|
No help site changes are required for this PR, so I did not create a draft PR. This change is internal client sync bookkeeping. It splits the pending-update watermark into What I checkedFiles changed — all four are library or test code, none is user-facing:
No user-facing surface. The diff touches no No matching articles. I searched all 947 files in Per Since no docs changes are needed, there is no linked help site PR to review, and no |
|
🚀 Deployed to production by https://github.com/mountiny in version: 9.4.75-3 🚀
Bundle Size Analysis (Sentry): |
|
🚀 Deployed to production by https://github.com/mountiny in version: 9.4.75-3 🚀
Bundle Size Analysis (Sentry): |



Explanation of Change
When a Pusher update is still being written to disk, the next Pusher event that follows it stops being mistaken for a missing update, so the client no longer pauses its write queue and re-downloads what it is already writing.
Fixed Issues
$ #100313
PROPOSAL: N/A
Follow-up, not fixed here: #100174
Tests
Check out this branch and run
npm run test -- tests/unit/OnyxUpdatesTest.ts tests/unit/OnyxUpdatesPusherChainFailureTest.ts. 20 pass.Six tests were added, five beside their existing WRITE-path siblings in
OnyxUpdatesTest.ts. Only the first two fail onmain; the rest are green there becausemainhas no marker at all, so each one names the change that turns it red instead:does not report a gap for a Pusher update that is still applyingmain— this is the behaviour the PR changesrelies on pusherEventsPromise staying rejected to stop a follower whose gap check the failed update had suppressedmain, and on this branch ifpusherEventsPromiseis made to recover from a rejectionreports a gap for an HTTPS response chained on a Pusher update that is still applyingupdateTypegate is dropped and every transport reads the markerclears the Pusher pending apply marker when an unrelated apply fails below it, so a real gap is never maskedlastUpdateIDPendingApply = 0is deleted from the shared.catchstops counting a Pusher update as in flight once its apply has settled.then()is deletedclears the pending apply watermark on sign-outAdding the marker to
getEffectiveLastUpdateID()reddens four tests, includingkeeps a Pusher update that is still applying out of the catch-up fetch range, which is what guards that decision.relies on pusherEventsPromise staying rejected to stop a follower whose gap check the failed update had suppressedlives in its own file. A rejected Pusher apply leaves the module-scopedpusherEventsPromiserejected for the rest of the module's life, so it cannot share an instance with the other tests. It is also the test that pins the coupling tracked in A single rejected Pusher apply poisons pusherEventsPromise, stopping all Pusher Onyx updates for the session #100174: nothing checks that update IDs are contiguous before advancing the watermark, so serializing onpusherEventsPromiseis the only thing stopping the follower from moving the watermark past the failed update's range.Run the regression set:
npm run test -- tests/unit/OnyxUpdatesTest.ts tests/unit/OnyxUpdatesPusherChainFailureTest.ts tests/unit/OnyxUpdateManagerTest.ts tests/actions/OnyxUpdateManagerTest.ts tests/unit/SequentialQueueTest.ts tests/actions/QueuedOnyxUpdatesTest.ts. 107 pass.Manual, web: open the app with the JS console filtered to
[OnyxUpdateManager], then send several messages in quick succession in a busy chat while a second device posts to the same chat.Verify
lastUpdateIDFromClient is less than the previousUpdateID receivedno longer fires for an update ID that a preceding[OnyxUpdateManager] Applying pusher updateline already named.Verify messages still arrive in order and no message is lost or duplicated.
Offline tests
GetMissingOnyxMessagesorReconnectAppstill fires when the client is genuinely behind, which is the reconnect path this change deliberately leaves intact.QA Steps
No user-visible behaviour changes on its own. It removes redundant
GetMissingOnyxMessagesfetches and the write-queue pauses that accompany them, so the observable effect is fewer stalls under load.PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionOffline testsfor a reviewer.lastUpdateIDPendingFlushpattern from fix: read one effective update watermark everywhere so the missing-updates fetch can advance #98777.Avatar, I verified the components usingAvatarare working as expected) — the marker's readers areOnyxUpdateManagerandSaveResponseInOnyx, both covered by the regression set in step 4.StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases) — N/A, no components changed.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. — rebased ontomainatf1f9591d66f9and re-ran the suites in step 4.Screenshots/Videos
Android: Native
N/A — no UI surface. This changes update-gap bookkeeping in
src/libs/actions/OnyxUpdates.tsonly.Android: mWeb Chrome
N/A — no UI surface.
iOS: Native
N/A — no UI surface.
iOS: mWeb Safari
N/A — no UI surface.
MacOS: Chrome / Safari
N/A — no UI surface.