Streaming callbacks with multiplexed transport - #3931
Open
T4rk1n wants to merge 3 commits into
Open
Conversation
A callback defined as a generator (or async generator) function streams: its yields are pushed to the browser as they are produced, with no keyword. Each yield has the same shape as a regular return and replaces the outputs; yielding dash.Patch gives incremental updates (e.g. LLM token streaming). Streams ride the WebSocket callback transport when active, otherwise the HTTP response streams NDJSON frames. Works on Flask, Quart and FastAPI; synchronous generators are rejected at registration since they occupy a worker for the whole stream. HTTP streams emit a keepalive line every stream_keepalive_interval ms so proxy idle timeouts don't close a working stream.
All of a page's HTTP streams share a single downlink NDJSON connection -- a server-side StreamHub and a renderer-side StreamClient -- instead of one connection per callback, so they no longer count against the browser's ~6-connections-per-host limit, and the downlink resumes from its last sequence on reconnect without dropping frames. This rides the shared-storage pub/sub, so Subscription now exposes (sequence, message) pairs (iter_with_seq / aiter_with_seq, with the plain message iterators built on them) across all backends, letting the downlink resume from a cursor.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
This PR adds streaming callbacks and the multiplexed HTTP transport that carries them across worker processes, built on the backend-agnostic shared storage primitive from #3930.
async defgenerator; eachyieldis pushed to the browser as it is produced (same shape as a normal return;dash.Patchyields apply incrementally, e.g. LLM token streaming). No opt-in keyword — a generator streams by definition. Sync generators are rejected at registration.dash.ctx.shared_storage/app.shared_storage, from Shared storage: backend-agnostic state manager + pub/sub #3930) — a cross-process key/value store + ordered publish/subscribe, used internally here as the broker for streaming.Streaming callbacks
Shared storage (base PR #3930)
The default
LocalSharedStorageelects a single owner process per machine (AF_UNIX socket on POSIX, TCP loopback on Windows — the bind is the lease, re-elected on owner death) and serves the others. A single-process deployment is its own owner and pays no socket overhead. Subscriptions are ordered and replayable: a reconnecting consumer resumes from its last-seen sequence out of a bounded buffer, and a buffer overrun surfaces as an explicit gap rather than a silent loss. Passshared_storage=Noneto disable, or aBaseSharedStoragesubclass/instance to swap the backend (DiskcacheSharedStorage,RedisSharedStorage, ... — see #3930).Architecture — multiplexed streaming
A streaming callback no longer holds its own HTTP connection. Its POST returns a fast ack; its frames are pumped onto a shared-storage topic and relayed over the page's single downlink, routed back to the right callback by
requestId. Because the frames travel through shared storage, the worker that runs a callback and the worker that holds the downlink need not be the same process — shared storage is the broker.flowchart TB subgraph browser["Browser — one page"] cbs["streaming callbacks<br/>(async def generators)"] sc["StreamClient<br/>single downlink per page"] cbs --> sc end subgraph server["Dash server — any number of worker processes"] wa["worker A<br/>runs callback, pumps frames"] wb["worker B<br/>serves the downlink"] end store[("Shared storage owner process<br/>KV + ordered pub/sub<br/>topic per connection")] sc -->|"1 · uplink POST, fast ack<br/>streamConnection = conn + requestId"| wa wa -->|"2 · publish frames, tagged requestId + seq"| store sc -->|"3 · single downlink<br/>streamDownlink = conn, from = seq"| wb store -->|"4 · subscribe, replay from seq"| wb wb -->|"5 · NDJSON frames"| sc sc -->|"6 · route by requestId, apply"| cbsTransport selection (renderer): a streaming callback rides the WebSocket transport when websocket callbacks are enabled; otherwise the multiplexed HTTP transport when shared storage is available; otherwise falls back to today's one NDJSON connection per callback. So nothing changes for apps that don't opt into shared storage.
Scheduler: long-lived streams no longer consume the renderer's concurrent-request budget, and clientside callbacks are exempt from it — a page full of streams no longer starves other callbacks.
Testing
StreamClientrouting, multiplexing, reconnect-from-cursor, keepalive (karma).iter_with_seq/aiter_with_seqsequence-aware subscriptions the downlink resumes from, across all backends (test_stream_hub.py).Notes
dcc.Store).RedisSharedStorage(see Shared storage: backend-agnostic state manager + pub/sub #3930).Follow-up (not in this PR)
StreamClientis written host-agnostic for this; the per-page transport already solves the connection-limit problem.