Skip to content

Treat an in-flight reconnect as covering the gap it is already fetching - #100320

Merged
mountiny merged 6 commits into
Expensify:mainfrom
callstack-internal:2929-reconnect-watermark
Sep 8, 2026
Merged

mountiny merged 6 commits into
Expensify:mainfrom
callstack-internal:2929-reconnect-watermark

Conversation

@adhorodyski

@adhorodyski adhorodyski commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Explanation of Change

A record that looks missing while the app's own reconnect is still fetching it counts as already on its way, so the app waits for that reconnect's answer and then asks only for whatever it did not bring back.

Scope: this covers the 0.8s–3.4s a catch-up request spends on the wire, which #99774's marker does not reach: that one records an update already being written locally, after a response lands. The two shares are not additive.

Fixed Issues

$ #100471
PROPOSAL: N/A

Tests

  1. Check out this branch and run npm run test -- tests/unit/OnyxUpdateManagerTest.ts. All 33 pass.
  2. Open the app on web with the JS console filtered to [OnyxUpdateManager] and the Network tab filtered to api.
  3. Sign in on a second device and open the same chat on both.
  4. On the first device, turn the network off in DevTools, send 3-4 messages from the second device, then turn the network back on.
  5. Verify a ReconnectApp request fires, and that while it is still pending the console logs Gap detected in update IDs from the server, but an in-flight reconnect already covers it, so waiting for its response.
  6. Verify no GetMissingOnyxMessages request fires next to it in the Network tab.
  7. Verify all messages from the second device arrive in the chat, in order, with none missing or duplicated.
  8. Repeat step 4 a few times and verify the app never stays stuck loading and messages still send.
  • Verify that no errors appear in the JS console

Offline tests

  1. Go offline, send two messages, and verify both queue and show as pending.
  2. Go back online and verify both send, the queue drains, and the chat matches the server state after a refresh.
  3. Verify a genuinely behind client still fetches: with no reconnect in flight, a gap must still fire GetMissingOnyxMessages, which is the path this change deliberately leaves intact.
  4. Verify a reconnect that fails while a gap is open still self-heals — the fetch is delayed by the retry throttle (up to CONST.NETWORK.MAX_RETRY_WAIT_TIME_MS) rather than skipped, and the pause watchdog is the backstop.

QA Steps

No user-visible behaviour changes on its own. It removes redundant GetMissingOnyxMessages fetches after a reconnect and the write-queue pauses that accompany them, so the observable effect is fewer stalls when the app comes back online.

  1. Sign in on two devices to the same account and open the same chat on both.
  2. On the first device, turn the network off for about ten seconds while the second device sends several messages, then turn it back on.
  3. Verify all messages appear on the first device, in order, with none missing or duplicated.
  4. Repeat a few times and verify the app never gets stuck on a loading state or stops sending.

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

No UI changes — this PR only removes a redundant network request and the write-queue pause that came with it.

Android: Native

N/A — no UI changes.

Android: mWeb Chrome

N/A — no UI changes.

iOS: Native

N/A — no UI changes.

iOS: mWeb Safari

N/A — no UI changes.

MacOS: Chrome / Safari

N/A — no UI changes.

reconnectApp sends its request through the sequential queue but never arms
setMissingOnyxUpdatesQueryPromise, so nothing on the client records that a
catch-up range is outstanding. The stored update ID therefore sits still for
the whole request flight (p50 0.80s, p90 3.43s), and a Pusher event arriving
in that window compares against the stale position and reads as a hole. The
client then fires GetMissingOnyxMessages for a range the reconnect is already
carrying: 97.8% of the fetches produced this way asked for a range wholly
inside the in-flight reconnect, median 355,329 update IDs.

Read the queue's own in-flight record instead of adding a second marker. When
the request on the wire is an OpenApp or a ReconnectApp whose coverage starts
at or below the client's position, wait on getCurrentRequest() and then
re-validate the deferred updates, which re-reads the position and fetches only
what is still missing.

Only the request on the wire qualifies. A reconnect still queued behind other
WRITEs never sends while the queue is paused for this gap, so waiting on it
would hold the pause open until the watchdog. The shouldFetchPendingUpdates
branch is deliberately left alone: it never enqueues a deferred update, so
suppressing its fetch would drop the range up to pendingLastUpdateID.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adhorodyski and others added 2 commits September 3, 2026 18:03
# Conflicts:
#	src/libs/actions/OnyxUpdateManager/index.ts
#	tests/unit/OnyxUpdateManagerTest.ts
@codecov

