Skip to content

Send SearchForTodos concurrently with writes instead of waiting on the queue - #97468

Draft
TMisiukiewicz wants to merge 2 commits into
Expensify:mainfrom
callstack-internal:perf/search-for-todos-concurrent
Draft

Send SearchForTodos concurrently with writes instead of waiting on the queue#97468
TMisiukiewicz wants to merge 2 commits into
Expensify:mainfrom
callstack-internal:perf/search-for-todos-concurrent

Conversation

@TMisiukiewicz

Copy link
Copy Markdown
Contributor

Explanation of Change

SearchForTodos is fired by loadPostDataForOpenOrReconnect() right after OpenApp/ReconnectApp are enqueued, but API.read gates every read behind waitForWrites(), so the request didn't actually hit the network until the sequential write queue drained. On large accounts, where OpenApp can take 5-10s, SearchForTodos sat idle that whole time before even being sent.

This PR makes two changes:

  1. Adds a READS_SENT_DURING_WRITES set (currently just SEARCH_FOR_TODOS) in src/libs/API/types.ts. API.read skips waitForWrites for commands in this set, so they go out concurrently with in-flight writes, mirroring the existing carve-out for the sign-in short-lived/support auth token commands.
  2. Because SearchForTodos' response touches Onyx keys that OpenApp/ReconnectApp also write, its Onyx updates must not apply while those writes are still in flight. In applyHTTPSOnyxUpdates (src/libs/actions/OnyxUpdates.ts), commands in that set now apply via waitForSequentialQueueIdle().then(() => Onyx.update(updates)) instead of the existing queueOnyxUpdates buffer (that buffer only drains when the queue processor finishes with an empty queue, so a response landing after the queue already went idle would be stranded). Gating on queue-idle applies immediately when nothing is in flight, since isReadyPromise starts resolved.

Net effect: the request stops occupying the critical path, and its data still lands no earlier than the writes it raced against. The sign-in commands are left as separate checks since they must apply immediately (deferring an auth token behind writes would break login).

Added tests/unit/SearchForTodosDeferralTest.ts covering (a) the request firing alongside an in-flight write while its Onyx updates apply only after that write settles, and (b) applying immediately when no write is in flight (regression guard against stranding).

Fixed Issues

$
PROPOSAL:

Tests

  • Verify that no errors appear in the JS console

Offline tests

N/A

QA Steps

Same as tests

  • Verify that no errors appear in the JS console

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

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

@mountiny
mountiny requested a review from Copilot July 30, 2026 14:05
@mountiny

Copy link
Copy Markdown
Contributor

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2867122695

ℹ️ 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".

