Skip to content

Commit bc582ea

Browse files
committed
Add Diskcache and Redis shared-storage backends
LocalSharedStorage only reaches one container, which fragments across pods behind a load balancer (Dash Enterprise scales apps multi-pod). Add two more BaseSharedStorage backends, selected via shared_storage=: - DiskcacheSharedStorage: KV + append-log pub/sub on a diskcache.Cache, shared by every process on one host. Single-machine parity; not for pods. - RedisSharedStorage: KV + Redis Streams pub/sub (atomic INCR+XADD, MAXLEN- bounded, SharedStorageGap past the trimmed floor). Correct across pods. Both reuse a shared PollingSubscription. Tests mirror the local suite; the Redis tests run against the CI redis service and skip when none is reachable.
1 parent 0b537db commit bc582ea

10 files changed

Lines changed: 733 additions & 34 deletions

File tree

‎.ai/ARCHITECTURE.md‎

Lines changed: 65 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -877,47 +877,78 @@ gap rather than a silent hole). A `Subscription` is iterable synchronously
877877
(`for`) or asynchronously (`async for`), is a context manager, and has
878878
`close()`.
879879
880-
### Default Backend: `LocalSharedStorage`
880+
### Backends
881881
882-
In-memory, owner-elected — no external service required:
882+
Three backends ship, differing only in **how far state reaches**. Pick by the
883+
deployment topology — critically, whether the app runs behind a load balancer as
884+
more than one container/pod (see [Deployment Topology](#deployment-topology-and-shared-storage) below).
883885
884-
- On first use, every worker races to become the single **owner** for the
885-
machine by binding a stable address (`AF_UNIX` socket on POSIX, TCP loopback
886-
on Windows). The winner hosts the authoritative in-memory engine; the losers
887-
become clients that proxy over the socket.
888-
- The bind *is* the lease: when the owner dies the address frees and a surviving
889-
worker re-elects — **coming up cold** (state is not durable; a re-election
890-
starts with an empty store).
891-
- A single-process deployment is its own owner and pays **no socket overhead**.
892-
- The channel is gated by a per-owner random token written to a `0600`
893-
advertisement file, so only same-host clients that can read it may attach.
886+
| Backend | Backing store | Reaches | Extra |
887+
|---------|---------------|---------|-------|
888+
| `LocalSharedStorage` *(default)* | in-memory, owner-elected socket | one **container** (all its workers) | none |
889+
| `DiskcacheSharedStorage` | a `diskcache.Cache` | one **host** (all processes sharing the dir) | `dash[diskcache]` |
890+
| `RedisSharedStorage` | Redis (Streams for pub/sub) | **any** process/container/pod | `dash[celery]` (redis) |
894891
895-
Constructor knobs (rarely needed):
892+
All three implement the same `BaseSharedStorage` contract (KV + ordered,
893+
replayable pub/sub with `SharedStorageGap` on buffer overrun), so app code is
894+
identical across them — only the constructor differs.
896895
897896
```python
898-
from dash._shared_storage import LocalSharedStorage
899-
900-
Dash(shared_storage=LocalSharedStorage(
901-
namespace=None, # defaults to a hash of cwd + argv[0] (isolates apps)
902-
buffer_size=2048, # per-topic replay buffer depth
903-
))
904-
```
905-
906-
### Configuration
897+
from dash import Dash
898+
from dash._shared_storage import (
899+
LocalSharedStorage, DiskcacheSharedStorage, RedisSharedStorage,
900+
)
907901
908-
```python
909-
Dash(shared_storage=LocalSharedStorage) # default
910-
Dash(shared_storage=None) # disabled
911-
Dash(shared_storage=MyBackend()) # custom backend
902+
Dash(shared_storage=LocalSharedStorage) # default
903+
Dash(shared_storage=DiskcacheSharedStorage(directory="/tmp/ss"))
904+
Dash(shared_storage=RedisSharedStorage(url="redis://host:6379"))
905+
Dash(shared_storage=None) # disabled
912906
```
913907
914908
When disabled, accessing `app.shared_storage` / `dash.ctx.shared_storage`
915909
raises `SharedStorageError`.
916910
911+
**`LocalSharedStorage`** — in-memory, no external service. On first use every
912+
worker races to become the single **owner** for the machine by binding a stable
913+
address (`AF_UNIX` on POSIX, `127.0.0.1` loopback on Windows); the winner hosts
914+
the engine, losers proxy over the socket. The bind *is* the lease — when the
915+
owner dies the address frees and a survivor re-elects **cold** (state is not
916+
durable). A single-process deployment is its own owner and pays no socket
917+
overhead. Knobs: `namespace` (defaults to a hash of cwd + argv[0]),
918+
`buffer_size` (default 2048).
919+
920+
**`DiskcacheSharedStorage`** — KV + an append-log pub/sub on a `diskcache.Cache`,
921+
the same store `DiskcacheManager` uses; pass an existing `cache` to share one.
922+
Every process on one host that opens the same directory shares state, so it
923+
gives multi-worker parity with the local backend without a socket. **Not for
924+
pods** — each pod has its own ephemeral disk.
925+
926+
**`RedisSharedStorage`** — KV on Redis strings, pub/sub on a Redis Stream per
927+
topic (an atomic `INCR`+`XADD` script keeps sequences ordered; `MAXLEN` bounds
928+
the replay window; a subscriber past the trimmed floor gets `SharedStorageGap`).
929+
Redis is the single source of truth, so no owner election. Pass a `url`
930+
(defaults to `$REDIS_URL`) or an existing `client` to reuse a connection pool.
931+
This is the only backend correct for **horizontally-scaled, multi-pod**
932+
deployments.
933+
934+
### Deployment Topology and Shared Storage
935+
936+
The default `LocalSharedStorage` is **per-container**: its socket/loopback
937+
election only reaches processes in the same network + filesystem namespace.
938+
939+
- **Single process / single pod** (e.g. Plotly Cloud apps): fine — one owner,
940+
nothing to fragment.
941+
- **Multiple gunicorn workers in one container**: fine — they share the pod's
942+
loopback/socket and elect one owner.
943+
- **Multiple pods behind a load balancer** (e.g. Dash Enterprise apps scaled by
944+
an HPA, routed round-robin with no session affinity): **each pod elects its own
945+
isolated owner**, so `set()`/`publish()` on one pod are invisible on another.
946+
This works at 1 pod and fragments silently once it scales — use
947+
`RedisSharedStorage` (one Redis shared by all pods) instead.
948+
917949
**Custom backends** subclass `BaseSharedStorage` and implement `get` / `set` /
918950
`delete` / `publish` / `subscribe` (plus optional `start` / `close`, called once
919-
per worker). This is the seam for a Redis- or database-backed store that shares
920-
state across *machines*, not just processes on one host.
951+
per worker) — the seam for e.g. a database-backed store.
921952
922953
### Module Layout
923954
@@ -926,12 +957,16 @@ state across *machines*, not just processes on one host.
926957
| `_shared_storage/base.py` | Abstract interface: `BaseSharedStorage`, `Subscription`, `SharedStorageError`, `SharedStorageGap` |
927958
| `_shared_storage/_engine.py` | `StoreEngine`: authoritative in-memory kv map + per-topic ordered log with bounded replay buffer (thread-safe, transport-agnostic) |
928959
| `_shared_storage/local.py` | `LocalSharedStorage`: owner election; owner hosts the engine, clients proxy |
929-
| `_shared_storage/_transport.py` | Length-prefixed, token-gated socket transport |
960+
| `_shared_storage/diskcache.py` | `DiskcacheSharedStorage`: KV + append-log pub/sub on a `diskcache.Cache` |
961+
| `_shared_storage/redis.py` | `RedisSharedStorage`: KV + Redis Streams pub/sub (atomic `INCR`+`XADD`) |
962+
| `_shared_storage/_polling.py` | `PollingSubscription`: shared poll-loop subscription for the diskcache/Redis backends |
963+
| `_shared_storage/_transport.py` | Length-prefixed, token-gated socket transport (local backend) |
930964
| `_shared_storage/_codec.py` | msgspec/json codec (data-only) |
931965
| `dash.py` | `shared_storage` constructor arg + lazy `app.shared_storage` property |
932966
| `_callback_context.py` | `dash.ctx.shared_storage` accessor |
933967
934-
Requires the `msgspec` dependency (declared in `requirements/install.txt`).
968+
Requires `msgspec` (in `requirements/install.txt`); `DiskcacheSharedStorage`
969+
needs the `diskcache` extra and `RedisSharedStorage` the `celery` extra (redis).
935970
936971
## Async Callbacks
937972

‎.github/workflows/testing.yml‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ jobs:
7272
matrix:
7373
python-version: ["3.9", "3.12"]
7474

75+
# Redis service for the RedisSharedStorage backend tests.
76+
services:
77+
redis:
78+
image: redis:6
79+
ports:
80+
- 6379:6379
81+
options: >-
82+
--health-cmd "redis-cli ping"
83+
--health-interval 10s
84+
--health-timeout 5s
85+
--health-retries 5
86+
87+
env:
88+
REDIS_URL: redis://localhost:6379
89+
7590
steps:
7691
- name: Checkout repository
7792
uses: actions/checkout@v4
@@ -108,7 +123,7 @@ jobs:
108123
run: |
109124
python -m pip install --upgrade pip wheel
110125
python -m pip install "setuptools<80.0.0"
111-
find packages -name dash-*.whl -print -exec sh -c 'pip install "{}[dev,ci,testing,diskcache]"' \;
126+
find packages -name dash-*.whl -print -exec sh -c 'pip install "{}[dev,ci,testing,diskcache,celery]"' \;
112127
113128
- name: Install dash-renderer dependencies
114129
working-directory: dash/dash-renderer

‎CHANGELOG.md‎

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

77
### Added
8-
- [#3930](https://github.com/plotly/dash/pull/3930) Shared storage: a backend-agnostic state manager available on every app via `dash.ctx.shared_storage` (and `app.shared_storage`). It offers a cross-process key/value store (`get`/`set`/`delete`) and ordered publish/subscribe (`publish`/`subscribe`) for sharing state between callbacks or across worker processes without an external service. The default `LocalSharedStorage` backend elects a single owner process per machine (binding an `AF_UNIX` socket on POSIX, TCP loopback on Windows) and serves the rest; a single-process deployment is its own owner and pays no socket overhead. Subscriptions are ordered and replayable — a consumer that reconnects resumes from its last-seen message from a bounded buffer, and a buffer overrun surfaces as an explicit gap rather than a silent loss. Values must be JSON-compatible (like `dcc.Store`). Started lazily on first use, so it costs nothing until touched; pass `shared_storage=None` to `Dash(...)` to disable, or a `BaseSharedStorage` subclass/instance to swap the backend. The wire codec is msgspec (never pickle).
8+
- [#3930](https://github.com/plotly/dash/pull/3930) Shared storage: a backend-agnostic state manager available on every app via `dash.ctx.shared_storage` (and `app.shared_storage`). It offers a cross-process key/value store (`get`/`set`/`delete`) and ordered, replayable publish/subscribe (`publish`/`subscribe`) for sharing state between callbacks or across worker processes without an external service. Subscriptions resume from a caller's last-seen sequence out of a bounded buffer, and a buffer overrun surfaces as an explicit gap rather than a silent loss. Values must be JSON-compatible (like `dcc.Store`). Started lazily on first use, so it costs nothing until touched; pass `shared_storage=None` to `Dash(...)` to disable. Three backends ship, selected by passing a `BaseSharedStorage` instance to `shared_storage=`: the default `LocalSharedStorage` (in-memory, elects one owner process per machine over an `AF_UNIX`/loopback socket — correct within a single container, including across multiple gunicorn workers); `DiskcacheSharedStorage` (on a `diskcache.Cache`, shared by every process on one host — parity for single-machine deployments, not for multi-pod ones with ephemeral disks); and `RedisSharedStorage` (on Redis, using Redis Streams for the ordered pub/sub — the backend for horizontally-scaled deployments behind a load balancer, e.g. apps scaled across pods).
99
- [#3646](https://github.com/plotly/dash/pull/3646) Experimental support for React 19. The default is still React 18.3.1; to use React 19 set the environment variable `REACT_VERSION=19.2.4` before running your app, or call `dash._dash_renderer._set_react_version("19.2.4")` inside the app. React 19 has no official UMD builds, so Dash serves the [`umd-react`](https://www.npmjs.com/package/umd-react) package, together with a compatibility shim loaded after react-dom and before any component package. The shim keeps component libraries built against React <=18 (e.g. dash-bootstrap-components, dash-mantine-components) working under React 19: it stubs the removed `ReactCurrentOwner` internals, redirects the legacy element `$$typeof` symbol so pre-bundled React 18 jsx-runtimes produce elements React 19 accepts (error #525), and exposes a global `react/jsx-runtime` (`window.ReactJSXRuntime`) that Dash's own component bundles externalize to. Component library authors adopting this convention should copy the defensive `jsxRuntimeExternal` webpack external from `components/dash-core-components/webpack.config.js` rather than a bare `'ReactJSXRuntime'` string: it falls back to a `React.createElement`-based runtime when the global is missing, so the same build also works on Dash versions older than this release.
1010

1111
### Removed

‎dash/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@
4545

4646
from ._shared_storage import ( # noqa: F401,E402
4747
BaseSharedStorage,
48+
DiskcacheSharedStorage,
4849
LocalSharedStorage,
50+
RedisSharedStorage,
4951
SharedStorageError,
5052
SharedStorageGap,
5153
Subscription,
@@ -103,7 +105,9 @@ def _jupyter_nbextension_paths():
103105
"hooks",
104106
"stringify_id",
105107
"BaseSharedStorage",
108+
"DiskcacheSharedStorage",
106109
"LocalSharedStorage",
110+
"RedisSharedStorage",
107111
"SharedStorageError",
108112
"SharedStorageGap",
109113
"Subscription",

‎dash/_shared_storage/__init__.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Backend-agnostic shared state + pub/sub (state manager).
22
3-
Exposes the ``BaseSharedStorage`` interface and the default
4-
``LocalSharedStorage`` backend (owner-elected, cross-process).
3+
Exposes the ``BaseSharedStorage`` interface and its backends:
4+
5+
- ``LocalSharedStorage`` (default): in-memory, owner-elected -- one container.
6+
- ``DiskcacheSharedStorage``: on a ``diskcache.Cache`` -- one machine, not pods.
7+
- ``RedisSharedStorage``: on Redis -- across processes and containers.
58
"""
69

710
from .base import (
@@ -10,11 +13,15 @@
1013
SharedStorageGap,
1114
Subscription,
1215
)
16+
from .diskcache import DiskcacheSharedStorage
1317
from .local import LocalSharedStorage
18+
from .redis import RedisSharedStorage
1419

1520
__all__ = [
1621
"BaseSharedStorage",
22+
"DiskcacheSharedStorage",
1723
"LocalSharedStorage",
24+
"RedisSharedStorage",
1825
"SharedStorageError",
1926
"SharedStorageGap",
2027
"Subscription",

‎dash/_shared_storage/_polling.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Subscription for poll-based backends.
2+
3+
A backend that can answer ``poll(topic, after_seq, timeout) -> PollResult``
4+
builds its live subscriptions from this: it drives the poll loop, tracks the
5+
cursor, resumes from it on every call, and turns a buffer overrun into
6+
``SharedStorageGap``. ``LocalSharedStorage`` has its own reconnecting
7+
subscription (it also handles owner re-election); this serves the diskcache and
8+
Redis backends, whose store is always reachable.
9+
"""
10+
import asyncio
11+
import threading
12+
from typing import Callable
13+
14+
from ._engine import PollResult
15+
from .base import SharedStorageGap, Subscription
16+
17+
PollFn = Callable[[str, int, float], PollResult]
18+
19+
20+
class PollingSubscription(Subscription):
21+
"""Iterate a topic by repeatedly polling ``poll_fn`` from a moving cursor."""
22+
23+
def __init__(
24+
self, topic: str, start_seq: int, poll_fn: PollFn, poll_timeout: float
25+
):
26+
self._topic = topic
27+
self._cursor = start_seq
28+
self._poll_fn = poll_fn
29+
self._poll_timeout = poll_timeout
30+
self._closed = threading.Event()
31+
32+
def close(self) -> None:
33+
self._closed.set()
34+
35+
def _gap(self) -> SharedStorageGap:
36+
return SharedStorageGap(f"replay buffer overran on topic {self._topic!r}")
37+
38+
def __iter__(self):
39+
try:
40+
while not self._closed.is_set():
41+
res = self._poll_fn(self._topic, self._cursor, self._poll_timeout)
42+
if res.gap:
43+
raise self._gap()
44+
yield from res.messages
45+
self._cursor = res.last_seq
46+
finally:
47+
self.close()
48+
49+
def __aiter__(self):
50+
return self._aiter()
51+
52+
async def _aiter(self):
53+
loop = asyncio.get_running_loop()
54+
try:
55+
while not self._closed.is_set():
56+
try:
57+
res = await loop.run_in_executor(
58+
None,
59+
self._poll_fn,
60+
self._topic,
61+
self._cursor,
62+
self._poll_timeout,
63+
)
64+
except RuntimeError:
65+
# The loop/executor is shutting down -- end cleanly.
66+
break
67+
if res.gap:
68+
raise self._gap()
69+
for message in res.messages:
70+
yield message
71+
self._cursor = res.last_seq
72+
finally:
73+
self.close()

0 commit comments

Comments
 (0)