codecov Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/libs/actions/OnyxUpdateManager/index.ts 86.92% <100.00%> (+0.52%) ⬆️
src/libs/actions/OnyxUpdateManager/utils/index.ts 98.27% <100.00%> (ø)
src/libs/actions/RequestConflictUtils.ts 100.00% <ø> (ø)
... and 15 files with indirect coverage changes

@adhorodyski

adhorodyski commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@adhorodyski

Copy link
Copy Markdown
Contributor Author

@MelvinBot please review this PR

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Code review — the approach is sound and the tests are unusually good at attacking it. One issue is worth fixing before merge, plus two smaller robustness notes.

The coverage predicate matches the convention already used by resolveReconnectDuplicationConflictAction, and only consulting the ongoing request (not queued reconnects) is the right call — a queued reconnect hasn't left yet, so getCurrentRequest() wouldn't be waiting for it.


1. validateAndApplyDeferredUpdates(clientLastUpdateID) anchors to a pre-reconnect update ID

OnyxUpdateManager/index.ts:298 forwards clientLastUpdateID, which is a snapshot taken before the reconnect landed. Push notifications reach here with that argument set — subscribeToPushNotifications.ts:116 passes clientLastUpdateID: lastUpdateIDAppliedToClient with shouldRunSync: true.

That matters more here than in the old path. GetMissingOnyxMessages advances the client by exactly the range it was asked for, so a stale anchor is harmless. A reconnect can advance it by an arbitrary amount, and detectGapsAndSplit filters deferred updates by minUpdateID: lastUpdateIDFromClient. With the anchor pinned low, updates the reconnect already superseded survive that filter and get re-applied over newer data, and latestMissingUpdateID is computed from the stale position — firing a GetMissingOnyxMessages for a range the reconnect just delivered, which is the fetch this PR exists to remove.

Concretely: client at 1, push notification carries clientLastUpdateID: 1, deferred {3, 7}, in-flight ReconnectApp(updateIDFrom: 1) returns 2–5. After it lands the client is at 5, but validateAndApplyDeferredUpdates(1) still sees update 3 as pending, fetches 1→2, and re-applies 3 on top of 5.

Passing undefined re-reads getEffectiveLastUpdateID() after the reconnect lands, which is what this path actually wants:

setMissingOnyxUpdatesQueryPromise(getCurrentRequest().then(() => validateAndApplyDeferredUpdates()));

Worth a test on the clientLastUpdateID-provided path — none of the five new cases exercise it.

2. getCurrentRequest() means "queue drained", not "this request answered"

Every terminal branch of the currentRequestPromise chain ends in return process() (SequentialQueue.ts:405, and again in each catch branch), so the promise resolves only once process() does. It behaves as "the reconnect landed" only because every caller that reaches this branch pauses the queue first (applyOnyxUpdatesReliably.ts:35), which makes the recursive process() short-circuit on its paused guard.

That's correct today, but it's an invisible dependency: if a future change stops pausing on gap detection, this silently becomes "hold every deferred update until the whole queue drains" with no test failing. Please pin it with a comment, or assert isQueuePaused here.

3. No fallback if the awaited chain rejects

If currentRequestPromise rejects, the .then is skipped but finalizeUpdatesAndResumeQueue still runs via finally — so the gap-revealing update is cleared without being applied and no fetch is issued, leaving the client behind until the watchdog. The rejection is also unhandled, since the Onyx subscriber calls handleMissingOnyxUpdates(value) without a catch (index.ts:359). A .catch falling back to fetchMissingUpdates would make this symmetric with the normal path.

Nit

reconnectCoverageFrom(ongoingCatchUp) is evaluated three times across lines 290–295 — hoist it to a const and reuse it in the log payload.

What I verified, and what I didn't

Traced and confirmed OK:

  • No double-apply of the reconnect payload: ReconnectApp is in requestsToIgnoreLastUpdateID, so its response applies directly and advances the last-applied ID before the deferred queue is validated. Deferred duplicates are then filtered by minUpdateID — provided the anchor is current (issue 1).
  • A failed reconnect doesn't strand the suppression for new gaps: rollbackOngoingRequest clears ongoingRequest before the retry backoff, so the next gap falls through to the normal fetch.
  • updateIDFrom is typed number throughout reconnectApp, so reconnectCoverageFrom's typeof === 'number' check can't silently degrade an incremental reconnect to coverage 0.
  • OpenApp can't reach this branch mid-load — the isLoadingApp guard returns first.
  • Spies don't leak between the new tests; the file has afterEach(() => jest.restoreAllMocks()), which the repo's jest config doesn't provide globally.
  • The reconnect-lands-without-closing-the-gap case is genuinely covered by the first new test, which is the case I'd most expect to be missed.