Comment thread src/libs/API/index.ts
if (command === READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN || command === READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN) {
// Sign in with shortLivedAuthToken commands shouldn't be blocked by write commands, and neither should READS_SENT_DURING_WRITES.
// Unlike the sign in commands, those hold their Onyx updates until the writes have applied theirs (see applyHTTPSOnyxUpdates).
if (command === READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN || command === READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN || READS_SENT_DURING_WRITES.has(command)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer SearchForTodos before gap checks

When SearchForTodos races an in-flight OpenApp/ReconnectApp, the server can process the write first but deliver the read response first. This branch sends that read immediately, but SaveResponseInOnyx runs doesClientNeedToBeUpdated() before applyHTTPSOnyxUpdates gets a chance to wait for the sequential queue, so a read whose previousUpdateID includes the still-in-flight write is treated as a gap instead of being deferred behind the write (and during initial app loading the saved update is ignored). Please defer the whole response/gap check until the write has at least staged/applied its update ID, not just the final Onyx.update.

Useful? React with 👍 / 👎.

@mountiny mountiny left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took a careful pass through this along with the surrounding queue/Onyx machinery. Ran the affected suites locally — NetworkTest, APITest, SequentialQueueTest and the new SearchForTodosDeferralTest all pass (60 tests), and lint is clean apart from pre-existing seatbelt warnings.

The goal is right and the diff is small and well-commented. The reasoning for rejecting queueOnyxUpdates (stranding a response that lands after the buffer already drained) is correct, and keeping the sign-in commands as separate checks with the "must apply immediately" note is the right call. One issue I think blocks, one robustness concern, and a few smaller things.


1. Staleness inversion: the payload is now older, but still applied last

The current gate does more than order the Onyx apply — it means the request is sent after the writes settle, so its payload reflects post-write server state.

With this change SearchForTodos is sent at t0 and applied at t2, on top of data that reflects t2. Onyx merge is last-write-wins per field, so the older payload wins on every overlapping key.

Concrete case — offline approve, then reconnect:

  1. User is offline and approves report X. ApproveMoneyRequest is persisted.
  2. Reconnect. reconnectApp() enqueues ReconnectApp, the queue holds both writes, and SearchForTodos now goes out immediately. The server has not processed the approval yet, so X comes back in the approve bucket with stateNum: SUBMITTED.
  3. The queue drains, the approval succeeds, X becomes APPROVED in Onyx.
  4. The held to-do payload then applies and merges X back to stateNum: SUBMITTED.

X shows as awaiting approval again until a Pusher update or a reload corrects it. The same shape applies to any Pusher update landing between t0 and t2, and reportActions_ merges can resurrect actions deleted in that window. On a large account the window is the full OpenApp duration — the 5-10s cited in the description.

The description says "its data still lands no earlier than the writes it raced against", which is true, but the hazard isn't when it lands — it's that it is older and lands last.

Suggestion: take the fast path only when the queue holds nothing but OPEN_APP / RECONNECT_APP. Those apply no optimistic data to these keys and their own payload is the fresher one, so the inversion disappears. Fall back to the existing waitForWrites behaviour whenever any other write is pending.

2. waitForIdle() doesn't establish that the write's Onyx data has been applied

resolveIsReadyPromise() fires at src/libs/Network/SequentialQueue.ts:420, and flushOnyxUpdatesQueue() — the thing that actually hands the buffered WRITE batch to Onyx — is called at line 427, in the same synchronous block. The deferred .then(() => Onyx.update(updates)) is a microtask queued at 420, so it runs after Onyx.update(writeBatch) has been called but well before it has resolved.

In practice the write gets a one-microtask head start (Onyx.update runs afterInit synchronously and defers its operations through clearPromise.then(...), so the write's operations are enqueued first). But that is a property of Onyx.update internals, not something this code establishes.

There's also a window with no head start at all: if the SearchForTodos response lands after resolveIsReadyPromise() but while flushQueue()'s Onyx.update is still in flight, waitForIdle() is already resolved and the todos payload applies immediately, concurrent with the flush. That matters because OpenApp / full ReconnectApp send report_, reportNameValuePairs_, transactions_ and transactionViolations_ as SET_COLLECTION, and SearchForTodos merges into exactly those collections.

Worth noting the codebase already treats these as distinct events: getCurrentFlushPromise() was added to QueuedOnyxUpdates precisely because queued WRITE updates "only land in Onyx when flushQueue() runs". It can't be used directly here though — flushQueue() nulls flushPromise before its Onyx.update resolves, so by the time the deferred microtask runs it returns an already-resolved promise.

A gate that does hold: wait for IS_LOADING_REPORT_DATA to go true -> false. It sits in finallyData, which lands in the same Onyx.update(copyUpdates) batch as the report and transaction setCollections, so observing false proves the write's data is applied. No new plumbing needed.

I have not reproduced a failure from this — flagging it as fragility rather than a demonstrated bug.

3. Each updateHandler call re-reads the gate

applyHTTPSOnyxUpdates invokes updateHandler up to three times (onyxData -> success/failureData -> finallyData), chained. Each applyWhenQueueIsIdle call reads waitForSequentialQueueIdle() at its own moment, so an unrelated write arriving between them re-arms isReadyPromise and splits what should be one batch across two very different times.

SearchForTodos carries no success/finally data today so this is latent, but it's a trap for the next command added to READS_SENT_DURING_WRITES. Capturing the gate promise once and reusing it for all three calls would close it.

4. The deferral is a no-op on non-leader tabs

flush() resolves isReadyPromise immediately when !isClientTheLeader(), so on a follower tab the to-do payload applies with no gating at all. Not a regression — the same is true today — but it does mean the safety argument only covers the leader tab.

5. The tests can't catch an ordering problem

TODOS_ONYX_DATA deliberately uses an unrelated key (NVP_PRIORITY_MODE), so the tests prove the deferral fires but not that it orders correctly against the write. A case where the write's onyxData does setCollection on a collection and the deferred read does mergeCollection into the same one — asserting the read's members survive — would cover the interaction that actually matters here.

6. NetworkTest: assertion weakened and the comment is now stale

Step 4 is still labelled "First API Call Verification" but now only asserts the command appears somewhere in the list. Filtering SearchForTodos out of calledCommands() and keeping the ordering assertion on the remainder would preserve what the test was checking.

7. Nit: Fixed Issues

### Fixed Issues is a bare $ with the PROPOSAL: placeholder still in place — needs the full issue URL before this is out of draft.

8. Minor: this also widens the post-sign-out window

Reads don't set canCancel (src/libs/API/makeRequest.ts sets it for writes only), so HttpUtils.cancelPendingRequests() on sign-out doesn't abort SearchForTodos. Its response can now park on isReadyPromise for the length of the queue drain and apply afterwards. Reads have never gone through flushQueue()'s preservedKeys filter so this isn't a new gap, just a wider window.

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.

3 participants