Skip to content

Streaming callbacks with multiplexed transport - #3931

Open
T4rk1n wants to merge 17 commits into
feat/shared_storagefrom
feat/streaming
Open

Streaming callbacks with multiplexed transport#3931
T4rk1n wants to merge 17 commits into
feat/shared_storagefrom
feat/streaming

Conversation

@T4rk1n

@T4rk1n T4rk1n commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Split out of #3888 and stacked on #3930 (shared storage) — this is the streaming half. It targets feat/shared_storage; review it as the diff on top of that PR, and it will be retargeted to dev once #3930 merges.

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.

  • Streaming callbacks — decorate an async def generator; each yield is pushed to the browser as it is produced (same shape as a normal return; dash.Patch yields apply incrementally, e.g. LLM token streaming). No opt-in keyword — a generator streams by definition. Sync generators are rejected at registration.
  • Multiplexed HTTP streaming — all of a page's streams share one downlink connection instead of one per callback, so they no longer hit the browser's ~6-connections-per-host ceiling. Works on Flask, Quart, and FastAPI, no WebSocket required.
  • Shared storage (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

@callback(Output("out", "children"), Input("go", "n_clicks"))
async def stream(n):
    async for token in llm.stream(prompt):
        patch = Patch()
        patch += token
        yield patch

Shared storage (base PR #3930)

app = Dash(__name__)                     # shared_storage=LocalSharedStorage by default
# inside a callback:
dash.ctx.shared_storage.set("k", value)  # cross-process KV (JSON-compatible values)
dash.ctx.shared_storage.publish("topic", msg)

The default LocalSharedStorage elects 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. Pass shared_storage=None to disable, or a BaseSharedStorage subclass/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"| cbs
Loading

Transport 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

  • Streaming transport: uplink/downlink end-to-end over real HTTP on Flask, Quart, and FastAPI.
  • Renderer: StreamClient routing, multiplexing, reconnect-from-cursor, keepalive (karma).
  • Shared-storage broker: the iter_with_seq / aiter_with_seq sequence-aware subscriptions the downlink resumes from, across all backends (test_stream_hub.py).
  • Browser validation (Selenium): the streaming integration suite runs over the multiplexed transport, plus an 8-stream demo where all streams and a clientside clock run simultaneously with a single downlink.

Notes

  • Shared-storage values must be JSON-compatible (like dcc.Store).
  • The default in-memory backend is not durable: if the owner process dies, a survivor re-elects with an empty store. For multi-pod deployments behind a load balancer use RedisSharedStorage (see Shared storage: backend-agnostic state manager + pub/sub #3930).

Follow-up (not in this PR)

  • Host the downlink in a SharedWorker so it's one connection per browser (shared across tabs) rather than per page. StreamClient is written host-agnostic for this; the per-page transport already solves the connection-limit problem.

Comment thread dash/_stream_hub.py
Comment on lines +60 to +76
def subscribe_envelopes(
storage: BaseSharedStorage,
connection_id: str,
replay_from: Optional[int] = None,
) -> Iterator[Any]:
"""Yield a connection's downlink envelopes until the subscription ends.

These frames feed a ``StreamedCallbackResponse`` so the existing NDJSON
response path serializes and keep-alives them -- no bespoke endpoint. It is
long-lived: it carries frames for every callback on the connection, not one
stream, and ends when the client hangs up. ``replay_from`` resumes a
reconnecting downlink from its last cursor. Each envelope carries its ``seq``
so the client can resume from it after a reconnect without losing frames.
"""
with storage.subscribe(stream_topic(connection_id), replay_from) as sub:
for seq, message in sub.iter_with_seq():
yield {**message, "seq": seq}

@KoolADE85 KoolADE85 Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop here ends on client disconnect, but not always on server shutdown. As a result, streams continue even after a Ctrl+C (see the repro app below).
We should respond to a shutdown event the way ws.py does.

import asyncio
from dash import Dash, Input, Output, callback, html

app = Dash(__name__, backend="fastapi")
server = app.server
app.layout = html.Div([
    html.H3("If you click the button, Ctrl+C won't stop this server"),
    html.Button("stream", id="btn", n_clicks=0),
    html.Div(id="out"),
])

@callback(
    Output("out", "children"),
    Input("btn", "n_clicks"),
    prevent_initial_call=True
)
async def long_stream(n):
    for i in range(600):
        await asyncio.sleep(0.5)
        print(f"tick {i}")
        yield f"tick {i}"

if __name__ == "__main__":
    app.run(debug=True)

Comment thread dash/backends/_flask.py Outdated
Comment on lines +299 to +300
frames = subscribe_envelopes(
storage, downlink["connectionId"], downlink.get("from")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we are implicitly trusting the connectionId sent by a client.
If the ID is somehow leaked or derived, then anyone can use the ID to join someone else's stream and receive its data. Suggest generating the ID server-side and tying it to a cookie or session.

See also _serve_uplink where a connectionId could also be used to inject frames into someone else's stream.

This also applies to the fastapi & quart backends.

Comment on lines +46 to +49
// Last sequence applied; the downlink resumes from here on reconnect. Starts
// at 0 so the first connect replays anything published before it subscribed
// (the uplink POST and the downlink open race).
private cursor = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cursor is per-page, monotonic, and is never reset.
On reconnect, the client sends from: this.cursor (line 160 below).
This seems correct as long as the server is running, but a server restart introduces a flaw: the server's cursor must catch up to the client before the stream can resume.

See this example app (works with gunicorn as well as the dev server)

import time

from dash import Dash, Input, Output, callback, dcc, html

app = Dash(__name__, backend="flask")
server = app.server

app.layout = html.Div(
    children=[
        html.H3("Streamed clock stalls silently after a server restart"),
        html.P("Watch it tick a few seconds, then restart the server."),
        html.P("Observe that it stays frozen for as many seconds as the previous server was alive."),
        html.Pre(id="clock", children="waiting for first tick…"),
        dcc.Interval(id="kick", interval=1000),
    ],
)


@callback(
    Output("clock", "children"),
    Input("kick", "n_intervals"),
    prevent_initial_call=True,
)
async def stream_clock(_):
    yield f"serverside clock: {time.strftime('%H:%M:%S')}"


if __name__ == "__main__":
    app.run(debug=True, port=8050)

@T4rk1n

T4rk1n commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@KoolADE85 This is ready for another look.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Dash performance benchmarks

✅ all within thresholds

scenario metric p90 (ms) median growth baseline p90 note
callback_chain chain_ms 466.0 443.4 0.91x 499.1
callback_chain graph_ms 2.4 2.4 1.0x 2.4
callback_fanout fanout_ms 81.8 78.9 0.94x 92.5
deep_nesting render_ms 55.3 50.9 0.96x 56.8
full_children_replace replace_ms 4543.1 1801.0 16.22x 4697.1
initial_render_large render_ms 559.0 533.1 0.94x 694.4
initial_render_small render_ms 96.5 89.6 1.04x 104.0
patch_append_nested append_ms 148.5 107.6 2.44x 192.3
patch_append_toplevel append_ms 126.0 92.0 2.19x 140.2
patch_scalar_update_large update_ms 166.6 152.8 0.94x 202.9
wildcard_all_resolve wildcard_ms 308.9 282.3 0.94x 313.6
wildcard_all_resolve graph_ms 1.2 1.2 1.0x 1.3

growth = late-third / early-third per-op time; ~1 is flat, a large value means the per-op cost scales with accumulated state.

machine scale vs baseline: 0.93x - divided out of the baseline ratios so they compare like for like (the absolute warn/fail ceilings are left un-scaled); calibrated on initial_render_small.

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.
Addresses the review comments on the multiplexed streaming transport.

- Authorize stream connections. The connection topic is now keyed on the
  server-signed end_id, derived server-side, never from a client-supplied id,
  so a page can only read or write its own streams. A stream request whose
  token does not verify is refused with 403 and never run some other way, so
  no frame can reach a topic without a valid token. Multiplexing across worker
  processes needs a secret_key so every worker verifies the same token (the
  same requirement background callbacks have); the callback fails visibly in
  the browser otherwise.

- Stop streams on server shutdown. In-flight pump tasks are cancelled and open
  downlink subscriptions closed on shutdown (FastAPI shutdown event, Quart
  after_serving, Flask atexit), so a long-polling downlink no longer keeps a
  streaming app from exiting on Ctrl+C.

- Reset a stale cursor. A cursor ahead of the topic head (server restart or
  owner re-election left the topic at seq 0) is surfaced as a gap; the downlink
  emits a reset envelope and the client resets its cursor to the head instead
  of stalling until the fresh sequence climbs past it.
FastAPI/Starlette dropped add_event_handler, breaking every FastAPI
app on startup. Move the shutdown hook into the lifespan middleware
receive wrapper instead.

Also extract _read_body() in _flask.py to bring serve_callback under
the 50-statement pylint limit after the compression+streaming merge.
Replace atexit with a SIGINT/SIGTERM signal handler that calls
shutdown_active_streams() before re-raising. The atexit approach
deadlocked: it runs only after all non-daemon threads stop, but a
downlink subscription blocks its worker thread in a long poll that
never returns without explicit close(), so the process hung forever.
The keepalive generator blocked in queue.get(timeout=keepalive) where
keepalive defaults to 15s, so even after shutdown_active_streams set
the flag the generator would not exit for up to 15 seconds. Poll at
0.5s intervals instead and emit keepalives based on elapsed time.

shutdown_active_streams now sets a module-level _shutdown event that
the generator checks, so all active NDJSON streams exit within 0.5s
of a shutdown signal.
Move signal, time imports to module level instead of inside functions.
Add test_stcb021 (shutdown flag stops keepalive generator within one
poll cycle) and test_stcb022 (shutdown_active_streams sets the flag
and closes subscriptions).
The async path (_akeepalive_frames) used for FastAPI and Quart was
polling at the full keepalive interval (up to 15s) and never checking
the _shutdown flag, so Ctrl+C during an active stream on ASGI backends
would hang until the keepalive timeout elapsed. Poll at 0.5s and exit
on shutdown, matching the sync path.
The signal handler was only in _flask.py, so FastAPI and Quart never
got it. The lifespan shutdown hook fires too late (after connections
drain), but connections can't drain because the streaming generator
blocks them. Move the handler to _stream_hub.py which all backends
import, so SIGINT tears down streams before the server starts waiting.
Catch KeyboardInterrupt around proc.wait() so Ctrl+C terminates the
uvicorn subprocess cleanly instead of printing a traceback and leaving
the reloader process behind.
Quart uses loop.add_signal_handler which overrides the module-level
signal.signal handler from _stream_hub. Its handler only set the
WebSocket shutdown event, so streaming generators were never told to
stop on Ctrl+C.
After a server restart the signed end_id token is stale, so the
multiplexed uplink returns 403. Instead of failing the callback,
catch the 403 and fall back to the regular serverside handler which
streams inline NDJSON (one connection per callback, no token needed).
The stream still works; multiplexing resumes after the next page load
brings a fresh token.
Extract _run_subprocess helper that catches both the first and second
KeyboardInterrupt so the process exits silently. Also fixes the
too-many-statements pylint error on the run method.
When the server shuts down during an active stream, the browser's
ReadableStream throws a network error. If we already received at
least one frame, treat this as a clean end (keep applied frames,
resolve the callback) instead of rejecting with an error that shows
in the devtools overlay.
After a shutdown sets _streaming._shutdown, a hot-reloaded restart
in the same process keeps it set, so all new streams exit immediately
and the page shows connection errors. Clear it on startup in each
backend: lifespan.startup for FastAPI, run() for Quart and Flask.
uvicorn now imports the app before installing its own signal handlers,
which replaced Dash's import-time SIGINT/SIGTERM handler, so a server
with an active streaming callback ignored Ctrl+C and sat in "Waiting
for connections to close". Reinstall the handler from the ASGI lifespan
startup hook, where it wraps uvicorn's handle_exit; the installer is
idempotent so the reload child (which already worked) is unchanged.

Client side, a server restart mid-stream left the page looping: the new
process has a fresh signing secret, so the downlink was refused with 403
once a second forever and the callbacks stayed in the running state.
The stream client now settles pending callbacks when the downlink is
refused, reset, or unreachable past a 30s window (frames applied stay
and resolve; nothing applied rejects so the 403 fallback still runs),
backs off exponentially while the server is down, and treats an
accepted-then-empty downlink close as a failure instead of reconnecting
in a burst. Settling retires the read loop before firing promises so a
stream started from a settled continuation gets a fresh downlink.

A pump cancelled by shutdown publishes a terminal error frame, so a
Redis-backed downlink reconnecting after a restart settles too. FastAPI
callback responses now carry application/json like the other backends.
@sonarqubecloud

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants