feat(streams): rollover block log and atomic stream → session cutover - #2216
Conversation
…ge cutover Compares immutable packed rows, reusable generational slots and mutable rollover blocks on billed rows, write time, page growth, replay, and the cursor a restarted isolate recovers, with an atomic transactionSync cutover to the message row. Numbers logged, invariants asserted.
- The Streams chunk log is mutable rollover blocks: an append grows the open
block row (UPDATE) until 256 KB, then opens the next. Same one billed row
per append; a stream of thousands of chunks is a handful of rows to
delete. Schema v2 folds existing cf_agents_stream_chunks rows into blocks.
- writer.close({ commit, discard }) / error(reason, { ... }) settle, run the
caller's synchronous writes and delete the stream's rows in one SQLite
transaction. Session.__DO_NOT_USE_WILL_BREAK__sync().upsert() is the
matching synchronous message write.
- ResumableStream.complete(id, { persist }) exposes the cutover;
discardCompleted() drops a completed stream's rows once its message is
persisted; start() reclaims completed streams a crash left behind.
- AIChatAgent and Think discard right after persisting (agent-tool child
turns excepted: the parent tails their stored chunks), so the retention
sweep no longer finds completed streams.
🦋 Changeset detectedLatest commit: 42d73a0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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 |
🔴 agents import sizesMeasured 267 runtime imports as minified bundles. The primary size is gzip; raw minified size is included for diagnosis. An existing import growing by more than 10% is marked red. This report is informational.
Compared Changed imports (68)
All 265 current runtime imports
Reported by agent-think[bot]. |
ctx.abort() at each point that matters — before an append commits, right after, during rollover (before/after commit), after persist before discard, and inside/after the atomic cutover — then inspect the fresh instance. Each restart finds either the exact committed prefix or the finished message.
- ResumableStream.finish() leaves a finished stream live until cutover();
cutover() settles it, runs the message write and deletes its rows in one
transaction; finalizePending() settles when nothing is persisted;
reclaim() on every start() replaces the alarm-driven sweep.
- AIChatAgent persists the turn's messages inside the cutover
(persistMessages { _cutover }); Think does the same through
_persistAssistantMessageWithCutover. The session change feed and
auto-compaction run once the transaction commits.
- cleanupStreamBuffers / STREAM_CLEANUP_DELAY_SECONDS removed; the host
_cleanupStreamBuffers callbacks stay as no-ops for persisted alarms.
There was a problem hiding this comment.
Note
This report is out of date. Scroll down for Devin Review's latest report on this PR.
Devin Review found 1 new potential issue.
3 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
| }); | ||
| return streamResult; | ||
| } finally { | ||
| this._resumableStream.finalizePending(); |
There was a problem hiding this comment.
🔴 Failed persistence discards recovery evidence
When message persistence throws, finalizePending() settles the still-live stream in AIChatAgent and Think. Recovery then ignores its only durable response copy.
Prompt for agents
Preserve a pending stream when the cutover's message write fails. AIChatAgent in packages/ai-chat/src/index.ts and Think in packages/think/src/think.ts currently call ResumableStream.finalizePending() after catching or propagating a failed cutover. That changes the transaction's rolled-back live stream to completed without persisting its message, preventing orphan recovery. Only finalize when there was intentionally no message to persist. Leave the stream live when cutover persistence throws, and add failure-path tests for both hosts.
Was this helpful? React with 👍 or 👎 to provide feedback.
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
…er commit - Fold cf_agents_stream_chunks rows into blocks one stream at a time on first touch (paged reads, whole-block inserts) instead of the whole log at startup; drop the table once empty. - A settle with commit/discard is a no-op on a stream already terminal or deleted: commit does not run, nothing is discarded. ResumableStream's cutover falls back to a plain persist in that case. - Terminal, deleted events and reader wakeups fire after the cutover transaction returns, never for a rolled-back commit.
A subclass that overrides persistMessages and calls super with only the messages dropped the internal _cutover option, so the message write and the stream settlement fell back to two commits. The pending cutover now lives on the instance keyed by the turn's message id: the first persist that carries that message runs the cutover, a persist of other messages takes the plain path, and the turn's end clears it. persistMessages' public signature is unchanged.
There was a problem hiding this comment.
Devin Review found 1 new potential issue.
6 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
| this.#sqlWrite("DROP TABLE cf_agents_stream_chunks", []); | ||
| this.#legacyChunkTable = false; |
There was a problem hiding this comment.
🔴 Rolled-back migration hides old chunks
When a v1 stream's first cutover rolls back, #legacyChunkTable stays false although SQLite restores the legacy table. Later reads and appends ignore every old chunk and restart the cursor at zero.
Prompt for agents
Make lazy legacy-table state consistent with transaction rollback in packages/agents/src/streams/streams.ts. foldLegacyChunks can run inside the outer cutover transaction. dropLegacyChunkTableIfEmpty currently mutates #legacyChunkTable before that outer transaction is known to commit. If the caller's commit callback throws, SQLite restores the legacy table but the instance flag remains false. Avoid retaining transaction-dependent schema state in memory, or refresh it after rollback. Add a test that seeds a v1 live stream, runs close({ commit: throwing, discard: true }), then verifies the old cursor and chunks remain visible and a new append continues at the old cursor.
Was this helpful? React with 👍 or 👎 to provide feedback.
What
The stream data and the final session message live in the same Durable Object storage, and the handoff between them is one transaction:
Three changes make that cheap and crash-safe:
cf_agents_stream_chunks(one row per append) becomescf_agents_stream_blocks: an append grows the open block row with an UPDATE until it reaches 256 KB, then opens the next. Still one billed row per append, but a stream of thousands of chunks is a handful of rows, so deleting it is a handful of writes instead of thousands. Schema v2 folds existing chunk rows into blocks on startup.writer.close({ commit, discard })(anderror(reason, { … })) settles the stream, runs the caller's synchronous writes and deletes the stream's rows in onetransactionSync.Session.__DO_NOT_USE_WILL_BREAK__sync().upsert()is the matching synchronous message write; it returns anotify()to dispatch the change feed after the commit.ResumableStream.finish()leaves the row live; the host's final persist then runscutover(): message write, settlement and row deletion in one transaction (persistMessages { _cutover }in AIChatAgent,_persistAssistantMessageWithCutoverin Think). A turn with nothing to persist is settled byfinalizePending().start()reclaims whatever a crash left behind: finished streams immediately, abandoned in-flight rows after an hour without a chunk. No cleanup alarm is armed any more;cleanupStreamBuffersandSTREAM_CLEANUP_DELAY_SECONDSare removed fromagents/chat, and the hosts'_cleanupStreamBufferscallback stays as a no-op so alarms persisted by earlier versions still resolve. Agent-tool child turns cut over without discarding (the parent tails their stored chunks after completion); those rows go at the next start.Why
Measured on a real DO (
src/tests/streams/sqlite-strategies-bench.test.ts, kept in the PR): a 400-chunk chat turn packed 10 per write.Rollover was chosen over slots because it has no shared state: every row belongs to one stream, is addressed by its primary key, and dies with the stream. Slots need a pool that never shrinks (5.6 MB left behind at 20k chunks), an index that doubles the per-flush write, and a free list rebuilt on every restart.
Crash matrix (
ctx.abort()at each point; in-repo assrc/e2e-tests/stream-cutover-crash.test.tsagainstwrangler dev, and reproduced on a deployed DO)completedwith its exact prefix; the nextstart()reclaims itcommitNever neither, never both.
Tests
src/tests/streams/cutover.test.ts: cutover commits message + settle + discard together; a throwingcommitrolls the settle back and the stream stays live; blocks roll over and replay across the boundary;delete()removes every block.getStreamChunkRowCountreports appended segments from the block tail; the alarm-driven cleanup tests became reclaim tests.new Streams({ r2 })#2215) are superseded by this PR.