Skip to content

perf(component): keep heavy message fields in a payload row, and add textOnly - #453

Draft
robelest wants to merge 3 commits into
mainfrom
perf/cheap-message-reads
Draft

robelest wants to merge 3 commits into
mainfrom
perf/cheap-message-reads

Conversation

@robelest

@robelest robelest commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator

Reading messages is often the main Convex cost of an Agent app. Convex bills a read by the whole document (https://docs.convex.dev/production/state/limits) and queries have no projection, so every list, save and context read pays for content, provider metadata, usage and sources it never uses.

  • A message's message, provider options and metadata, usage, sources, warnings and legacy reasoning move to a new messagePayloads row when they add 1 KiB or more over its text. Every read joins it back, so MessageDoc is unchanged and older rows read as before, with no migration.
  • listMessages, listUIMessages and agent.listMessages take textOnly, which returns each non-tool message as its role and text from the row alone.
  • The row records its payload's size, so maximumBytesRead counts each payload a page joins, rows read ahead included, and convex-helpers' own pagination ends, splits and continues the page as it would rows saved whole.
  • Saves stop writing the deprecated parentMessageId, which nothing reads.

Bytes read per call, local backend (precompiled 2026-10-22), same seeded threads before and after. Before has no textOnly, so its text column is excludeToolMessages.

thread (120 msgs) page of 20 text page of 20 context, 100 recent save after it
plain chat (gpt-4.1) 38.6 → 38.6 KB 38.6 → 38.6 KB 193.0 → 193.0 KB 3.6 → 3.6 KB
encrypted reasoning (gpt-5) 189.0 → 190.4 KB 189.0 → 21.3 KB 944.9 → 952.0 KB 18.6 → 1.9 KB
Claude thinking 139.6 → 141.0 KB 139.6 → 21.3 KB 698.0 → 705.2 KB 13.7 → 1.9 KB
Claude tools, 20 to 50 KB results 333.2 → 335.6 KB 85.5 → 24.2 KB 1605.1 → 1567.0 KB 58.7 → 2.5 KB
Gemini grounding 264.8 → 266.2 KB 264.8 → 23.3 KB 1323.8 → 1331.0 KB 26.2 → 2.1 KB
image and PDF 429.9 → 431.3 KB 429.9 → 28.0 KB 2149.3 → 2156.3 KB 2.8 → 2.8 KB

A useUIMessages client subscribed through a 10-step streamed generation (35 KB tool results): 98.6 → 99.1 KB per step, or 46.3 KB with textOnly (page re-runs 553 → 28 KB; the rest is stream deltas).

The 1 KiB threshold keeps plain chat whole. Rows saved before this change stay whole, so reads handle both shapes either way; the threshold only picks one per write. At 512 B, plain chat's text page would drop 38.6 → 20.9 KB, but its full pages and context reads would cost 3% more for apps that never pass textOnly.

Context reads still read every payload: the model needs them. Reading context once per segment and then incrementally is what makes those cheap, and needs nothing more from this storage.

@pkg-pr-new

pkg-pr-new Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@convex-dev/agent@453

commit: 713f739

@ianmacartney

ianmacartney commented Nov 9, 2026 via email

Copy link
Copy Markdown
Member

…ads stay whole

Text lists read message rows alone, prompt context adds what the model reads, and full reads add result metadata. A byte budget counts what each view reads. Every read path returns what a whole row would, and deletes, updates, clones, finalize and pending replaces leave no stray rows. Saves write no empty sources or warnings, and no parentMessageId. Existing tests read messages through the API instead of raw rows.

A payload leaves out the text its row holds, and a model read reads it once. Every message of the bench fixture's thread shapes, and generated messages with several, empty or space-edged text parts between other parts, reads back byte-identical from full, model and by-id reads, clones, updates and rows holding them whole. A row an older extractText wrote reads, clones and updates as its message, and a row whose text no longer matches its message fails to read. The stream recovery write-budget tests size their streams for a message written once.
A message's content moves to messagePayloads, and its provider metadata, usage, sources and warnings to messageRecords, each row present only when it has a field. Text lists read the message row, every context read (prompt context, fetchContextMessages and search) joins the content, and full reads join both, so MessageDoc is unchanged and older rows read as before. Rows record each tier's size, so maximumBytesRead counts what a view joins through convex-helpers' own pagination. Each join is a db.get, one of the 4,096 Convex allows a transaction, so a transaction reads at most 1,024 messages: pages stop there, and getMessagesByIds and search refuse more. A clone batch reads each file once for its refcounts, and stops before the row whose copy would pass a byte or db.get budget. Joins and copies run in bounded batches, under Convex's 1,000 concurrent operations. Rows carry the approval ids their message requests or answers, so an approval reads only its request's content. Updates replace tier rows in place, and every write, a clone included, stores the split shape. listMessages, listUIMessages and agent.listMessages take textOnly. Saves stop writing the deprecated parentMessageId, and empty sources or warnings.

A message's row holds its text, so its payload leaves the text parts' text out, with each part's length, and reads put it back. A message whose row holds no text, as when hidden from search, is stored whole. Every write sets a row's text to its message's text, or leaves it unset when hidden, so rows an older extractText wrote come into line when updated or cloned, and a text view of a row saved whole extracts its text from the message.
@robelest
robelest force-pushed the perf/cheap-message-reads branch from 4749a32 to 713f739 Compare November 9, 2026 22:41

This branch has not been deployed

No deployments
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.

2 participants