Skip to content

perf: run independent hot-path calls concurrently - #1945

Merged
bmc08gt merged 10 commits into
code/cashfrom
perf/parallel-async
Nov 3, 2026
Merged

bmc08gt merged 10 commits into
code/cashfrom
perf/parallel-async

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Several hot paths awaited network or disk calls back to back when neither read the other's result. This runs them concurrently. Each fix is its own commit.

Path Change Round trips saved
Balance refresh (TokenCoordinator.updateTokens) The mint-metadata refresh overlaps the GetAccounts call; the build waits for both 1, on every refresh
Token build (TokenController.buildTokenBalances) Per-account metadata lookups run concurrently, 8 at a time, in account order N−1 → about N/8 on a cold cache (fresh login)
Group send and boost (SharedStatePayment.pay) Payee Resolve calls run concurrently, 8 at a time, in payee order N−1. The intent is still built after every payee resolves
Stored group open The moderation-grants GetChat leaves hydrateChat for a new refreshViewerState, which openTranscript runs next to loadMessages 1 before the transcript fetch starts
Group profile open refreshRules and refreshCover run side by side 1
PaymentRequests.loadChat OPEN and RESOLVED first pages are fetched together, after the stored copies hydrate 1
Edit featured groups The joined-group feed starts before the featured list and its covers load, and is cancelled if that load fails 1–2
Cancel transfer (history and details) The feed check and the balance refresh are separate launches 1
Phantom checkBalances The USDC balance read starts while SOL is checked 1 public RPC
Push posting Name substitutions resolve concurrently, and title and body together; {n} is still replaced in order 1 per unknown name

Failure handling stays structured: one failed call cancels the rest and fails the operation as the sequential code did. Two differences:

  • In pay and loadChat, when more than one call fails, the error returned is whichever fails first rather than the first in the list.
  • checkBalances still judges SOL first, so a wallet short on both reports InsufficientSol. The Solana driver uses blocking OkHttp execute(), so cancelling the USDC read doesn't stop a request already sent. The short-SOL and SOL-error results can wait until the USDC response arrives. Both reads start together, so that wait is only how much longer USDC takes than SOL.

hydrateChat now returns Stored without a round trip, which its KDoc already said.

New tests cover the token build (order, a dropped account, concurrency), concurrent payee resolves, refreshViewerState and the stored-group open, the featured-groups feed starting early, and InsufficientSol winning when the USDC read fails.

Left alone on purpose: transaction and intent building, account provisioning, SharedStatePayment.price (amounts must share one verified state), E2EE key fetches, and feed catch-up, which loads one chat at a time by design.

updateTokens awaited the batched GetMintMetadata before it sent
GetAccounts, though neither reads the other. Only the per-account build
needs the warm cache. Split TokenController.fetchTokenAccounts into
fetchPrimaryAccounts and buildTokenBalances so the coordinator can send
both RPCs together and join the refresh before building.

Every balance refresh (poller, reconnect, onBalanceUpdateRequested) saves
one round trip. A failure in either call still ends the refresh as before.
buildTokenBalances looked up each account's metadata one at a time. Behind
the coordinator's warm cache that is a memory read, but on a fresh login
refreshAllMetadata has no known mints, so every token cost a Room read and
a GetMintMetadata round trip in turn before the first balance showed.

Run the lookups concurrently, at most 8 at once, keeping the account order.
An unresolved mint is still dropped.
SharedStatePayment.pay resolved each payee's owner key with its own
Resolve RPC, one after another, before building the intent. A group send
to N people waited N round trips. The lookups are read-only and nothing
reads one from another, so run them concurrently (at most 8 at once) and
keep the payee order the multi-transfer zips against.

Intent construction is unchanged and still starts only after every payee
resolves. One difference: when several payees fail, the error surfaced is
the first to fail rather than the first in the list.
…grants

hydrateChat awaited a GetChat for every stored group to refresh the
moderation grants, and openTranscript only started GetMessages once that
returned. The grants write touches only the moderation columns and
loadMessages never reads them, so opening a stored group paid one extra
round trip before the transcript fetch began.

The refresh moves out of hydrateChat into refreshViewerState, which the
screen launches next to loadMessages. hydrateChat now returns Stored
without a round trip, as its KDoc already said.
…ofile open

