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.
| "mellea.error.detail": ev.detail, | ||
| }, | ||
| ) | ||
| elif isinstance(ev, CompletedEvent): |
There was a problem hiding this comment.
IIUC there is a subtle change here - moving the completed event from streaming_end to the CompletedEvent changes full_text_length in cases of exceptions.
Previously it was payload.full_text_length = len(accumulated) which is now len(ev.full_text).
Not a bug per se but it looks like finish_streaming_span still uses payload.full_text_length = len(accumulated) so we may end up with two different lengths for what I think should be the same metric.
There was a problem hiding this comment.
Adding to this: it's actually two different attribute names, not one name with two values. tracing_plugins.py sets mellea.streaming.full_text_length (lines 374 and 401), while finish_streaming_span in tracing.py:841 sets mellea.full_text_length — no streaming. segment. So a dashboard or query keyed on one name won't pick up data recorded under the other at all, on top of the value mismatch you already flagged.
There was a problem hiding this comment.
actually full_text_length= len(accumulated) was the bug here, I fixed it in c6a130a so the value match now
I also updated the attr name to mellea.streaming.full_text_length along with all the attrs on that span
There was a problem hiding this comment.
Do we need this anymore after the refactor? I don't see it being used anywhere.
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.
| await self._done.wait() | ||
| if self._finalized: | ||
| return | ||
| self._finalized = True |
There was a problem hiding this comment.
self._finalized flips to True here, before the try/finally that awaits self._mot.aclose() and then fires CompletedEvent/STREAMING_END. If a CancelledError lands during either of those two awaits inside the finally block, STREAMING_END never finishes firing — and since the guard is already True, nothing (not even aclose() calling _finalize() again) will retry it. That leaks the stream span (never popped from the in-flight registry, OTel token never detached) and the metrics plugins that key off STREAMING_END never run. I reproduced this with a slow streaming_event subscriber plus an external cancellation — STREAMING_END fired 0 times. The old code had an explicit shield against exactly this.
asyncio.shield() around the whole cleanup body fixes it — I tested this pattern in isolation and confirmed the shielded work keeps running to completion even though the caller sees CancelledError:
async def _finalize(self, *, success=False, error=None, full_text_length=0) -> None:
if self._finalized:
return
self._finalized = True
await asyncio.shield(self._do_finalize(success, error, full_text_length))
async def _do_finalize(self, success, error, full_text_length) -> None:
try:
await self._mot.aclose()
finally:
await _emit_event(...)
if has_plugins(HookType.STREAMING_END):
...There was a problem hiding this comment.
I had Claude draft a explanation for this after we put a simpler fix in c6a130a:
Good catch on the teardown window — fixed, though not with
shield().shield()runs its body in a new task, but thestreamspan's OTel context token is task-affine (finish_streaming_span→_detach_tokenmust detach on the task that attached, per thestart_application_spandocstring). Shielding would detach on the wrong task and corrupt the context on the normal path — trading a rare leak for a guaranteed bug, and re-introducing the cross-task boundary this refactor removed.Instead I nested a
try/finallysoSTREAMING_ENDfires even if theCompletedEventemission is cancelled or raises, all on the caller's task — the token detaches correctly and theCancelledErrorstill propagates, so anasyncio.wait_fortimeout still surfaces asTimeoutError. The single-cancel path (the one the PR advertises) was already clean; this closes the double-cancel-mid-teardown window you reproduced with the slow subscriber.On the raising-subscriber variant: the
STREAMING_ENDsubscribers are ours and mode-isolated (StreamMetricsPluginisFIRE_AND_FORGET, so cpex never propagates its errors; the span-closer is our own code), and a third-partystreaming_eventhook that raises is the subscriber's own bug — I'd rather it surface than be silently swallowed. So no blanketexcept BaseExceptionlike the old orchestrator had.
There was a problem hiding this comment.
Thanks — the nested finally now reaches STREAMING_END if cancellation interrupts _mot.aclose() or CompletedEvent emission. However, _finalized is set before teardown awaits begin. If cancellation arrives while invoke_hook(STREAMING_END, ...) is in flight, terminal dispatch is interrupted; later aclose() or _finalize() calls return without retrying it.
Reproduced against _finalize() with a blocking terminal dispatcher. Existing cancellation tests cover only a single cancellation and do not exercise cancellation during terminal-hook delivery.
Suggest adding a regression test and ensuring STREAMING_END completes reliably under cancellation during teardown.
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?
| ) -> 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?
| 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...?
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?