Not verified: I could not execute the suite in this environment, so the "all 33 pass" claim in the test plan is unchecked by me. No web verification either — there's no UI surface to observe, and the test plan needs two devices plus network toggling.

Coverage gaps beyond issue 1: no case with OPEN_APP as the ongoing request, even though isReconnectFamilyRequest accepts it; and a watchdog-escalated reconnect uses reconnectAppWithSideEffects, which bypasses the sequential queue, so getOngoingRequest() can't see it and that reconnect won't be deduped. The latter is a missed optimization, not a bug.


view run

…red updates

Only the Airship push path passes a clientLastUpdateID, and it snapshots the
watermark before the catch-up it is about to wait on has landed. Pinning to it
let superseded updates past the minUpdateID filter, and OnyxUpdates.apply
re-applies successData/failureData/finallyData for HTTPS updates even when the
update is old, so an old payload could write over newer data. It also set the
refetch range from the stale position.

The pin still has to win when it is ahead of the live value: in Headless JS no
Onyx callbacks run, so getEffectiveLastUpdateID reads 0 and simply dropping the
argument refetches from 0. Math.max keeps both cases correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@adhorodyski

Copy link
Copy Markdown
Contributor Author

On the extra assert Melvin proposed, we should do nothing. No need to guard against a potential future refactor breaking it.

@melvin-bot

melvin-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

@ZhenjaHorbach 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]

@ZhenjaHorbach

ZhenjaHorbach commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
2026-09-08.13.14.59.mov
Android: mWeb Chrome
2026-09-08.13.14.59.mov
iOS: HybridApp
2026-09-08.13.11.45.mov
iOS: mWeb Safari
2026-09-08.13.10.00.mov
MacOS: Chrome / Safari
2026-09-08.13.00.51.mov

@melvin-bot
melvin-bot Bot requested a review from mountiny September 8, 2026 11:16
@ZhenjaHorbach

Copy link
Copy Markdown
Contributor

LGTM!

@mountiny
mountiny merged commit 830f8a8 into Expensify:main Sep 8, 2026
35 of 37 checks passed
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

🚧 mountiny has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

OSBotify commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify OSBotify mentioned this pull request Sep 8, 2026
69 tasks done
@OSBotify

OSBotify commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/mountiny in version: 9.4.74-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@MelvinBot

MelvinBot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Help site review: no changes required to docs/articles.

This PR changes only client-side update reconciliation. Nothing it touches is documented on the help site, so I did not create a draft PR.

Why

What changed — all four files are internal sync/network plumbing with no user-facing surface:

  • src/libs/actions/OnyxUpdateManager/index.ts — when a gap in server update IDs is detected and an in-flight reconnect already covers it, wait for that response instead of firing GetMissingOnyxMessages.
  • src/libs/actions/OnyxUpdateManager/utils/index.ts — clamps the client's last update ID with Math.max.
  • src/libs/actions/RequestConflictUtils.ts — exports reconnectCoverageFrom.
  • tests/unit/OnyxUpdateManagerTest.ts — unit tests.

Nothing documented is affected. The help site documents features, settings, and workflows people interact with. This PR adds no feature, renames no tab or setting, adds no button, and changes no copy. Your own QA steps confirm it: "No user-visible behaviour changes on its own." The observable effect — fewer stalls and redundant requests after coming back online — is a performance improvement, and the help site does not document App sync internals, reconnect behavior, or offline update reconciliation.

Searched for candidate articles. I checked docs/articles for anything covering offline behavior, sync, reconnect, or loading states in New Expensify. Every sync match is an accounting-connection troubleshooting article (NetSuite, QuickBooks, Sage Intacct, Xero sync errors) — integration sync, not client update sync. The offline/reconnect matches are about broken company card feed connections and bank re-authentication. None describe the behavior this PR alters, so there is no existing text to correct and no new article to write.

No live UI verification was needed. The naming and capitalization checks in HELPSITE_NAMING_CONVENTIONS.md apply to text I would write. Since no article text changes, there are no feature names, tab names, settings labels, or buttons to verify.

Since no docs changes are required, there is no linked help site PR to review or mark Ready for review. If you would like an article written anyway — for example, a customer-facing note about offline messaging behavior in New Expensify chat — reply with @MelvinBot and what you want covered, and I will draft it.


view run · view recording

@OSBotify

OSBotify commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/mountiny in version: 9.4.74-2 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants