Skip to content

Claim the sequential queue read gate for deferred writes - #99815

Open
Abdukhamid000 wants to merge 2 commits into
Expensify:mainfrom
Abdukhamid000:fix/writeWhenReady-read-gate
Open

Claim the sequential queue read gate for deferred writes#99815
Abdukhamid000 wants to merge 2 commits into
Expensify:mainfrom
Abdukhamid000:fix/writeWhenReady-read-gate

Conversation

@Abdukhamid000

Copy link
Copy Markdown
Contributor

cc @JakubKorytko - writeWhenReady is yours, and the design question at the bottom is really for you.

Explanation of Change

API.write() claims the sequential queue's read gate synchronously: push() calls setIsReadyPromisePending() before its first await, with a comment saying exactly why - "so any READ that fires on the next synchronous line via waitForIdle() correctly parks behind this write".

writeWhenReady() never did. So deferring a write silently dropped the read-after-write ordering that every write() caller gets for free: the queue stays empty for the length of the deferral, waitForWrites() resolves immediately, and a destination screen that fetches the same data races ahead of the write and repopulates itself from pre-write server state.

That is what caused #99805. Nothing in writeWhenReady's docblock warned about it - it covers conflictResolver, barrier-rejection semantics, cross-call ordering and crash-loss durability, but not this - so the next caller to defer a write would have walked into the same trap.

The fix. claimReadGateForDeferredWrite() claims the gate for a write that is not on the queue yet. push() marks the gate pending again idempotently once the write lands, so it adopts the claim rather than opening a second one, and the queue drain resolves it - the handover leaves no gap for a READ to slip through. The claim is two-phase: handOff() when the write reaches the queue, release() when it never does.

flush() had to learn about it too. Its empty-queue branch resolved the gate unconditionally, and during a deferral the queue is empty - so any unrelated flush in that window would have released the claim and the fix would have evaporated. It now skips that resolve while a deferred claim is outstanding. The follower branch still resolves: a tab that never processes the queue would otherwise park READs forever. There is a test that fails without the guard.

Offline is a no-op, matching push() (which returns before claiming the gate in that state) and flush() (which resolves it) - neither parks READs behind a queue that isn't running. If the app goes offline mid-deferral, the claim is handed back rather than parking every READ until reconnect.

One design call worth a second opinion, @JakubKorytko: this is safe by default with no opt-out, so every writeWhenReady caller now holds the gate, and READs wait out the barrier on top of the write's own round trip. There are no production callers today, so nothing regresses - but if you'd rather have an escape hatch for a long deferral whose destination genuinely doesn't refetch, say so and I'll add the option.

I also documented a footgun this introduces: a barrier must not await a READ of its own, or it deadlocks until safetyTimeoutMs breaks it.

Fixed Issues

$ #99805
PROPOSAL: N/A - root-cause follow-up to the revert in #99814

Tests

This is a primitive with no production callers, so the behavior is covered by unit tests rather than a UI flow:

  1. npx jest tests/unit/SequentialQueueReadGateTest.ts - 6 tests over the gate itself: a READ parks behind a claim; a hand off leaves the gate to the queue drain; a flush() that finds the queue empty mid-deferral does not release it; a second claim adopts the first rather than opening a second gate; offline is a no-op; a stale release cannot resolve a gate a later write opened
  2. npx jest tests/unit/APIWriteWhenReadyTest.ts - the wiring: the gate is claimed synchronously before the barrier settles, handed off when the write executes online, and given back when it executes offline
  3. Verify the whole neighbourhood still passes: npx jest tests/unit/SequentialQueueTest.ts tests/unit/APITest.ts tests/unit/NetworkTest.tsx tests/unit/MiddlewareTest.ts tests/unit/resolveWriteBarrierTest.ts tests/unit/pendingSearchWriteTest.ts tests/unit/pendingSubmitWriteTest.ts

All 179 tests across those 9 suites pass locally, along with ESLint, typecheck and cspell.

  • Verify that no errors appear in the JS console

Offline steps

Covered by the "is a no-op while offline" test in SequentialQueueReadGateTest and the "hands the gate back when the write executes while offline" test in APIWriteWhenReadyTest. There is no user-facing flow to exercise, since nothing in production calls writeWhenReady on this branch.

QA steps

No user-facing change: writeWhenReady has no production callers, so this alters no flow QA can reach. A regression here would surface as READs hanging, so a general smoke test of any list that loads from the server (Inbox, Reports, Spend > Expenses) is enough to confirm nothing is parked.

  • 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

N/A - no user-facing change; this PR touches only the API/queue primitive and its unit tests.

Android: mWeb Chrome

N/A - no user-facing change; this PR touches only the API/queue primitive and its unit tests.

iOS: Native

N/A - no user-facing change; this PR touches only the API/queue primitive and its unit tests.

iOS: mWeb Safari

N/A - no user-facing change; this PR touches only the API/queue primitive and its unit tests.

MacOS: Chrome / Safari

N/A - no user-facing change; this PR touches only the API/queue primitive and its unit tests.

MacOS: Desktop

N/A - no user-facing change; this PR touches only the API/queue primitive and its unit tests.

`API.write()` claims the read gate synchronously: `push()` calls
`setIsReadyPromisePending()` before its first await, so a READ firing on the
next line parks behind the write. `writeWhenReady()` never did, so deferring a
write silently dropped that ordering - the queue stayed empty for the length of
the deferral, and a destination screen fetching the same data raced ahead and
repopulated itself from pre-write server state.

`claimReadGateForDeferredWrite()` claims the gate on behalf of a write that is
not on the queue yet. `push()` adopts the claim when the write lands, so the
handover leaves no gap, and the queue drain resolves it.

`flush()` also had to learn about it: its empty-queue branch resolved the gate
unconditionally, which during a deferral means "the write hasn't been pushed
yet", not "nothing is coming". The follower branch still resolves, since a tab
that never processes the queue would otherwise park READs forever.

No-op while offline, matching `push()` and `flush()`, neither of which parks
READs behind a queue that isn't running.
@Abdukhamid000
Abdukhamid000 requested review from a team as code owners August 29, 2026 06:23
@melvin-bot
melvin-bot Bot requested review from heyjennahay and ikevin127 and removed request for a team August 29, 2026 06:23
@melvin-bot

melvin-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

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

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Coverage Δ
src/libs/API/writeWhenReady.ts 98.73% <100.00%> (+0.10%) ⬆️
src/libs/Network/SequentialQueue.ts 90.08% <96.29%> (+0.46%) ⬆️
... and 7 files with indirect coverage changes

@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: 7214f06594

ℹ️ 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 on lines +109 to +110
setIsReadyPromisePending();
deferredWriteGateClaims += 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the gate claimed when the active queue drains

When writeWhenReady() is called while another write is already being processed, this reuses that write's pending isReadyPromise. If the active queue finishes before the deferred barrier settles, the queue-drain path at flush() lines 602–621 resolves the shared promise without checking deferredWriteGateClaims, so subsequent reads proceed while the deferred write is still absent from the queue and can restore pre-write server data. The claim count must be honored by every online gate-resolution path, not only the newly guarded empty-queue branch.

Useful? React with 👍 / 👎.

if (!settleOnce()) {
return;
}
// A later write may have opened a new gate since; resolving that one would let READs through

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.

❌ CONSISTENCY-16 (docs)

Comments should read as plain, natural sentences. This comment joins two independent clauses with a semicolon, which the style guide asks you to write as two separate sentences instead.

Split the clauses into two sentences:

// A later write may have opened a new gate since. Resolving that one would let READs through
// while somebody else's write is still pending.
if (isReadyPromise !== claimedPromise) {
    return;
}

Reviewed at: 7214f06 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

waitForIdle: jest.fn(() => Promise.resolve()),
// Called by the network layer on init; stub so advancing fake timers doesn't hit a missing export.
flush: jest.fn(),
// The claim writeWhenReady settles once its write reaches (or fails to reach) the queue; the real

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.

❌ CONSISTENCY-16 (docs)

Comments should read as plain, natural sentences. This comment uses a semicolon to join two independent sentences; the style guide asks you to write them as two separate sentences.

Split into two sentences:

// The claim writeWhenReady settles once its write reaches (or fails to reach) the queue. The real
// gate is covered in SequentialQueueReadGateTest.
claimReadGateForDeferredWrite: jest.fn(() => ({handOff: jest.fn(), release: jest.fn()})),

Reviewed at: 7214f06 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

Nothing imports the type - consumers use the inferred return type of
claimReadGateForDeferredWrite - and exporting it added a knip finding.
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.

1 participant