Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions .ai/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,8 @@ Special handling for Colab:
| `DASH_PRUNE_ERRORS` | Simplify tracebacks |
| `HOST` | Server host |
| `PORT` | Server port |
| `DASH_SECRET_KEY` | Signing secret for page, background and stream tokens when `server.secret_key` is unset |
| `DASH_SHARED_STORAGE` | Shared-storage backend when `shared_storage=` is not passed (see Shared Storage) |

## Stores and Client-Side State

Expand Down Expand Up @@ -976,6 +978,16 @@ election only reaches processes in the same network + filesystem namespace.
This works at 1 pod and fragments silently once it scales — use
`RedisSharedStorage` (one Redis shared by all pods) instead.

A hosting platform can switch the backend without editing the app through
`DASH_SHARED_STORAGE` (`_shared_storage/_env.py`), read only when the app did
not pass `shared_storage=` (the default is a sentinel, so an explicit argument,
`None` included, always wins): `local`, `none`, `diskcache:///abs/path`, or a
`redis://` / `rediss://` URL. `cluster://` is reserved and raises; anything
else raises `InvalidConfig` at construction. The value becomes a zero-argument
factory, so nothing is built or connected until `app.shared_storage` is first
read, but a missing extra (`dash[redis]`, `dash[diskcache]`) fails at
construction with the backend's own `ImportError`.

### Custom and Out-of-Tree Backends

`BaseSharedStorage` is the stable, public extension point. A backend — shipped
Expand Down Expand Up @@ -1010,6 +1022,7 @@ same way as a built-in: `Dash(shared_storage=PostgresSharedStorage(...))`.
| `_shared_storage/_polling.py` | `PollingSubscription`: shared poll-loop subscription for the diskcache/Redis backends |
| `_shared_storage/_transport.py` | Length-prefixed, token-gated socket transport (local backend) |
| `_shared_storage/_codec.py` | msgspec msgpack codec (data-only) |
| `_shared_storage/_env.py` | `DASH_SHARED_STORAGE` parsing |
| `dash.py` | `shared_storage` constructor arg + lazy `app.shared_storage` property |
| `_callback_context.py` | `dash.ctx.shared_storage` accessor |

Expand Down Expand Up @@ -1380,8 +1393,15 @@ The connection id is never chosen by the client: every stream request rides on
`?endId=`, the server-signed per-page-load token, and the backend derives the
id from it (`get_stream_connection_id`), answering 403 when it is missing or
forged -- otherwise a client could read or inject into another page's topic.
Across worker processes every worker must resolve the same signing secret
(`secret_key`).
Across worker processes every worker must resolve the same signing secret.
`_get_signing_secret` resolves, in order: `server.secret_key`, then
`DASH_SECRET_KEY` (for Dash's signing only, never copied onto
`server.secret_key`, so Flask sessions are untouched), then a secret persisted
in the background-callback store, then a per-process random one. With the last,
tokens only verify on the worker that issued them: stream requests 403 on the
other workers, and the first failure in each process logs a warning pointing at
`DASH_SECRET_KEY` (`_warn_unverified_stream_token`). A request with no token at
all is not logged.

