Repository navigation
Client streams: the client as a streaming edge into a running request (full duplex 3/6) - #593
Conversation
A model can declare a stream the client feeds while its request runs (Connection(from_partition=CLIENT, ...)). /generate/ws opens a live call, takes append and end_input messages for it, and aborts a closed socket's calls; the preprocess thread turns each append into stream items with Model.process_stream_chunk and sends CLIENT_STREAM_INPUT to the conductor, which routes the chunks to the consuming partition's worker and holds them for a call still waiting for a slot. This is #593's tree on top of the outbox PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4e0efe3 to
0adc9dd
Compare
…nd_input A model with a client stream treats a socket request without end_input as a live call, so a one-shot that carries its clip in files but no end_input waits for appends until it times out (or, without a request_id, is refused). Document that its opening message carries end_input: true, in clients.rst and the nemotron_duplex notes. The nemotron test clients already do; the protocol itself is #593's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0adc9dd to
b29b06f
Compare
A model can declare a stream the client feeds while its request runs (Connection(from_partition=CLIENT, ...)). /generate/ws opens a live call, takes append and end_input messages for it, and aborts a closed socket's calls; the preprocess thread turns each append into stream items with Model.process_stream_chunk and sends CLIENT_STREAM_INPUT to the conductor, which routes the chunks to the consuming partition's worker and holds them for a call still waiting for a slot. The preprocess queue stays FIFO, as on main: a pass takes up to MAX_APPENDS_PER_PASS items in arrival order and stops at the first new request. Before the thread processes a queued opening or append it asks the API server whether that submission is still pending (APIServer.is_pending); if not, it drops the item unsent and deletes an append's files. An abort that lands after the check goes out behind what the thread just sent, from the same thread and outbox, and the conductor tears the request down as usual. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b29b06f to
95819a8
Compare
Automated review🟡 No findings on The previous run stopped before posting any findings, so this is effectively the first review. Description
Questions
What the review checked
Not reviewed: Advisory, not a merge gate. Rules: |
The conductor sizes a client's max_pending_items room from the consumer's consumed count, which only reached it on WORKER_GRAPHS_DONE. A consumer reading its client stream inside a dynamic loop (nemotron_duplex's encode_loop) sends that only when the loop ends, so a well-behaved live call was refused (or, under keep_newest, starved) once it had sent max_pending_items items in total. The worker now sends a CLIENT_STREAM_CONSUMED message after a step whose consumed count moved, for client streams that have a max_pending_items bound only, and skips rids whose WORKER_GRAPHS_DONE already carried the count this step. One columnar message per step (request ids, edge names, running counts), from the lockstep leader only; the count last reported lives on the StreamBuffer, so there is no new handle-keyed state. Models without a paced client stream pay one check per step. The send sits in Worker._stream_and_send, above the graph runtime, so the Python and Rust runtimes behave the same and the WORKER_GRAPHS_DONE frame Rust builds is unchanged. The conductor folds the report in through the same path as WORKER_GRAPHS_DONE's counts, and sends a keep_newest item held for room. Tests drive a real graph runtime (Python, and Rust when built) through a looped consumer and the real Worker._stream_and_send into the real Conductor: a call that sends three times its limit while being consumed is not refused, and a consumer that stops reading still gets its client refused. The existing limit tests now use the per-step report rather than a WORKER_GRAPHS_DONE that finished the worker graph on every step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Re the automated review:
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # mstar/api_server/entrypoint.py # test/modular/test_generate_ws.py
…passes its turn first Two fixes to the merge of main's ordered submission (#787) with live calls: - An append task looked up live[request_id] when it ran, not when it was read, so an append queued behind a refused opening raised KeyError once the opening's task had removed the call (no reply), and one queued across a call's end could feed a new call reusing the id. Each append now waits on the call it named when it was read. - A refused message passed its turn only after sending its error, which waits on the socket: a client not reading yet held every later message (test_refusals_never_hold_up_reading[failed-call-append]). The turn is now released before the send, as the call's future already was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gaurav-Shah05
left a comment
There was a problem hiding this comment.
Overall good, and should be ready after the bug below is fixed.
Two things for follow-ups:
-
One append is one stream item of any length, so appends under 80 ms fall behind, and the scheduler can't tell a 20 s upload from an 80 ms append. The precedent from other engines is cutting the client audio into model frames as it arrives. Stream items in the model's own units would fix both.
-
Other engines also serve duplex on the OpenAI Realtime API, where we only have
/generate/ws. Main'snemotron_duplex/realtime_api.pystill runsDuplexStreambeside the engine, and a thin/v1/realtimeadapter over client streams would let that file go.
| # Chunks a call still waiting for admission may send before it is turned | ||
| # away with a 503, so a full deployment can't hold calls' input without | ||
| # bound. Counted in appends, whatever their length (20 s of 80 ms ones). | ||
| MAX_HELD_CLIENT_CHUNKS = 256 |
There was a problem hiding this comment.
at max_concurrent_requests, a live call holds up to 256 appends (20 s at 80 ms) until it gets a slot, so it starts and stays that far behind. The runs in #770 leave no headroom at 32 calls, so I think a call with open input should be refused at that cap.
| max_items: int | None = None | ||
| # Items (not item_size units) the consumer has been sent and not consumed | ||
| # yet, at once. | ||
| max_pending_items: int | None = None |
There was a problem hiding this comment.
No model or config in the stack sets max_pending_items, so refuse, keep_newest, the held waiting item and the per-step CLIENT_STREAM_CONSUMED reports only run in tests. Can you leave them out until the first model needs a bound on pending input?
…ax_pending_items At the cap, a live call held up to 256 appends (20 s at 80 ms) until it got a slot, so it started, and stayed, that far behind its client. The opening now carries its own client input on NEW_REQUEST (its first chunk and the streams it ends), so the conductor can tell a live call from a one-shot when it arrives: a call that leaves any stream open gets a 503, and a one-shot queues as before. Nothing is held for a queued request any more, so the held-chunk map and its cap go. No model or config sets max_pending_items, so refuse, keep_newest, the held waiting item, the per-step CLIENT_STREAM_CONSUMED reports and the chunk policies' max_window_size check go until a model needs a bound on pending input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: - mstar/model/base.py: kept this branch's ClientChunk; dropped PrefixStream, which main replaced with KVLabel/PrefixKeys (#832/#834). - mstar/api_server/data_worker.py: imports take ClientChunk plus main's PrefixKeys/keyed_labels; this branch's _load_media refactor kept with main's _prefix_streams -> _keyed_labels rename; _finish_reading drains when the rid is already draining (this branch) or still has a failed read's transfers in flight (main #419), docstring merged. - mstar/api_server/entrypoint.py: PendingRequest takes main's output_totals (#844), which counts outputs as they are accepted; this branch's `delivered` counter and _count_outputs, the same fix for the same leak, are deleted. Stream drain keeps this branch's last_activity read with the swap-out; the done path clears req.chunks as main does. /generate/ws: main's empty-id-as-None and check_request_id (#618) move into the receive loop beside this branch's isinstance check, since live-call routing reads the id there; the error reply keeps this branch's call/turn release with main's client_error_text scrubbing (#646), under this branch's `payload` name. - mstar/conductor/conductor.py: imports from both; worker graphs from main's validate_deployment (#640), then this branch's _find_client_streams. - test/modular/test_kv_prefix_request_keys.py: kept this branch's get_partition_topology stub and main's kv_labels. Adapted to main (no conflict markers): - test/modular/test_client_stream_api.py: stub renamed prefix_key_streams -> kv_labels; the tensor-manager fake gains take_failed_reads; the bad-append test expects #646's "internal error" text in place of the model's exception; test_a_streaming_request_lets_go_of_the_chunks_it_has_delivered dropped, as main's test_api_result_delivery.py covers the release and the totals. - test/modular/test_request_id_limit.py: the fake server gains takes_stream_input, submission_key and abort_submission, which this branch's /generate/ws reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ack_unread_tensors takes the wire id since #557 and puts it in TensorReceived; passing the worker-local handle named the wrong (or no) request on the producer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failure forwarding a client chunk (conductor) or feeding a ws append (API server) sent the raw exception text to the client. Route both through #646's client_error_text like main's other error sites: a ValueError keeps its (path-scrubbed) message and the 400, anything else reaches the client as an internal error with an error id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bad client_streams: block, max_call_input_mb or client-stream declaration was raised alone, by the conductor, the API server's init or the data worker's thread, before or beside #640's collected report. check_client_streams (mstar/validation/client_streams.py) now returns them as problems from validate_deployment. Conductor._find_client_streams, client_stream_limits and _call_input_limit only read; the API server and data worker read theirs on first use, after the conductor has accepted the deployment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since d566acd a call with open input is refused at max_concurrent_requests; only one-shot requests queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A ValueError from an append is the client's fault: answer it with its message, paths scrubbed, and log one WARNING line, as the conductor's ingest path does. Anything else still goes through client_error_text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…refusals Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- test_client_error_text.py: _FailingServer gains takes_stream_input, submission_key and abort_submission, which /generate/ws reads. - test_whisper_kv_sizing.py: the conductor's fake request body gains client_input=None, which _ingest_request reads. - api_server_helpers.py: make_test_api_server sets _model_config (None) instead of writing max_call_input_bytes, now a cached_property of it that a real server leaves unset until first use (test_api_server_helpers.py compares the two field sets). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What this does
This is part 3 of 6 of native full duplex (#186): client streams. A model can now declare a live input stream that the client feeds into a running request:
There is one request per conversation:
/generate/ws, appends chunks while it runs, and then ends its input.StreamBufferand chunk policy, the same as a stream from another partition.This PR builds on #766 (the preprocess thread's non-blocking sends). It makes no engine change. It makes two worker changes:
producer_donenow ends only the edges it counts. Every multi-partition model sees this (Orpheus, Qwen3-Omni, Qwen3-TTS, Chatterbox, Cosmos3 windowed). See "Changes every model sees".How it works
mstar/streaming/topology.py):CLIENTis a reserved producer. It is not a partition.PartitionTopology.client_connections().Model.process_stream_chunk(chunk: ClientChunk)turns one append into stream items, keyed by client edge name. AClientChunkcarries:tensorsandinput_metadataby modality, and each file's targetstreams);model_kwargs, and the opening's (request_kwargs).process_promptreturns tensors under a client edge's name, they become the stream's first chunk. So a one-shot upload is a stream of one chunk.entrypoint.py):/generate/wsserves concurrent live calls.{"request_id", "append": {"text"?, "files"?: [{"name", "data", "stream"?}], "model_kwargs"?}}feeds a call. It takes the same fields an opening does."append": [files]is shorthand for{"files": [...]}.end_inputis eithertrue(every client stream of the call) or a list of client stream names (only those). It can be sent alone, on an append, or on the opening message for a one-shot.request_id. Only the socket that opened the call can append to it.status400) and no finish.{"request_id", "append": true, "error"}, and the call goes on.APIServer.stream_inputreturns False for a call that is not taking input.max_call_input_mb,docs/serving.rst)./generate/ws.data_worker.py):CLIENT_STREAM_INPUTto the conductor.MAX_APPENDS_PER_PASS) in arrival order. It stops at the first new request, so output delivery gets its turn between requests.APIServer.is_pending: held, and not finished, failed or aborted)._fail_requestabort, [Data worker][API server][Bug fix] Abort a request when its output fails, and stop at the first error #471).producer_done_items).max_concurrent_requests: an opening that leaves any client stream open (a live call) is refused with a 503, instead of waiting for a slot. A call that waited would start, and stay, as far behind its client as it waited. A one-shot still queues. The opening's own client input (its first chunk and the streams it ends) rides onNEW_REQUEST(NewRequestConductor.client_input) and goes out after admission, so nothing is held for a queued request.get_worker_graphs, because bagel shapes its graphs from the deployment there.process_stream_chunk. A partition may take several client streams. Each one ends on its own (per-edgeproducer_done).ClientStreamLimitson a clientConnection,mstar/streaming/topology.py):max_items, in the model's units (item_size/unit). It is counted in the data worker before anything is stored. A call that goes past it ends with a 400 that names the limit.client_streams:. It can't raise or remove one, because buffers are sized from the model's own bound.producer_doneends only the edges it counts. Before, it ended every stream from that producer. That also closed a co-located consumer's sibling stream, with a count of 0.docs/clients.rst: the protocol.docs/adding_models.rst: declaring a client stream.docs/serving.rst:max_call_input_mbandclient_streams:limits.docs/environment_variables.rst: the Rust frontend's limits.Changes every model sees
PendingRequest.consumed_chunksis gone). With--log-stats, the per-modality output counts come from what it delivered.producer_done: it ends only the edges it counts. The conductor is its only sender. The conductor'sproducer_done_itemsnames every edge between the two partitions that it ends, with 0 for an edge that carried nothing. So the only streams that no longer end are another consumer partition's streams from the same producer on the same worker. The old code closed those with a count of 0.finished_reading(drained=True)for a request the thread is already draining now waits for in-flight reads beforeREADS_DONE./generate/wsrouting: messages are routed by shape (append vs opening). Null fields count as absent. Replies to refusals are sent from tasks, so they never hold up reading.How it was tested
Rebased onto main
d519553db(one commit). The rebase adapted to main's per-edge connection keys and item counts (#616), output-failure aborts (#471), delivery accounting (#551) and media caps (#644). Stub models in main's newer data-worker tests gainedget_partition_topology, which the thread now reads when it is built.On this PR's head
e741cfe90(with #766's fix merged in): CPU only, on a 24-vCPU Xeon Platinum 8488C, Python 3.12.13, torch 2.12.1+cu132, pytest 9.1.1, withmstar_rustbuilt from this tree.ruff check .: clean.OMP_NUM_THREADS=2 python -m pytest <CPU_TEST_PATHS> -p xdist -n 4): 6050 passed, 3 failed, 612 skipped. Mainabdfd5802in the same environment: 6061 / 3 / 612, with the same 3 failures.test_serving_metrics.py::test_refusals_count_from_when_metrics_are_turned_onneedsprometheus_client, which[all]doesn't install (CI installs[metrics]). The twotest_eval_compare_multigpu.py::test_dump_stacks_retries_with_sudo_only_when_askedcases find no processes under nohup + xdist. All three pass on main with the environment fixed.test/modular/test_piecewise_config_signature.pyran outside xdist. It parametrizes over a set, so xdist workers collect it in different orders (on main too). It passes.test/rust/test_rust_wire_frames.pyandtest/rust/test_runtime_parity.py: 193 passed, 1 skipped. The skip istest_a_finished_worker_graph_asks_for_request_info[python]: only the Rust runtime computes that flag, by design.test_data_worker_late_release.pyrun as on main (a thread with nois_pending), next to variants with the API server's pending set wired in.main, so it runs on this PR once the PRs below it merge.On
d566acd52(a live call refused at the cap;max_pending_itemsremoved): CPU only, on macOS (arm64), Python 3.12.10, torch 2.12.1, withoutmstar_rust.ruff check .: clean.test_client_stream_api.py,test_client_stream_conductor.py): 114 passed.test/modularfiles that touch the changed modules, each in its own process, onfb9056948and ond566acd52: every file has the same outcome on both. The failures they share come from the Mac (no CUDA or triton, IPC socket paths longer than macOS allows). Run as one process, the suite segfaults partway through, on both.d566acd52's conductor test run onfb9056948(minus the newclient_inputfield), a live call at the cap is queued, not refused:assert 'b' not in ... and [NewRequestConductor(request_id='b', ...)] == [].GPU, not rerun on this head. The latest GPU run is of the whole stack through #770, at its previously pushed head. That is before
d566acd52and before #766'sff5002e7e. The load run below used 36 slots for 32 live calls, so no live call met the cap. One H100 80GB HBM3, torch 2.12.1+cu132 (CUDA 13.2), against maind519553db, with the correctness gate harnesscorrectness.py, kept outside the repo.interruptionsdiffers at frame 329 (,vs.). This is a bf16 near-tie, and the stack's output is unchanged from the previous revision's run (details in nemotron_duplex: live input over a client stream, cache-aware streaming encoder (full duplex 4/6) #594). Not run this time: encoder parity, served vs standalone, the cuFFT check, listening.end_input: ["audio_in"]ends the call liketrue.client_streams.audio_in.max_items.Earlier revisions
321763dd8), against maind519553db, with the same gate harness.torch.stft/irfft, and the stack's captured regions run no FFT.end_input, append from another socket, the input budget, the session cap, abort mid-call and recovery, 409 on a live id, null fields, upload channel / rate / length refusals).uvx ruff@0.16.0 check .: clean.python -m pytest @test/cpu-core.txt, before main dropped the list): 3004 passed, 255 skipped, 21 failed. The 21 are exactly the set mainfa37d227cfails in the same container (test_e2e_validators,test_flex_attention_resource,test_waypoint_*).test_whisper_kv_sizing.py): 502 passed, 2 failed. The 2 are the ones main fails (test_graph_runtime_factory.py::test_rust_refuses_the_pyzmq_communicatorandtest_kv_prefix_root.py::test_only_a_node_whose_output_is_cached_under_a_digest_is_compiled_dynamic).Performance
The path every model takes. One H200, torch 2.11.0+cu128,
MSTAR_SHM_ARENA=1. The whole stack through #770 at321763dd8, against maind519553db. Fresh server processes, and main run twice to show its own spread. Harnessperf_final.py, phaseregress_llm, kept outside the repo. Qwen3.5-0.8B, stack vs main:/generatec=32: 3960 / 4001 tok/s vs main 3952–4070./generatec=64: 5441 / 4984 vs 4961–5169./generate/wswith 32 sockets: 3753 / 3729 vs 3645–3780.Not rerun after
d566acd52. For a model without client streams it adds one unset field toNEW_REQUEST.Next steps
CLIENT_STREAM_INPUTcontrol message to the conductor, which routes it to the consuming worker.ClientAppendand the file round trip below./generate/wsyet, so live calls need the Python frontend today./generate/wsopening to a client-stream model, withoutend_input, be live by default? (Q5. Today it is, and a one-shot must sayend_input: true.)FailRequests.Stack
🤖 Generated with Claude Code