Skip to content

perf(streams,tasks,chat): cut storage row writes to legacy parity on the streaming hot path - #2191

Merged
mattzcarey merged 5 commits into
mainfrom
perf/streams-storage-ops
Sep 1, 2026
Merged

perf(streams,tasks,chat): cut storage row writes to legacy parity on the streaming hot path#2191
mattzcarey merged 5 commits into
mainfrom
perf/streams-storage-ops

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #2173: an audit of every storage row write on the Streams/Tasks/chat paths. Two rounds: first cutting table-row writes to exact parity with the pre-capability chat pattern, then measuring the billed metric (rowsWritten, which counts index maintenance — total_changes() doesn't) and restructuring the DDL around it. Behavior is unchanged — all 3,509 tests across agents/ai-chat/think pass (test edits are tighter assertions plus one new accounting test).

On DO SQLite, rows written cost ~1000× rows read, so every trade converts writes into (few) reads. Explicit transactions don't reduce billed writes — each synchronous block is already one atomic commit; the billed unit is the row write itself.

Round 1: the append fence becomes a read

The old fence was a guarded count-bump UPDATE on the stream row — one extra row write per append buying settled-write rejection, the cursor, and updated_at. All three are derivable:

  • A Durable Object executes one synchronous block at a time, so #append's state-check + chunk-tail read + INSERT is exactly as atomic as the guarded UPDATE (the invariant is stamped on the method: nothing between the fence read and the INSERT may await or run user code; serialization runs first because toJSON() can re-enter).
  • The chunk log's tail is the live cursor and liveness signal, derived through one #tail helper (writer cursor, status(), list(), sweep verification).
  • Settlement stamps the final cursor in the settle UPDATE it already writes, so terminal rows read straight through.

readBatches terminates on an empty poll observed terminal in the same synchronous block, and its liveness checks read one column, not SELECT * including metadata.

Chat adapter: two-phase retention sweep (coarse updated_at cutoff, then one indexed chunk-tail read per candidate — an actively appending stream is never swept, a quiet sweep reads zero chunk rows); migration imports rows complete (1+N writes instead of 1+2N, and imported terminal rows carry their exact cursor — pinned by the migration test); destroy() no longer flushes chunks it deletes in the same call; the cleanup alarm re-arms off the sweep's survivor count instead of a second table scan; the write-only _segmentIndex field is deleted.

Tasks: claim refreshes amortize to one run-row write per 15s (half the claim slack) of accumulated step time instead of one per step — a policy-respecting step still can't outlive its claim; already-elapsed sleeps journal born-completed in one INSERT; startup reconcile skips job-queue upserts that already match (an unchanged upsert is still a billed write), with one rearm() for a lost alarm; parked-run cancel settles in one row write; identical status() messages skip their write; settle paths only re-sync the wake mirror when their fenced write actually landed.

Round 2: bill one row per hot-path write

Measured empirically in workerd and pinned by the new write-accounting-probe test: an ordinary rowid table's PRIMARY KEY is a hidden UNIQUE index, so its INSERTs bill 2 rows; WITHOUT ROWID makes the PK the table and bills 1; each touched explicit index adds one; untouched indexes are free.

  • All five capability tables (streams, stream chunks, task runs, task steps, jobs — none released, so DDL is free to change) are now WITHOUT ROWID. A chunk append bills exactly one row, asserted per-statement.
  • The task runs table drops its (state, next_at) index — it taxed every claim, refresh, and settle write to speed one startup scan of a retention-bounded table. The definition index stays (never touched by run updates; list-by-definition scales with retained runs).
  • The aperture's rowid ordering tiebreaks became stream_id, matching public list().