The downlink is hosted in a SharedWorker (`dash-stream-worker.js`, served like
the WebSocket worker; `config.stream.worker_url`) so **one connection per
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added
- [#4026](https://github.com/plotly/dash/pull/4026) Add `DASH_SECRET_KEY` so a hosting platform can give every worker and pod the same signing secret for page, background and stream tokens without editing the app (`server.secret_key` still wins and is not changed).
- [#4026](https://github.com/plotly/dash/pull/4026) Add `DASH_SHARED_STORAGE` to pick the shared-storage backend when the app does not pass `shared_storage=`: `local`, `none`, `diskcache:///abs/path`, or a `redis://` / `rediss://` URL.
- [#3976](https://github.com/plotly/dash/pull/3976) Add a new `scrollToTop` prop to `dcc.Link` to control whether the page scrolls to the top after client-side navigation. It defaults to `True` to preserve the existing behavior. Fixes [#3974](https://github.com/plotly/dash/issues/3974).
- [#3947](https://github.com/plotly/dash/pull/3947) Make `plotly-cloud` a default install dependency of Dash instead of an optional extra, so the `plotly` CLI and Dash's cloud integration work out of the box. The `dash[cloud]` extra is kept for backward compatibility.
- [#3930](https://github.com/plotly/dash/pull/3930) Add shared storage: a backend-agnostic cross-process state manager (key/value with optional TTL, plus ordered replayable pub/sub) on every app via `dash.ctx.shared_storage`, started lazily and disabled with `shared_storage=None`. Ships `LocalSharedStorage` (default, in-memory with optional disk persistence), `DiskcacheSharedStorage`, and `RedisSharedStorage` for horizontally-scaled deployments; see `.ai/ARCHITECTURE.md`.
Expand All @@ -21,6 +23,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- [#3646](https://github.com/plotly/dash/pull/3646) Remove React 16 support (`16.14.0` is no longer an accepted value for `REACT_VERSION` / `_set_react_version`).

### Changed
- [#4026](https://github.com/plotly/dash/pull/4026) Log a warning, once per process, when a streaming request is refused because its stream token failed verification, which on multi-worker deployments usually means the workers do not share a signing secret.
- [#3987](https://github.com/plotly/dash/pull/3987) Forward FastAPI reload scope options (`reload_dirs`, `reload_excludes`, and `reload_includes`) to Uvicorn when reloading.
- [#3986](https://github.com/plotly/dash/pull/3986) Adjust `_run_before_hooks` in the `fastapi` backend to honor a response returned by a `before_request` function, matching the `flask` backend's behavior.

Expand Down
43 changes: 35 additions & 8 deletions dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import hashlib
import inspect
import logging
import threading
import warnings
from functools import wraps
from typing import Callable, Optional, Any, List, Tuple, Union, Dict, TypeVar, cast
Expand Down Expand Up @@ -464,11 +465,14 @@ def get_request_end_id(secret: bytes):
request; this verifies the signature and returns the underlying end_id so
background handles can be checked against it.
"""
return _callback_signing.unsign(
secret, _callback_signing.END_SCOPE, _request_end_token()
)


def _request_end_token():
adapter = get_app().backend.request_adapter()
if not adapter:
return None
token = adapter.args.get("endId")
return _callback_signing.unsign(secret, _callback_signing.END_SCOPE, token)
return adapter.args.get("endId") if adapter else None


def get_stream_connection_id() -> "str | None":
Expand All @@ -482,11 +486,34 @@ def get_stream_connection_id() -> "str | None":
forged token yields ``None``, and the backend refuses the request (403).

``end_id`` is signed with the server secret, so across worker processes every
worker must resolve the same secret: set a ``secret_key`` on the server, or
cross-worker stream requests will not verify. Single-process apps are fine
with no configuration.
worker must resolve the same secret: set a ``secret_key`` on the server or
the ``DASH_SECRET_KEY`` environment variable, or cross-worker stream requests
will not verify. Single-process apps are fine with no configuration.
"""
return get_request_end_id(_get_signing_secret())
connection_id = get_request_end_id(_get_signing_secret())
if connection_id is None and _request_end_token():
_warn_unverified_stream_token()
return connection_id


_stream_token_warning_lock = threading.Lock()
_stream_token_warned = False


def _warn_unverified_stream_token():
# Once per process: a secret mismatch fails every cross-worker request.
global _stream_token_warned # pylint: disable=global-statement
with _stream_token_warning_lock:
if _stream_token_warned:
return
_stream_token_warned = True
get_app().logger.warning(
"A streaming request was refused (403): its stream token failed "
"verification. On multi-worker or multi-pod deployments this usually "
"means the workers do not share a signing secret (or the server "
"restarted since the page loaded); set server.secret_key or the "
"DASH_SECRET_KEY environment variable."
)


def _get_signing_secret() -> bytes:
Expand Down
2 changes: 2 additions & 0 deletions dash/_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ def load_dash_env_vars():
"DASH_COMPRESS",
"DASH_MCP_ENABLED",
"DASH_MCP_PATH",
"DASH_SECRET_KEY",
"DASH_SHARED_STORAGE",
"HOST",
"PORT",
)
Expand Down
61 changes: 61 additions & 0 deletions dash/_shared_storage/_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Pick a shared-storage backend from ``DASH_SHARED_STORAGE``.

Lets a hosting platform switch backends without editing the app's ``Dash(...)``
call. Only used when the app did not pass ``shared_storage=``.
"""

import functools
import re
from typing import Any, Optional
from urllib.parse import urlparse

from ..exceptions import InvalidConfig
from .diskcache import DiskcacheSharedStorage, _require_diskcache
from .local import LocalSharedStorage
from .redis import RedisSharedStorage, _require_redis

ENV_VAR = "DASH_SHARED_STORAGE"


def _redact(value: str) -> str:
# Keep credentials in a URL out of the error message.
return re.sub(r"(://)[^/@]*@", r"\1***@", value)


def _invalid(value: str, reason: str) -> InvalidConfig:
return InvalidConfig(
f"{ENV_VAR}={_redact(value)!r} is not valid: {reason}. Use 'local', 'none', "
"'diskcache:///absolute/path', or a redis:// or rediss:// URL."
)


def storage_from_env(value: Optional[str]) -> Any:
"""Turn a ``DASH_SHARED_STORAGE`` value into a ``shared_storage`` argument.

Returns ``None`` (disabled), or a zero-argument callable that builds the
backend. Nothing is built or connected here, so startup stays lazy; missing
optional dependencies still fail now, with the backend's own error.
"""
raw = (value or "").strip()
lowered = raw.lower()
if lowered in ("", "local"):
return LocalSharedStorage
if lowered == "none":
return None

scheme = urlparse(raw).scheme.lower()
if scheme in ("redis", "rediss"):
_require_redis()
return functools.partial(RedisSharedStorage, url=raw)
if scheme == "diskcache":
parsed = urlparse(raw)
if parsed.netloc or not parsed.path.startswith("/"):
raise _invalid(raw, "diskcache needs an absolute path (three slashes)")
_require_diskcache()
return functools.partial(DiskcacheSharedStorage, directory=parsed.path)
if scheme == "cluster":
raise InvalidConfig(
f"{ENV_VAR}={_redact(raw)!r}: the cluster:// backend is not supported in this "
"version of Dash."
)
raise _invalid(raw, "unknown backend")
62 changes: 46 additions & 16 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@
)
from .backends import get_backend
from .version import __version__
from ._configs import get_combined_config, pathname_configs, pages_folder_config
from ._configs import (
get_combined_config,
load_dash_env_vars,
pathname_configs,
pages_folder_config,
)
from ._utils import (
AttributeDict,
format_tag,
Expand Down Expand Up @@ -76,11 +81,8 @@
from . import backends

from ._get_app import with_app_context, with_app_context_factory
from ._shared_storage import (
BaseSharedStorage,
LocalSharedStorage,
SharedStorageError,
)
from ._shared_storage import BaseSharedStorage, SharedStorageError
from ._shared_storage._env import storage_from_env
from ._grouping import map_grouping, grouping_len, update_args_group
from ._obsolete import ObsoleteChecker
from ._callback_context import callback_context
Expand Down Expand Up @@ -154,6 +156,9 @@
_ID_DUMMY = "_pages_dummy"

_UNINITIALIZED = object() # Sentinel for tracking init_app state
# Default for ``shared_storage`` so "not passed" (DASH_SHARED_STORAGE may pick
# the backend) can be told apart from an explicit argument.
_SHARED_STORAGE_DEFAULT: Any = object()

DASH_VERSION_URL = "https://dash-version.plotly.com:8080/current_version"

Expand Down Expand Up @@ -467,6 +472,20 @@ class Dash(ObsoleteChecker):
takes a thread for milliseconds. ASGI backends (Quart, FastAPI) keep
one open connection per browser instead and ignore this.
:type stream_poll_interval: int

:param shared_storage: Backend for ``dash.ctx.shared_storage`` and the
streaming-callback transport: a ``BaseSharedStorage`` subclass or
instance, or ``None`` to disable. Default ``LocalSharedStorage`` (one
machine or pod). When not passed, the ``DASH_SHARED_STORAGE``
environment variable can choose it: ``local``, ``none``,
``diskcache:///absolute/path``, or a ``redis://`` / ``rediss://`` URL.
An explicit argument, including ``None``, always wins.

Stream requests are signed per page load, so with several workers or
pods they all need the same signing secret: set ``server.secret_key``
or the ``DASH_SECRET_KEY`` environment variable. ``DASH_SECRET_KEY`` is
used for Dash's own signing only and does not set ``server.secret_key``.
:type shared_storage: BaseSharedStorage subclass or instance, or None
"""

_plotlyjs_url: str
Expand Down Expand Up @@ -531,7 +550,7 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
stream_poll_interval: int = 100,
shared_storage: Optional[
Union[Type[BaseSharedStorage], BaseSharedStorage]
] = LocalSharedStorage,
] = _SHARED_STORAGE_DEFAULT,
enable_mcp: Optional[bool] = None,
mcp_path: Optional[str] = None,
**obsolete,
Expand Down Expand Up @@ -714,6 +733,10 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
# access so it costs nothing until used and never binds in a gunicorn
# preload master or the Flask reloader parent -- only in the worker that
# actually touches it.
if shared_storage is _SHARED_STORAGE_DEFAULT:
shared_storage = storage_from_env(
load_dash_env_vars().get("DASH_SHARED_STORAGE")
)
self._shared_storage_arg = shared_storage
self._shared_storage_instance: Optional[BaseSharedStorage] = None
self._shared_storage_lock = threading.Lock()
Expand Down Expand Up @@ -743,9 +766,8 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
self._pages_lock = threading.Lock()
self._pages_async_lock: Optional[asyncio.Lock] = None

# Secret used to sign background-callback handles (see _callback_signing).
# Prefer the Flask/Quart secret_key (shared across workers when the
# operator sets one); otherwise fall back to a per-process random secret.
# Fallback signing secret, used when neither server.secret_key nor
# DASH_SECRET_KEY is set. See _get_signing_secret.
self._generated_signing_secret: Optional[bytes] = None

if server:
Expand Down Expand Up @@ -1012,7 +1034,7 @@ def shared_storage(self) -> BaseSharedStorage:
with self._shared_storage_lock:
if self._shared_storage_instance is None:
storage = self._shared_storage_arg
if isinstance(storage, type):
if isinstance(storage, (type, functools.partial)):
storage = storage()
storage.start()
self._shared_storage_instance = storage
Expand Down Expand Up @@ -1061,21 +1083,29 @@ def serve_layout(self):
)

def _get_signing_secret(self) -> bytes:
"""Return the secret used to sign background-callback handles.
"""Return the secret used to sign page tokens (``end_id``), background
callback handles and stream connections.

Resolution order:

1. The server's ``secret_key`` if set (shared across workers when the
operator configures one, e.g. for Flask-Login).
2. Otherwise a random secret persisted in the background-callback result
2. ``DASH_SECRET_KEY`` from the environment, so a hosting platform can
give every worker and pod the same key without editing the app. Used
for Dash's own signing only, never assigned to ``server.secret_key``
(that would change Flask session behavior).
3. Otherwise a random secret persisted in the background-callback result
store, so every worker reads back the same value. This is exactly as
shared as the callback results themselves, so it works cross-worker
whenever the deployment is set up for multi-worker background
callbacks (an explicitly shared cache / broker).
3. Finally, if no background manager is available, a per-process random
secret (there are no background handles to verify in that case).
4. Finally, if no background manager is available, a per-process random
secret. Stream tokens then only verify on the worker that issued
them, so multi-worker streaming apps need 1 or 2.
"""
key = getattr(self.server, "secret_key", None)
key = getattr(self.server, "secret_key", None) or load_dash_env_vars().get(
"DASH_SECRET_KEY"
)
if key:
return key.encode("utf-8") if isinstance(key, str) else key
if self._generated_signing_secret is None:
Expand Down
21 changes: 19 additions & 2 deletions tests/shared_storage/test_dash_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@ def _redis_available():
return False


def _counter_app(storage):
_FROM_ENV = object()


def _counter_app(storage=_FROM_ENV):
"""A two-callback app: 'bump' increments a shared counter, 'read' (a separate
callback) shows the current shared value."""
app = Dash(__name__, shared_storage=storage)
kwargs = {} if storage is _FROM_ENV else {"shared_storage": storage}
app = Dash(__name__, **kwargs)
app.layout = html.Div(
[
html.Button("bump", id="bump"),
Expand Down Expand Up @@ -124,6 +128,19 @@ def test_counter_shared_across_callbacks_redis(dash_duo):
_drive_counter(dash_duo)


def test_redis_selected_by_env(dash_duo, monkeypatch):
if not _redis_available():
pytest.skip("no Redis reachable at REDIS_URL")
monkeypatch.setenv("DASH_SHARED_STORAGE", REDIS_URL)
app = _counter_app()
storage = app.shared_storage
assert isinstance(storage, RedisSharedStorage)
# The env var gives the default key prefix, so clear what earlier runs left.
storage.delete("count")
dash_duo.start_server(app)
_drive_counter(dash_duo)


def test_disabled_shared_storage_errors_the_callback(dash_duo):
"""With shared_storage=None, a callback touching ctx.shared_storage fails;
the app surfaces a callback error rather than updating the output."""
Expand Down
Loading
Loading