Skip to content

perf(chat): rebuild the conversation list with one preview read and one stamp read - #1978

Open
bmc08gt wants to merge 1 commit into
code/cashfrom
perf/chat-feed-from-metadata
Open

bmc08gt wants to merge 1 commit into
code/cashfrom
perf/chat-feed-from-metadata

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Every message write rebuilt the conversation list with three kinds of read: getLatestVisibleForAllChats through the preview stream, the same query again inside buildFeedFromDb, and getUnreadSeq once per chat for the READ pointer's stamp. At 1,000 chats that was 1,000 stamp queries per incoming message.

  • Preview query from chat_metadata. It took its chat ids from SELECT DISTINCT chat_id_hex FROM chat_messages, which walks an index over every stored message. It now reads them from chat_metadata. The list only builds from metadata rows, so a chat with messages but no metadata row was never shown; the query now leaves it out too.
  • No second preview read. buildFeedFromDb uses the previews the stream already emitted. Until the stream's first emission it reads them itself, so first paint is unchanged.
  • One stamp read. getUnreadSeqs reads every row's stamp in one statement per 400 keys: a VALUES list joined to chat_messages on its primary key. 400 keys is 800 bind variables, under the 999 of the platform SQLite at minSdk.

The paged feed and stageReadStamps still read one stamp at a time. They run once per sync, not once per message.

The preview stream now also re-runs on chat_metadata writes, because Room invalidates by table. The re-run is cheap now, and distinctUntilChanged stops it from rebuilding the list. One difference from before: a chat_metadata write can reach combine ahead of the preview stream's re-run, so one build can pair that chat's new metadata with its previous preview. The next emission corrects it, and the settled list is the same as before. code/cash never did this because it re-read the previews on every build.

Numbers

ChatArrivalBenchmarkTest.arrival, 1000 chats × 1000 messages, host JVM. Second (warm) run of each scenario, from the write to the last observer settling:

Scenario code/cash This branch
(a) message in the open chat 315 ms 39 ms
(b) message in another chat 422 ms 50 ms
buildFeedFromDb alone, (a) 315 ms 25 ms

The harness doesn't call FeedSyncDelegate. It models buildFeedFromDb with DAO calls, so on this branch that model was changed to match the new code: no second preview read, one getUnreadSeqs instead of 1,000 getUnreadSeq. The "this branch" column measures that model. Both runs used the same machine, back to back, with load average between 9 and 16.

Tests

  • FeedObservedPreviewTest: the list built from streamed previews equals the one built from a direct read, a preview change doesn't query the previews again, and every row's stamp comes from one getUnreadSeqs call.
  • ChatMessageDaoTest: a chat with no metadata row is left out, getUnreadSeqs agrees with getUnreadSeq across more keys than one statement takes, and its plan uses the primary key.
  • Six feed tests now stub getUnreadSeqs, giving each pointer a stamp equal to its id. Five of them stubbed getUnreadSeq that way already; FeedDeletedMessageTest had relied on the relaxed mock's default.

…ne stamp read

Every message write rebuilt the list with three kinds of read:
getLatestVisibleForAllChats once through the preview stream, the same
query again inside buildFeedFromDb, and getUnreadSeq once per chat for
the READ pointer's stamp.

- The preview query now takes its chat ids from chat_metadata instead of
  SELECT DISTINCT chat_id_hex FROM chat_messages, which walked an index
  over every stored message. The list builds only from metadata rows, so
  a chat whose messages have no metadata row was never shown; the query
  now leaves it out as well.
- buildFeedFromDb uses the previews the stream already read. Until the
  stream's first emission it reads them itself, so first paint is
  unchanged.
- getUnreadSeqs reads every row's stamp in one statement per 400 keys, a
  VALUES list joined to chat_messages on its primary key. 400 keys is
  800 bind variables, under the 999 of the platform SQLite at minSdk.

The paged feed and stageReadStamps still read one stamp at a time; they
run per sync, not per message.

FeedObservedPreviewTest checks the streamed and direct builds produce the
same list and that a preview change does not query the previews again.
ChatMessageDaoTest covers the metadata-less chat, getUnreadSeqs against
getUnreadSeq across more keys than one statement takes, and the query
plan's primary-key lookup.
@bmc08gt bmc08gt self-assigned this Nov 7, 2026
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