Opening the profile of a group the viewer isn't in, with no cover stored,
issued refreshRules and then refreshCover. Each is its own GetChat that
writes one column and swallows its failure, so the second waited a full
round trip for nothing. They now run side by side.
…ently

loadChat fetched the OPEN first page and then the RESOLVED first page.
They share no cursor and neither reads the other, so the second waited a
round trip for nothing. Both now run under one coroutineScope after the
stored copies are hydrated; either failing cancels the other and fails
the load as before. When both fail, the error returned is whichever
arrives first rather than always OPEN's.
loadCandidates waited for the featured-groups read, and the cover
GetChat for each featured group without one, before it asked for the
joined group feed. The feed reads nothing from that result; it is only
merged in at the end. It now starts first and is awaited after the
featured list is in. A failed featured read cancels it, and the screen
shows the failure as before.
…a cancel

After a cancelled transfer both transaction screens checked pending feed
rows (a Room query plus GetNotificationsById) and then refreshed token
balances. The two write separate data and each returns its failure as a
Result, so the balance refresh no longer waits for the feed check.
checkBalances read the SOL balance, checked it, and only then read the
USDC token account: two independent public-RPC round trips in a row on
every Phantom buy or deposit. The USDC read now starts first and is
awaited after the SOL check. SOL is still judged first, so a wallet short
on both still reports InsufficientSol, and every early return cancels the
pending USDC read. These are balance reads only; transaction building in
this controller is unchanged.
Posting a push resolved each title substitution, then each body
substitution, one at a time. A name missing from the local cache is a
GetProfile round trip, so a push naming two unknown users waited on two
fetches in a row before it could post. The substitutions in a string, and
the title and body, now resolve concurrently; the {n} placeholders are
still replaced in index order. A failure still drops the post, as it did.
@bmc08gt bmc08gt self-assigned this Nov 3, 2026
@github-actions github-actions Bot added area: payments Payments, transfers, intents, billing area: network gRPC, connectivity, API, exchange rates area: onramp Deposit, purchase, Coinbase, fiat on-ramp area: notifications Push notifications, in-app messaging area: tokens Token accounts, balances, token info area: deeplinks Deep link handling, URL routing, and link parsing labels Nov 3, 2026
@bmc08gt
bmc08gt merged commit 34b5814 into code/cash Nov 3, 2026
5 checks passed
bmc08gt added a commit that referenced this pull request Nov 3, 2026
…ee-pane

* origin/code/cash: (44 commits)
  refactor(chat-list): move the chat list into a KMP module and wire it into the desktop graph (#1950)
  build(deps): bump dev.zacsweers.metro from 1.4.5 to 1.5.1 (#1901)
  feat(flipcash): add return-payment contract scaffolding (#1949)
  refactor(chat-ui): make shared/chat-ui a KMP module (#1934)
  build(deps): bump com.google.firebase:firebase-bom from 34.19.0 to 35.1.0 (#1900)
  build(deps): bump io.ktor:ktor-network from 3.4.1 to 3.6.0 (#1909)
  build(deps): bump rinku from 1.6.0 to 1.9.0 (#1907)
  build(deps): bump androidx.webkit:webkit from 1.17.0 to 1.18.0 (#1906)
  build(deps): bump agp from 9.4.1 to 9.5.0 (#1905)
  build(deps): bump androidx-datastore from 1.2.1 to 1.3.0 (#1904)
  build(deps): bump compose-multiplatform from 1.12.1 to 1.12.2 (#1903)
  build(deps): bump com.google.android.gms:play-services-base (#1902)
  build(deps): bump okhttp from 5.5.0 to 5.5.1 (#1899)
  refactor(messenger): split ChatViewModel's reducer into per-area files (#1933)
  refactor(amount-entry): make shared/amount-entry a KMP module (#1932)
  fix(build): stop KMP host tests building release variants and debug manifests changing per commit (#1948)
  refactor(theme): collapse Avenir into one family with a real Medium face (#1947)
  perf: run independent hot-path calls concurrently (#1945)
  docs(claude): test gestures with device input and keep mode state at screen level (#1943)
  build: bump Flipcash to 2026.11.2
  ...

# Conflicts:
#	CLAUDE.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: deeplinks Deep link handling, URL routing, and link parsing area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging area: onramp Deposit, purchase, Coinbase, fiat on-ramp area: payments Payments, transfers, intents, billing area: tokens Token accounts, balances, token info

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant