refactor(streaming)!: replace stream_with_chunking with single-task stream() - #1543
Conversation
…tream() Replace the two-task stream_with_chunking() + StreamChunkingResult with a single-task stream()/Streamer primitive consumed by `async for`, and factor chunk-boundary bookkeeping into a stateful Chunker. stream() returns a Streamer driven by one async generator on the caller's task -- no background orchestration task. Consume it with `async for`, ideally inside `async with` for a guaranteed cleanup contract (aclose()/__aexit__) that cancels an abandoned generation on early break or exception. Typed StreamEvents are emitted through the streaming_event hook rather than a result.events() iterator; acomplete() is removed. Also: - Rename chunking strategies SentenceChunker/WordChunker/ParagraphChunker to SentenceChunking/WordChunking/ParagraphChunking; Chunker now names the new stateful driver. - Add ModelOutputThunk async-iterator API (__aiter__/__anext__) with a single-consumer guard, plus aclose()/async-with cleanup. - Move STREAMING_START into stream() so the stream span correctly parents the backend chat span; remove the cross-task span reattachment machinery. - Update docs, tutorials, and examples to the new API; add a multi-stream events example and an async-iterator example. BREAKING CHANGE: stream_with_chunking(), StreamChunkingResult, and the ...Chunker strategy names are removed with no deprecation shim. See docs/dev/migrate-streaming-v0.8.md for migration. Closes generative-computing#1440 Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
AngeloDanducci
left a comment
There was a problem hiding this comment.
Generally LGTM, a few review comments. I'll leave the larger design discussion to Jake since I think he's been more in the loop on the streaming refactor.
planetf1
left a comment
There was a problem hiding this comment.
Nice simplification overall — dropping the cross-task span-reattachment machinery in particular removes some genuinely hairy code. Four things below are worth fixing before merge; none call the core design into question, they're all narrow and fixable.
planetf1
left a comment
There was a problem hiding this comment.
Two more from a second pass, plus one I've folded into the existing thread on tracing_plugins.py:395 as a reply rather than a new comment since it's building on the same spot.
| Iterate the returned `Streamer` object with `async for` to receive the output | ||
| as validated chunks, ideally inside `async with` so the stream is released on | ||
| every exit. The attributes below track progress and outcome. Instances are | ||
| created by `stream`; do not instantiate directly. |
There was a problem hiding this comment.
Absent my other comments about the multi-chat streaming, I think it's unclear what the chunk actually contains here. Could you please add either an in docstring example or say that it returns string segments of the model output thunk?
There was a problem hiding this comment.
I added a clarifying sentence in c6a130a
There was a problem hiding this comment.
I did some tests with this multi stream events example, and I'm worried that it shows our current approach is actually difficult to utilize.
A few thoughts:
- It is quite difficult to correlate a stream, the events, and the place those events need to go. For instance, I could get the stream id from the streamer and give it to some tui consumer. But I would have to have the plugin route to some queue / map that the tui consumer can grab events with that stream id from.
- What is the preferred pattern if a user is creating and constantly streaming new mots? I feel like that pattern would either require multiple plugins that basically do the same thing (which might be a performance issue); and/or a complicated pattern to route the events to the correct consumer.
- Is it possible to just have the streamer return events as well as mot chunks (or a different function of the streamer that can return the events) allowing the user to choose what to consume?
Additionally, I wonder if we could just register handlers directly to a streamer instead of having to go through the plugin system. Or maybe there's a way to utilize the plugin system to do something similar? After trying to utilize the plugin system to do some things beyond the example below, I'm just worried that it's quite convoluted to handle events from streamers.
There was a problem hiding this comment.
I actually went back and forth on how this should work a few items before settling on this implementation. I am open to further design discussions around it at or after Monday scrum.
If I'm understanding you correctly though this isn't an issue with the current implementation as it stands but the design we chose to implement. If that's the case we could choose to deal with this in a follow up PR before the next release. If you disagree and want to block merge of this PR on that design discussion we can do that too.
Even if we redesign, these hooks would need to stick around for the telemetry at least.
| # Carry forward whatever follows the last emitted chunk. split() may drop | ||
| # inter-chunk whitespace, so locate each chunk by position rather than | ||
| # string-subtracting, then keep the raw suffix as the new pending fragment. | ||
| cursor = 0 | ||
| for c in chunks: | ||
| pos = self._pending.find(c, cursor) | ||
| if pos >= 0: | ||
| cursor = pos + len(c) | ||
| self._pending = self._pending[cursor:] | ||
| return chunks |
There was a problem hiding this comment.
Are we ensuring anywhere that the final mot == what was actually received in chunks from the model even if the chunker edits things during streaming?
I also think that if split drops whitespace, isn't it possible for .find to fail here, messing up _pending?
There was a problem hiding this comment.
I dug into this and part of it was already addressed in a fix suggested by @planetf1 above, I'll include Claudes explanation:
Good instinct — the mutation-via-
find()case is now a hard error:Chunker.feed()raisesValueErrorifsplit()returns a chunk that isn't a verbatim substring of the buffered text, so a normalizing/rewriting strategy fails loudly instead of silently re-emitting. On themotquestion: the finalmotcan't diverge from what the model produced, becausemot.valueis accumulated from the raw deltas directly and the chunker never writes back to it — chunking only shapes the consumer-facingasync forstream. The only place chunk text feeds back into state isfull_texton early exit (accumulated[:emitted_end], located byfind()), and that's covered by the same substring guard.
| def __aiter__(self) -> AsyncIterator[str]: | ||
| """Return the generator that drives generation and yields chunks.""" | ||
| return self._gen |
There was a problem hiding this comment.
Should this get similar handling as mots to prevent multi-consumers?
There was a problem hiding this comment.
I actually asked this myself during my own self review, I'll let Claude explain:
Streamer doesn't need the MOT-style guard, because the failure mode it protects against can't occur here. MOT's
__aiter__returnsselfand re-arms, so a secondasync forwould re-drive and split the stream — hence its explicit guard. Streamer's__aiter__returns the same async generator object (self._gen), which is single-consumer by construction: once the firstasync forexhausts it, a second one just getsStopAsyncIterationimmediately and yields nothing — no split, no re-run,full_text/motintact (verified). So a second iteration is inert rather than dangerous. It's silent rather than loud (you get[], not an error); happy to add an explicit raise if you'd prefer the louder contract, but there's no correctness risk either way.
Add Streamer.completed_normally, a driver-set flag that is True only on natural completion. Unlike `not failed_early`, it is False after an early break, giving callers a correct "did this finish?" signal; docs, examples, and the migration guide now use it, and the stream() chunk contract is documented. Harden _finalize so STREAMING_END fires even if the CompletedEvent emission is cancelled or raises, via a nested try/finally kept on the caller's task. asyncio.shield is avoided: it would detach the task-affine OTel token on the wrong task. Chunker.feed() now raises ValueError when split() returns a chunk that is not a verbatim substring of the buffered text, rather than silently re-emitting; the precondition is documented on split(). Fix a full_text_length regression on the stream span (it recorded the raw accumulated length instead of the emitted-text length main used) and namespace the four stream span attributes under mellea.streaming.*. Remove an unused asyncio import from tracing.py. Gate the hook-observing streaming tests with _cpex_skip so the core streaming suite still runs without the optional hooks extra. Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
|
@AngeloDanducci @planetf1 @jakelorocco I believe I had addressed all your review (except the open design point @jakelorocco raise) and left response on every comment. If you could re-review and mark them as resolved. |
jakelorocco
left a comment
There was a problem hiding this comment.
lgtm; a few minor issues and discussed verbally about next steps
| cursor = 0 | ||
| for c in chunks: | ||
| pos = self._pending.find(c, cursor) | ||
| if pos < 0: | ||
| raise ValueError( | ||
| f"{type(self._strategy).__name__}.split() returned a chunk that " | ||
| "is not a verbatim substring of the buffered text; split() must " | ||
| "not mutate chunk text (see ChunkingStrategy.split)." | ||
| ) | ||
| cursor = pos + len(c) | ||
| self._pending = self._pending[cursor:] | ||
| return chunks |
There was a problem hiding this comment.
Should we also enforce that the chunker always advances? or if not, that it doesn't produce empty chunks? This doesn't seem to impact our chunking strategies but maybe we should include this check to ensure third party chunkers don't fail in these modes?
There was a problem hiding this comment.
added another check for empty strings in 9d9f5ee which together with the existing check catch not advancing
There was a problem hiding this comment.
Good catch — added in 2663b15. feed() now raises ValueError before the find() loop when split() returns an empty string. Without it, find("", cursor) always returns 0, so _pending wouldn't advance and the empty string would be silently emitted as a chunk. The verbatim-substring guard catches mutation but not zero-length — they need separate checks.
The always-advances property follows from the two guards together: a non-empty verbatim-substring chunk strictly advances the cursor. Added test_chunker_rejects_empty_chunk alongside the existing test_chunker_rejects_mutating_strategy.
| ) -> PartialValidationResult: | ||
| _ = chunk, backend, ctx | ||
| return PartialValidationResult("fail", reason="nope") | ||
| @pytest.mark.asyncio |
There was a problem hiding this comment.
I think this marker should be elsewhere / deleted, not on the class?
There was a problem hiding this comment.
_FailOnSecondReq is a Requirement subclass used as a test fixture, not a test class. The marker had no effect and was misleading. Removed in 2663b15.
| for every requirement that returned `"fail"` during streaming. | ||
| failed_early: `True` if a requirement returned `"fail"` during streaming | ||
| and the stream stopped before natural completion. | ||
| completed_normally: `True` only if the stream reached its natural end. |
There was a problem hiding this comment.
Can you please add detail here that completed_normally doesn't prevent the final validation from raising an exception, etc...?
Add an empty-chunk guard to Chunker.feed() so a custom split() that returns empty strings fails loudly; together with the existing substring check this guarantees the buffer always advances. Clarify that completed_normally reflects reaching the stream's end prior to final validation. Remove a stray asyncio marker from a Requirement subclass. Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
|
@jakelorocco and @planetf1 I responded to or addressed your latest review. If you could check one more time. In addition I am investigating the events API after our design discussion during scrum and intend to either open a follow up issue or PR to address it rather than include it in this PR. I'll open that Issue or PR by EOD once I have it properly scoped |
|
Per the design questions in #1543 (comment) and the discuss we had today in scrum I investigated better ways to consume the stream using events rather than output. My initial attempt was to extend Feel free to review that draft and when I return from my trip next week I'll pick it back up and finish it based on feedback. |
A cancellation delivered while STREAMING_END is being dispatched can curtail delivery to the remaining subscribers with no retry, since the _finalized guard is set before teardown. Document this at the guard so it reads as a known trade-off rather than an oversight. Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
|
@planetf1 @jakelorocco @AngeloDanducci unless I hear otherwise I intend to add this to the merge queue after from lunch (in ~2hr) |
058e3dd
Pull Request
Issue
Fixes #1440
Description
Replaces the two-task
stream_with_chunking()+StreamChunkingResultwith a single-taskstream()/Streamerprimitive consumed by a plainasync for, and factors the inline chunk-boundary bookkeeping into a statefulChunker. Builds on the POC in #1409.stream()returns aStreamerdriven by one async generator on the caller's task — there is no background orchestration task. It is consumed withasync for, ideally insideasync with, which provides a guaranteed cleanup contract (aclose()/__aexit__) that cancels an abandoned generation on an earlybreak, an exception, or external cancellation (e.g. anasyncio.wait_fortimeout) — the terminal events fire and the thunk is finalized on every path. TypedStreamEvents are emitted through thestreaming_eventplugin hook rather than aresult.events()iterator, andacomplete()is removed.Key changes:
stream()/Streamer— single async generator on the caller's task; terminal state (failed_early,failure_reason,streaming_failures,full_text,final_validations,mot) lives on the thinStreamerhandle.Chunker— new stateful driver that wraps a statelessChunkingStrategyand holds only the pending fragment between deltas; delta-invariant (any slicing yields the same chunks as onesplit()over the whole text).SentenceChunker/WordChunker/ParagraphChunker→SentenceChunking/WordChunking/ParagraphChunking, freeing theChunkername for the driver. String aliases (chunking="sentence", etc.) are unchanged.ModelOutputThunkiterator API —__aiter__/__anext__wrapastream()in the async-iterator protocol with a single-consumer guard, plusaclose()/async withcleanup.astream()itself is unchanged.STREAMING_ORCHESTRATION_START/_ENDhooks are removed; the span is renamedstream_with_chunking→stream;CompletedEventmoves to thestreaming_eventhook.multi_stream_events.py) and a raw MOT async-iterator example (async-iterator.py); added a migration guide atdocs/dev/migrate-streaming-v0.8.md.Breaking change, no deprecation shim:
stream_with_chunking(),StreamChunkingResult, and the...Chunkerstrategy names are removed. Migration guide:docs/dev/migrate-streaming-v0.8.md.Testing
Rewrote
test/stdlib/test_streaming.pyfor the new API and added theChunkerdelta-invariance suite totest/stdlib/test_chunking.py. Retargeted the telemetry span/metrics tests and the hook-call-site tests to the new topology (streamspan roots thechatspan; events via thestreaming_eventhook). Added the mocked integration twin for the streaming span-topology test so it is covered in the fast tier, not only behind the slow e2e path. Verifiedruff,ruff format, andmypyclean; ran the tutorial example code against Ollama to confirm the documented sample output shapes.Attribution
Adding a new component, requirement, sampling strategy, or tool?