Skip to content

Commit 5abb55c

Browse files
authored
Merge pull request #4095 from plotly/shared-storage-opt-in
Make shared storage opt-in, move msgspec to extras
2 parents 783a3dc + 1296357 commit 5abb55c

27 files changed

Lines changed: 290 additions & 79 deletions

‎.ai/ARCHITECTURE.md‎

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,7 @@ Special handling for Colab:
727727
- `prevent_initial_callbacks` - Skip callbacks on load (default: `False`)
728728
- `background_callback_manager` - DiskcacheManager or CeleryManager
729729
- `on_error` - Global callback error handler
730-
- `shared_storage` - Cross-process state + pub/sub backend (default: `LocalSharedStorage`). Pass `None` to disable, or a `BaseSharedStorage` subclass/instance to swap. See [Shared Storage](#shared-storage).
730+
- `shared_storage` - Cross-process state + pub/sub backend (default: off). Pass `LocalSharedStorage` to enable, or a `BaseSharedStorage` subclass/instance to swap. See [Shared Storage](#shared-storage).
731731

732732
**WebSocket Callbacks:**
733733
- `websocket_callbacks` - Enable WebSocket for all callbacks (default: `False`). Requires FastAPI backend.
@@ -820,7 +820,9 @@ channel. Unlike `dcc.Store` (which lives in the browser), shared storage lives
820820
on the server and lets callbacks running in different workers see the same keys
821821
and topics without standing up an external service like Redis.
822822

823-
Enabled by default on every app. It starts lazily on first use, so it costs
823+
Opt-in: off unless the app passes `shared_storage=` or `DASH_SHARED_STORAGE`
824+
is set, so existing deployments do not grow a socket owner or a streaming
825+
downlink on upgrade. Once enabled it starts lazily on first use, so it costs
824826
nothing until touched.
825827

826828
### Accessing It
@@ -836,13 +838,21 @@ def handler(n):
836838
return store.get("clicks", 0)
837839

838840
# Or off the app directly:
839-
app = dash.Dash()
841+
app = dash.Dash(shared_storage=dash.LocalSharedStorage)
840842
app.shared_storage.set("key", {"any": "json-compatible value"})
841843
```
842844

843845
Values (and published messages) must be **JSON-compatible** — dict / list / str
844846
/ int / float / bool / None — the same constraint as `dcc.Store` and callback
845-
outputs. Messages are encoded with `msgspec` (msgpack), a hard dependency.
847+
outputs. Messages are encoded with `msgspec` (msgpack) from the
848+
`dash[shared_storage]` extra (also pulled in by `dash[diskcache]` and
849+
`dash[redis]`). Without it, `LocalSharedStorage` in `"memory"` mode still works
850+
and its socket transport falls back to stdlib JSON (both ends run the same
851+
install, so they always agree). JSON turns non-string dict keys into strings and
852+
cannot carry `bytes`, so keep values strictly JSON-shaped. Everything that
853+
writes encoded bytes somewhere lasting (the persistent modes, Diskcache, Redis)
854+
raises an `ImportError` naming the extra that brings it instead, so a stored
855+
format never depends on what happens to be installed.
846856

847857
### API
848858

@@ -895,7 +905,7 @@ more than one container/pod (see [Deployment Topology](#deployment-topology-and-
895905
896906
| Backend | Backing store | Reaches | Extra |
897907
|---------|---------------|---------|-------|
898-
| `LocalSharedStorage` *(default)* | in-memory, owner-elected socket | one **container** (all its workers) | none |
908+
| `LocalSharedStorage` | in-memory, owner-elected socket | one **container** (all its workers) | none (`dash[shared_storage]` for the persistent modes) |
899909
| `DiskcacheSharedStorage` | a `diskcache.Cache` | one **host** (all processes sharing the dir) | `dash[diskcache]` |
900910
| `RedisSharedStorage` | Redis (Streams for pub/sub) | **any** process/container/pod | `dash[redis]` |
901911
@@ -909,14 +919,15 @@ from dash._shared_storage import (
909919
LocalSharedStorage, DiskcacheSharedStorage, RedisSharedStorage,
910920
)
911921
912-
Dash(shared_storage=LocalSharedStorage) # default
922+
Dash() # disabled (default)
923+
Dash(shared_storage=LocalSharedStorage)
913924
Dash(shared_storage=DiskcacheSharedStorage(directory="/tmp/ss"))
914925
Dash(shared_storage=RedisSharedStorage(url="redis://host:6379"))
915-
Dash(shared_storage=None) # disabled
916926
```
917927
918928
When disabled, accessing `app.shared_storage` / `dash.ctx.shared_storage`
919-
raises `SharedStorageError`.
929+
raises `SharedStorageError`, and streaming callbacks still work, each on its own
930+
connection instead of the multiplexed downlink.
920931
921932
**`LocalSharedStorage`** — in-memory, no external service. On first use every
922933
worker races to become the single **owner** for the machine by binding a stable
@@ -993,7 +1004,7 @@ subscriptions likewise poll without blocking and sleep on the loop.
9931004
9941005
### Deployment Topology and Shared Storage
9951006
996-
The default `LocalSharedStorage` is **per-container**: its socket/loopback
1007+
`LocalSharedStorage` is **per-container**: its socket/loopback
9971008
election only reaches processes in the same network + filesystem namespace.
9981009
9991010
- **Single process / single pod** (e.g. Plotly Cloud apps): fine — one owner,
@@ -1009,16 +1020,16 @@ election only reaches processes in the same network + filesystem namespace.
10091020
A hosting platform can switch the backend without editing the app through
10101021
`DASH_SHARED_STORAGE` (`_shared_storage/_env.py`), read only when the app did
10111022
not pass `shared_storage=` (the default is a sentinel, so an explicit argument,
1012-
`None` included, always wins): `local`, `none`, `diskcache:///abs/path`, or a
1013-
`redis://` / `rediss://` URL. A `key_prefix` query parameter on the Redis URL
1014-
(`redis://host/0?key_prefix=myapp`) sets `RedisSharedStorage(key_prefix=)` and
1015-
is stripped before the URL reaches redis-py; the diskcache path is
1016-
percent-decoded. `cluster://` is reserved and raises; anything else raises
1017-
`InvalidConfig` at construction, without echoing the value (it can hold
1018-
credentials). The value becomes a zero-argument
1019-
factory, so nothing is built or connected until `app.shared_storage` is first
1020-
read, but a missing extra (`dash[redis]`, `dash[diskcache]`) fails at
1021-
construction with the backend's own `ImportError`.
1023+
`None` included, always wins): `local`, `none` (same as unset or empty),
1024+
`diskcache:///abs/path`, or a `redis://` / `rediss://` URL. A `key_prefix` query
1025+
parameter on the Redis URL (`redis://host/0?key_prefix=myapp`) sets
1026+
`RedisSharedStorage(key_prefix=)` and is stripped before the URL reaches
1027+
redis-py; the diskcache path is percent-decoded. `cluster://` is reserved and
1028+
raises; anything else raises `InvalidConfig` at construction, without echoing
1029+
the value (it can hold credentials). The value becomes a zero-argument factory,
1030+
so nothing is built or connected until `app.shared_storage` is first read, but a
1031+
missing extra (`dash[redis]`, `dash[diskcache]`, `dash[shared_storage]`) fails
1032+
at construction with the backend's own `ImportError`.
10221033
10231034
### Custom and Out-of-Tree Backends
10241035
@@ -1039,12 +1050,13 @@ and returns a `Subscription` (`__iter__` / `__aiter__` / `close`) that raises
10391050
top-level `dash`; the poll-loop helpers (`PollResult`, the polling subscription)
10401051
are private and not part of the contract.
10411052
1042-
Core only ships backends whose dependency is already a Dash extra (`msgspec`
1043-
base; `dash[diskcache]`; `dash[redis]`). Anything needing a heavier dependency
1044-
belongs **out of tree** behind this same interface — e.g. a Postgres backend
1045-
(`LISTEN`/`NOTIFY` for push pub/sub + a table for KV and replay) lives in its own
1046-
package so a `psycopg` connection is never pulled into Dash core. It plugs in the
1047-
same way as a built-in: `Dash(shared_storage=PostgresSharedStorage(...))`.
1053+
Core only ships backends whose dependency is already a Dash extra
1054+
(`dash[shared_storage]`; `dash[diskcache]`; `dash[redis]`). Anything needing a
1055+
heavier dependency belongs **out of tree** behind this same interface — e.g. a
1056+
Postgres backend (`LISTEN`/`NOTIFY` for push pub/sub + a table for KV and
1057+
replay) lives in its own package so a `psycopg` connection is never pulled into
1058+
Dash core. It plugs in the same way as a built-in:
1059+
`Dash(shared_storage=PostgresSharedStorage(...))`.
10481060
10491061
### Module Layout
10501062
@@ -1412,7 +1424,7 @@ with an empty result on the terminal frame.
14121424
14131425
### Multiplexed downlink and the stream SharedWorker
14141426
1415-
When the app has a shared-storage backend (the default `LocalSharedStorage`),
1427+
When the app has a shared-storage backend (opt-in, see Shared Storage),
14161428
HTTP streams do not each hold their own response. The callback's POST carries
14171429
`streamConnection: {requestId}` and returns a fast ack; a *pump*
14181430
(`dash/_stream_hub.py`) drives the generator as an asyncio task and publishes

‎CHANGELOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ This project adheres to [Semantic Versioning](https://semver.org/).
55
## [Unreleased]
66

77
### Added
8+
- [#4095](https://github.com/plotly/dash/pull/4095) Make shared storage and the multiplexed streaming downlink opt-in (`shared_storage=` or `DASH_SHARED_STORAGE`), and move `msgspec` to a new `dash[shared_storage]` extra for the persistent `LocalSharedStorage` modes (`dash[diskcache]` and `dash[redis]` include it).
89
- [#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).
910
- [#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.
1011
- [#4024](https://github.com/plotly/dash/pull/4024) Add support for social media meta tags in regular Dash apps using `app.title`, `app.description`, and `app.image`.
1112
- [#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).
1213
- [#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.
13-
- [#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`.
14+
- [#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) via `dash.ctx.shared_storage`, started lazily. Ships `LocalSharedStorage` (in-memory with optional disk persistence), `DiskcacheSharedStorage`, and `RedisSharedStorage` for horizontally-scaled deployments; see `.ai/ARCHITECTURE.md`.
1415
- [#3931](https://github.com/plotly/dash/pull/3931) Add streaming callbacks: an `async def` generator callback streams its yields to the browser as they are produced (`dash.Patch` yields apply incrementally). A browser's streams share one SharedWorker-hosted connection so they do not count against the per-host connection limit; closing a tab cancels its streams.
1516
- [#3977](https://github.com/plotly/dash/pull/3977) Add partial WebSocket prop reads with `get_prop(..., path=...)`. Closes [#3975](https://github.com/plotly/dash/issues/3975).
1617
- [#3765](https://github.com/plotly/dash/pull/3765) Add opt-in partial pattern matching for callback `Input`, `Output`, and `State` via `partial_pattern=True`, so dictionary ID patterns can match component IDs with extra keys and combine with `ALL`/`MATCH` wildcards. Fixes [#3764](https://github.com/plotly/dash/issues/3764).

‎dash/_callback_context.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def shared_storage(self):
216216
217217
A backend-agnostic key/value + publish/subscribe store shared across
218218
worker processes -- for cross-callback or session state without an
219-
external service. Requires ``Dash(shared_storage=...)`` (on by default).
219+
external service. Requires ``Dash(shared_storage=...)`` (off by default).
220220
"""
221221
return get_app().shared_storage
222222

‎dash/_shared_storage/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
Exposes the ``BaseSharedStorage`` interface and its backends:
44
5-
- ``LocalSharedStorage`` (default): in-memory, owner-elected -- one container.
5+
- ``LocalSharedStorage``: in-memory, owner-elected -- one container.
66
- ``DiskcacheSharedStorage``: on a ``diskcache.Cache`` -- one machine, not pods.
77
- ``RedisSharedStorage``: on Redis -- across processes and containers.
88
"""

‎dash/_shared_storage/_codec.py‎

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,53 @@
1-
"""Codec for the shared-storage socket transport.
1+
"""Codec for the shared-storage transport and the persistent backends.
22
3-
Uses ``msgspec`` (msgpack: fast, compact), a hard dependency of Dash. The
4-
format is data-only, so bytes read off the socket cannot execute code.
3+
Uses ``msgspec`` (msgpack: fast, compact) from the ``dash[shared_storage]``
4+
extra, also part of ``dash[diskcache]`` and ``dash[redis]``. Without it, only the in-memory transport works and it falls back to
5+
stdlib JSON; the backends that write encoded bytes somewhere lasting (disk
6+
persistence, diskcache, Redis) call :func:`require_msgspec` so the stored
7+
format never depends on what happens to be installed. Both formats are
8+
data-only, so bytes read off the socket cannot execute code.
59
610
Payloads must be JSON-compatible (dict / list / str / int / float / bool /
711
None) -- the same constraint as ``dcc.Store`` and callback outputs.
812
"""
913

14+
import json
1015
from typing import Any
1116

12-
import msgspec
17+
try:
18+
import msgspec
19+
except ImportError:
20+
msgspec = None # type: ignore[assignment]
1321

14-
# msgspec raises this on malformed input. It subclasses ``ValueError`` in
15-
# current msgspec but not in every version, so callers should catch it by name.
16-
DecodeError = msgspec.DecodeError
22+
HAS_MSGSPEC = msgspec is not None
1723

18-
_encoder = msgspec.msgpack.Encoder()
19-
_decoder = msgspec.msgpack.Decoder()
24+
if msgspec is not None:
25+
# msgspec raises this on malformed input. It subclasses ``ValueError`` in
26+
# current msgspec but not in every version, so callers should catch it by
27+
# name.
28+
DecodeError: Any = msgspec.DecodeError
29+
_encoder = msgspec.msgpack.Encoder()
30+
_decoder = msgspec.msgpack.Decoder()
2031

32+
def encode(obj: Any) -> bytes:
33+
return _encoder.encode(obj)
2134

22-
def encode(obj: Any) -> bytes:
23-
return _encoder.encode(obj)
35+
def decode(data: bytes) -> Any:
36+
return _decoder.decode(data)
2437

38+
else:
39+
DecodeError = json.JSONDecodeError
2540

26-
def decode(data: bytes) -> Any:
27-
return _decoder.decode(data)
41+
def encode(obj: Any) -> bytes:
42+
return json.dumps(obj, separators=(",", ":")).encode("utf-8")
43+
44+
def decode(data: bytes) -> Any:
45+
return json.loads(data)
46+
47+
48+
def require_msgspec(what: str, extra: str = "shared_storage") -> None:
49+
if not HAS_MSGSPEC:
50+
raise ImportError(
51+
f"{what} requires the {extra} extra:\n\n"
52+
f' $ pip install "dash[{extra}]"\n'
53+
)

‎dash/_shared_storage/_env.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@ def storage_from_env(value: Optional[str]) -> Any:
4949
"""
5050
raw = (value or "").strip()
5151
lowered = raw.lower()
52-
if lowered in ("", "local"):
53-
return LocalSharedStorage
54-
if lowered == "none":
52+
if lowered in ("", "none"):
5553
return None
54+
if lowered == "local":
55+
return LocalSharedStorage
5656

5757
try:
5858
scheme = urlparse(raw).scheme.lower()

‎dash/_shared_storage/_persistence.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
keys are sharded across fixed-size chunk files via a key->chunk index, so a
55
single mutation rewrites only the affected chunk rather than the whole store
66
(the design is adapted from raposa's ``BinaryStorage``). Serialization is
7-
msgspec msgpack via :mod:`._codec`; each chunk write is atomic (temp file +
8-
``os.replace``).
7+
msgspec msgpack via :mod:`._codec` (the ``dash[shared_storage]`` extra); each
8+
chunk write is atomic (temp file + ``os.replace``).
99
1010
Two modes drive *when* the store is written:
1111

‎dash/_shared_storage/_transport.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
A single owner process serves one :class:`StoreEngine` over a stream socket
44
(AF_UNIX where available, TCP loopback otherwise). Messages are length-prefixed:
55
a 4-byte big-endian length followed by a body encoded by ``_codec`` (msgspec
6-
msgpack). The codec is data-only -- the socket is
7-
localhost-only, but deserializing untrusted bytes off a socket is a
8-
remote-code-execution hazard, so the transport carries only data. The channel is
6+
msgpack, or JSON without the ``dash[shared_storage]`` extra). The codec is
7+
data-only -- the socket is localhost-only, but deserializing untrusted bytes off
8+
a socket is a remote-code-execution hazard, so the transport carries only data. The channel is
99
additionally gated by a per-owner random token; only same-host clients that read
1010
the owner's advertisement file (written 0600) can attach.
1111

‎dash/_shared_storage/diskcache.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import time
2121
from typing import Any, Optional
2222

23-
from ._codec import decode, encode
23+
from ._codec import decode, encode, require_msgspec
2424
from ._engine import DEFAULT_BUFFER, PollResult
2525
from ._polling import PollingSubscription
2626
from .base import BaseSharedStorage, Subscription, check_topic_ttl
@@ -37,6 +37,7 @@
3737

3838

3939
def _require_diskcache():
40+
require_msgspec("DiskcacheSharedStorage", "diskcache")
4041
try:
4142
import diskcache # type: ignore[import-not-found,import-untyped] # pylint: disable=import-outside-toplevel
4243

‎dash/_shared_storage/local.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import time
2626
from typing import Any, Optional, Tuple
2727

28+
from ._codec import require_msgspec
2829
from ._engine import DEFAULT_BUFFER, PollResult, StoreEngine
2930
from ._persistence import _Persistence, default_store_dir
3031
from ._transport import (
@@ -477,6 +478,7 @@ class LocalSharedStorage(BaseSharedStorage):
477478
``flush_interval`` seconds and on clean exit, and recovered on start; up to
478479
one interval of writes may be lost on an unclean crash.
479480
481+
The persistent modes need the ``dash[shared_storage]`` extra (msgspec).
480482
``path`` is the on-disk store directory for the persistent modes; it defaults
481483
to a per-namespace folder under the user cache directory. ``flush_interval``
482484
(seconds) only applies to ``"persist-reset"``.
@@ -501,6 +503,8 @@ def __init__(
501503
raise SharedStorageError(
502504
f"flush_interval must be positive, got {flush_interval!r}"
503505
)
506+
if mode != "memory":
507+
require_msgspec(f"LocalSharedStorage(mode={mode!r})")
504508
ns = namespace or _default_namespace()
505509
if mode != "memory" and path is None:
506510
path = default_store_dir(ns)

0 commit comments

Comments
 (0)