Memory

  • Chunk replay is now a generator over paged reads (readChunks replaces the aperture's unbounded readAll): a reconnecting client holds one page of segments, not the whole stored turn.
  • The task engine adds two closure variables per live attempt; the adapter drops a field. Reader loops stop materializing full stream rows (metadata included) per wakeup.

Accounting (in-suite, real DO SQLite, 20 turns × 100 chunks)

per 100-chunk turn before this PR after
table rows written (bench metric) 22 12 — exact legacy parity, asserted with toBe
billed rows written (rowsWritten) 34 13 (legacy shipped schema: 33)
naive per-chunk table rows 202 102 (adapter 8.5× under)
sweep rows read 239 → 40 239 → 42 (two-phase modeled honestly)

So on the billed metric the streaming path is now ~2.6× cheaper than what merged in #2173 and ~2.5× cheaper than the original pre-streams chat.

Declined / deferred (from the same audit)

  • New read-serving indexes (created_at for list(), partial state index): each costs +1 billed row per insert/delete to save reads — the wrong trade on this axis.
  • Coalescing mid-turn stash writes / lazy initial snapshots in chat/think turn persistence: real write savings, but they shift crash-recovery windows; candidate follow-up if that nuance is acceptable.
  • Bigger packed segments (write count scales inversely): a durability-cadence policy change, left at the shipped 10-chunk default.
  • readBatches returning terminal status (saves a per-connection read in sseResponse): public API change, per-connection cost only.

Verification: agents 1967/1967, ai-chat 655/655, think 887/887 (downstream suites against rebuilt dists), typecheck 117 projects, sherif/exports/oxfmt/oxlint clean. design/rfc-streams.md records both the fence/authority move and the billed-write model in its evolution section.

…the streaming hot path

Streams' append fence becomes a read: a Durable Object executes one
synchronous block at a time, so state-check + chunk-tail read + INSERT is
exactly as atomic as the old guarded count-bump UPDATE — and one row write
per append instead of two. The stream row is written only at open and
settle; settlement stamps the final cursor, and live cursors/liveness
derive from the chunk log through one #tail helper. Reader loops use
narrow state reads and terminate on an empty poll observed terminal in the
same synchronous block.

The chat adapter's retention sweep decides abandonment in two phases
(coarse row cutoff, then one indexed chunk-tail read per candidate), the
legacy migration imports rows complete (final count and last activity up
front; chunk imports are bare INSERTs — 1+N writes instead of 1+2N),
destroy() no longer flushes chunks it deletes in the same call, the
cleanup alarm scans the table once, and the write-only _segmentIndex
field is gone.

Tasks amortizes claim refreshes to one write per half claim-slack of wall
time instead of one per step, journals already-elapsed sleeps
born-completed in a single INSERT, skips startup job-queue upserts that
already match the run's deadline, settles a parked-run cancel in one row
write, dedupes identical status writes, and only re-syncs the wake mirror
when its settle write actually landed.

The in-suite benchmark now pins exact adapter/legacy write parity
(240 vs 240 rows for 20 turns x 100 chunks; previously 440) and models
the two-phase sweep (239 -> 42 rows read).
@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3783b30

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Patch
@cloudflare/agent-think Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot 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.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

@pkg-pr-new

pkg-pr-new Bot commented Sep 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2191

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2191

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2191

hono-agents

npm i https://pkg.pr.new/hono-agents@2191

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2191

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2191

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2191

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2191

commit: 3783b30

…THOUT ROWID tables; bound replay memory

Cloudflare bills rowsWritten, which counts index maintenance — and an
ordinary rowid table's PRIMARY KEY is a hidden UNIQUE index, so every
chunk append billed 2 rows while total_changes() (the parity benchmark's
metric) reported 1. Measured empirically in workerd and pinned by a new
write-accounting test: rowid composite-PK insert = 2, WITHOUT ROWID = 1,
each touched explicit index +1, untouched indexes free.

All five capability tables (streams, stream chunks, task runs, task
steps, jobs — none released) go WITHOUT ROWID; the aperture's rowid
ordering tiebreaks become stream_id. The task runs table drops its
(state, next_at) index — a billed tax on every claim, refresh, and
settle, paid only to speed the startup reconcile's one scan of a
retention-bounded table. A 100-chunk chat turn now bills 13 rows vs 33
for the legacy schema (and 34 for the capability shape this PR started
from).

Replay memory is bounded too: the chat adapter's chunk replay becomes a
generator over paged reads (readChunks replaces the aperture's readAll),
so a reconnecting client holds one page of segments instead of the whole
stored turn.
devin-ai-integration[bot]

This comment was marked as resolved.

The snapshot pins the verbatim sqlite_master text; the WITHOUT ROWID
change altered three tables' stored DDL. The templates now end exactly
at the ROWID keyword so the stored text stays clean of trailing
whitespace.
…inistic newest-first ordering

Review flagged that WITHOUT ROWID on cf_agents_streams dropped the rowid
insertion-order tiebreak: same-tag rows sharing a created_at millisecond
(a retried turn's shape) would order by random nanoid, so recovery's
latest-row lookups could pick an older turn. rowid IS the right
tiebreak, and its hidden-index cost lands once per stream open — per
turn, never per chunk — so the metadata table stays a rowid table while
the chunk log keeps the WITHOUT ROWID billing win. The aperture queries
get their rowid tiebreak back, a new test pins three same-tag
same-created_at rows to insertion order, and the write-accounting test
now documents the deliberate 3-billed-row stream open.
@mattzcarey
mattzcarey merged commit b40bc5b into main Sep 1, 2026
19 checks passed
@mattzcarey
mattzcarey deleted the perf/streams-storage-ops branch September 1, 2026 10:27
@github-actions github-actions Bot mentioned this pull request Sep 1, 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