Skip to content

Commit 4699277

Browse files
authored
Merge pull request #3826 from plotly/feat/per-websocket-threadpool
Add websocket_max_workers and run async callbacks directly on the loop
2 parents 3ccdeb6 + abbb592 commit 4699277

10 files changed

Lines changed: 571 additions & 153 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](https://semver.org/).
44

55
## [UNRELEASED]
66

7+
### Added
8+
- [#3826](https://github.com/plotly/dash/pull/3826) WebSocket callback dispatch no longer lets long-lived callbacks limit the number of concurrent users. Async callbacks (including session-persistent ones) run directly on the connection event loop instead of occupying a worker thread, and synchronous callbacks run on a shared `ThreadPoolExecutor` whose size is configurable via the new `websocket_max_workers` argument to `Dash` (default `4`). A synchronous persistent (no-output) callback now warns at registration since it would tie up a worker thread.
9+
710
## Fixed
811
- [#3822](https://github.com/plotly/dash/pull/3822) Fix `UnboundLocalError` for `user_callback_output` in async background callbacks (Celery and Diskcache managers) when the callback raises `PreventUpdate` or another exception before the variable is assigned.
912
- [#3819](https://github.com/plotly/dash/pull/3819) Fix `RuntimeError: No active request in context` when a non-Dash path falls through to the FastAPI catch-all route. Fixes [#3812](https://github.com/plotly/dash/issues/3812).

dash/_callback.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import collections
22
import hashlib
33
import inspect
4+
import warnings
45
from functools import wraps
56
from typing import Callable, Optional, Any, List, Tuple, Union, Dict, TypeVar, cast
67

@@ -880,6 +881,19 @@ async def async_add_context(*args, **kwargs):
880881
if inspect.iscoroutinefunction(func):
881882
callback_map[callback_id]["callback"] = async_add_context
882883
else:
884+
# A persistent, no-output callback streams via set_props and typically
885+
# runs for the life of the connection. When synchronous it occupies a
886+
# WebSocket worker thread the whole time and can exhaust the pool, so
887+
# warn that it should be async (async callbacks run on the event loop).
888+
if _kwargs.get("persistent") and not has_output:
889+
warnings.warn(
890+
f"persistent=True callback '{callback_id}' is synchronous and "
891+
"has no Output; it will occupy a WebSocket worker thread for the "
892+
"life of the connection and can exhaust the pool. Define it with "
893+
"'async def' so it runs on the event loop instead.",
894+
RuntimeWarning,
895+
stacklevel=2,
896+
)
883897
callback_map[callback_id]["callback"] = add_context
884898

885899
return func

dash/_callback_context.py

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import asyncio
21
import functools
32
import warnings
43
import json
@@ -367,20 +366,13 @@ def set_props(component_id: typing.Union[str, dict], props: dict):
367366
"""
368367
ws = _get_from_context("dash_websocket", None)
369368
if ws is not None:
370-
# Stream immediately via WebSocket
369+
# Stream immediately via WebSocket. Queuing is synchronous and thread-safe
370+
# (janus sync side), so we queue directly instead of scheduling a task. This
371+
# avoids detached/orphaned tasks when the callback runs on the event loop and
372+
# preserves ordering relative to the callback response.
371373
_id = stringify_id(component_id)
372-
373-
async def _send_props():
374-
for prop_name, value in props.items():
375-
await ws.set_prop(_id, prop_name, value)
376-
377-
# If we're in an async context, schedule the coroutine
378-
try:
379-
asyncio.get_running_loop()
380-
asyncio.ensure_future(_send_props())
381-
except RuntimeError:
382-
# No running event loop - run synchronously
383-
asyncio.run(_send_props())
374+
for prop_name, value in props.items():
375+
ws.set_prop_sync(_id, prop_name, value)
384376
else:
385377
# Batch for response (existing behavior)
386378
callback_context.set_props(component_id, props)

dash/backends/_fastapi.py

Lines changed: 76 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import concurrent.futures
66
import json
77
import queue
8-
from typing import TYPE_CHECKING, Any, Callable, Dict
8+
from typing import TYPE_CHECKING, Any, Callable, Dict, List
99
import sys
1010
import mimetypes
1111
import hashlib
@@ -46,9 +46,9 @@
4646
DashWebsocketCallback,
4747
run_ws_sender,
4848
run_callback_in_executor,
49+
run_callback_on_loop,
4950
make_callback_done_handler,
50-
SHUTDOWN_SIGNAL,
51-
DISCONNECTED,
51+
shutdown_ws_connection,
5252
)
5353
from ._utils import format_traceback_html
5454

@@ -263,8 +263,8 @@ def __init__(self, server: FastAPI):
263263
self.error_handling_mode = "ignore"
264264
self.request_adapter = FastAPIRequestAdapter
265265
self.response_adapter = FastAPIResponseAdapter
266-
self._before_request_funcs = []
267-
self._after_request_func = None
266+
self._before_request_funcs: List[Callable[[], Any]] = []
267+
self._after_request_func: Callable[[], Any] | None = None
268268
self._enable_timing = False
269269

270270
def __call__(self, *args: Any, **kwargs: Any):
@@ -685,7 +685,7 @@ def serve_websocket_callback(self, dash_app: "Dash"):
685685
Args:
686686
dash_app: The Dash application instance
687687
"""
688-
# pylint: disable=too-many-statements,too-many-locals
688+
# pylint: disable=too-many-statements,too-many-locals,too-many-branches
689689
ws_path = dash_app.config.routes_pathname_prefix + "_dash-ws-callback"
690690

691691
# Get allowed origins from dash app config
@@ -729,16 +729,28 @@ async def websocket_handler(websocket: WebSocket):
729729

730730
await websocket.accept()
731731

732+
# The connection's event loop, used to dispatch async callbacks as
733+
# tasks and to resolve get_props futures.
734+
loop = asyncio.get_running_loop()
735+
732736
# Create janus queue for outbound messages (main loop context)
733737
outbound_queue: janus.Queue[str] = janus.Queue()
734-
# Track pending get_props requests with standard queue.Queue for responses
735-
pending_get_props: Dict[str, queue.Queue] = {}
738+
# Track pending get_props requests. Values are queue.Queue (threadpool /
739+
# sync path) or asyncio.Future (event-loop / async path).
740+
pending_get_props: Dict[str, queue.Queue | asyncio.Future] = {}
736741
# Shutdown event to signal connection closure to worker threads
737742
shutdown_event = threading.Event()
738-
# Get thread pool executor
739-
executor = self.get_callback_executor()
740-
# Track pending callback futures
741-
pending_callbacks: Dict[str, concurrent.futures.Future] = {}
743+
# Sync callbacks run on a shared thread pool executor (async callbacks
744+
# run directly on the event loop). A single bounded pool caps the total
745+
# worker-thread count regardless of how many connections are open.
746+
executor = self.get_callback_executor(
747+
getattr(dash_app, "_websocket_max_workers", 4)
748+
)
749+
# Track pending callbacks: concurrent.futures.Future (sync/threadpool)
750+
# or asyncio.Task (async/event-loop).
751+
pending_callbacks: Dict[
752+
str, concurrent.futures.Future | asyncio.Future
753+
] = {}
742754

743755
# Start sender task to drain outbound queue (sends pre-serialized text)
744756
# pylint: disable=protected-access
@@ -777,66 +789,78 @@ async def websocket_handler(websocket: WebSocket):
777789
dash_app._websocket_callbacks,
778790
)
779791

780-
# Create WebSocket callback instance
792+
# Async callbacks (incl. persistent ones) run as tasks on
793+
# the event loop; sync callbacks go to the threadpool.
794+
# pylint: disable=protected-access
795+
cb_spec = dash_app.callback_map.get(payload.get("output"), {})
796+
is_async = inspect.iscoroutinefunction(cb_spec.get("callback"))
797+
798+
# Create WebSocket callback instance. The loop is passed only
799+
# for the async path so get_prop awaits instead of blocking.
781800
ws_cb = DashWebsocketCallback(
782801
pending_get_props,
783802
renderer_id,
784803
outbound_queue,
785804
shutdown_event,
805+
loop if is_async else None,
786806
)
787807

788-
# Submit callback to executor
789-
future = run_callback_in_executor(
790-
executor,
791-
dash_app,
792-
payload,
793-
ws_cb,
794-
FastAPIResponseAdapter(),
808+
done_handler = make_callback_done_handler(
809+
outbound_queue,
810+
pending_callbacks,
811+
request_id,
812+
renderer_id,
813+
shutdown_event,
795814
)
796815

797-
# Set up done callback to send response
798-
future.add_done_callback(
799-
make_callback_done_handler(
800-
outbound_queue,
801-
pending_callbacks,
802-
request_id,
803-
renderer_id,
804-
shutdown_event,
816+
if is_async:
817+
task = asyncio.create_task(
818+
run_callback_on_loop(
819+
dash_app,
820+
payload,
821+
ws_cb,
822+
FastAPIResponseAdapter(),
823+
)
805824
)
806-
)
807-
pending_callbacks[request_id] = future
825+
task.add_done_callback(done_handler)
826+
pending_callbacks[request_id] = task
827+
else:
828+
# Submit callback to executor
829+
future = run_callback_in_executor(
830+
executor,
831+
dash_app,
832+
payload,
833+
ws_cb,
834+
FastAPIResponseAdapter(),
835+
)
836+
# Set up done callback to send response
837+
future.add_done_callback(done_handler)
838+
pending_callbacks[request_id] = future
808839

809840
elif msg_type == "get_props_response":
810-
# Put response in waiting queue (non-blocking)
841+
# Resolve the waiting future (async path) or queue (thread
842+
# path) for this request (non-blocking).
811843
request_id = message.get("requestId")
812-
response_queue = pending_get_props.get(request_id)
813-
if response_queue is not None:
814-
response_queue.put_nowait(message.get("payload"))
844+
pending = pending_get_props.get(request_id)
845+
if isinstance(pending, asyncio.Future):
846+
if not pending.done():
847+
pending.set_result(message.get("payload"))
848+
elif pending is not None:
849+
pending.put_nowait(message.get("payload"))
815850

816851
elif msg_type == "heartbeat":
817852
outbound_queue.sync_q.put_nowait('{"type": "heartbeat_ack"}')
818853

819854
except WebSocketDisconnect:
820855
pass # Clean disconnect
821856
finally:
822-
# Signal shutdown to worker threads
823-
shutdown_event.set()
824-
# Unblock any threads waiting on get_prop responses
825-
for response_queue in pending_get_props.values():
826-
response_queue.put_nowait(DISCONNECTED)
827-
# Signal sender to shutdown and cancel it
828-
outbound_queue.sync_q.put_nowait(SHUTDOWN_SIGNAL)
829-
sender_task.cancel()
830-
try:
831-
await sender_task
832-
except asyncio.CancelledError:
833-
pass
834-
# Close the janus queue
835-
outbound_queue.close()
836-
await outbound_queue.wait_closed()
837-
# Cancel any pending futures
838-
for f in pending_callbacks.values():
839-
f.cancel()
857+
await shutdown_ws_connection(
858+
shutdown_event,
859+
pending_get_props,
860+
pending_callbacks,
861+
outbound_queue,
862+
sender_task,
863+
)
840864

841865
self.server.add_api_websocket_route(ws_path, websocket_handler)
842866

0 commit comments

Comments
 (0)