Shared storage: backend-agnostic state manager + pub/sub - #3930
Conversation
A cross-process key/value store and ordered publish/subscribe available on every app via dash.ctx.shared_storage / app.shared_storage, for sharing state between callbacks or across worker processes without an external service. The default LocalSharedStorage elects a single owner process per machine (binding an AF_UNIX socket on POSIX, TCP loopback on Windows -- the bind is the lease, re-elected on owner death) and serves the rest; a single-process deployment is its own owner and pays no socket overhead. Started lazily on first use, so it costs nothing until touched and never binds in a gunicorn preload master or the Flask reloader parent. Pass shared_storage=None to disable, or a BaseSharedStorage subclass/instance to swap the backend. Subscriptions are ordered and replayable: a consumer that reconnects resumes from its last-seen message out of a bounded buffer, and a buffer overrun surfaces as an explicit gap rather than a silent loss -- the property streaming will rely on. The wire codec is msgspec (msgpack), never pickle, so bytes off the socket cannot execute code; values must be JSON-compatible like dcc.Store. This is phase 1; streaming callbacks move onto it next.
An async subscription drives its blocking poll via loop.run_in_executor; when the event loop / executor is torn down (a client disconnects, or the app stops) that raises "cannot schedule new futures after shutdown" out of a streaming callback. Catch it and end the subscription cleanly instead of logging a traceback -- notably for long-lived pub/sub subscriptions (e.g. a live feed).
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.
Give RedisSharedStorage its own extra instead of borrowing redis from the celery extra. Same pin as celery.txt for now; in Dash 5.0 redis moves out of the celery extra and lives here only. Point the ImportError, docs, and CI at dash[redis].
The typing-compliance test runs mypy over an app that imports dash, surfacing 7 errors in the shared-storage code: - redis/diskcache: ignore the optional-dep imports (not installed / unstubbed in the typing job), matching the background managers' pattern - annotate _publish_script and Dash._shared_storage_instance so they aren't inferred as None-only - assert the coordinator's family/token are set before connect_to_owner
Parametrize the Dash-app integration tests over all three backends (Local, Diskcache, Redis-if-reachable) and add scenarios that exercise the real callback-dispatch pipeline: KV round-trip, state shared across two callbacks, a counter persisting across independent dispatches, and two separate Dash apps sharing one backend store. Redis params skip when no server is reachable.
Replace the HTTP-dispatch integration tests with real dash_duo browser tests: start a server, click buttons, assert on the rendered DOM. The counter scenario (one callback writes a shared value, another reads it) runs on Local, Diskcache, and Redis-when-reachable. These need a browser, so move the whole tests/shared_storage suite out of the lint-unit job into a dedicated shared-storage CI job with a Redis service and setup-chrome (modeled on the background-callbacks job), run with --headless.
|
| @@ -0,0 +1,37 @@ | |||
| """Codec for the shared-storage socket transport. | |||
|
|
|||
| Prefers ``msgspec`` (msgpack: fast, compact) and falls back to stdlib ``json``. | |||
There was a problem hiding this comment.
This looks like a Claude-ism: is there some condition that demands a real reason to "fall back"? I'd argue we should pick 1 implementation and keep it DRY.
| # Per-topic replay buffer size. Sized to cover a reconnect window comfortably; | ||
| # a producer that outruns a disconnected consumer past this raises a gap rather | ||
| # than dropping messages silently. | ||
| DEFAULT_BUFFER = 2048 |
There was a problem hiding this comment.
I'm a bit uneasy about this: it looks like this means we will keep old messages around in a buffer? We are going to have to maintain code paths for this, and it has a limited use-case.
In a traditional callback, if there's a network blip, you must re-trigger the callback anyway. It's a design choice that I feel we should not change just because we're streaming now.
As implemented, the feature is "general purpose" (very cool btw!). It will also be useful for publishing larger datasets from a shared location. A callback that publishes a 2MB dataset will silently retain 2048 of them (4GB per topic!). Could OOM or fill up disk space, and it's not obvious that this will happen.
I'm inclined to suggest that old data just be discarded - no buffer. If the client falls behind for some reason, then they need to trigger the callback again.
Alternatively, I suggest making the buffer 0 by default: safe out-of-the-box but can be enabled if you know what you're doing.



Split out from #3888 (Streaming callbacks + shared storage) so it can be reviewed on its own. This PR is phase 1: the backend-agnostic shared storage layer. The streaming work builds on top of it and stays on #3888 for now.
What this adds
Shared storage: a backend-agnostic state manager available on every app via
dash.ctx.shared_storage(andapp.shared_storage).get/set/delete) and ordered publish/subscribe (publish/subscribe) for sharing state between callbacks or across worker processes without an external service.LocalSharedStorageelects a single owner process per machine (binding anAF_UNIXsocket on POSIX, TCP loopback on Windows — the bind is the lease, re-elected on owner death) and serves the rest; a single-process deployment is its own owner and pays no socket overhead.shared_storage=NonetoDash(...)to disable, or aBaseSharedStoragesubclass/instance to swap the backend.dcc.Store).Contributor Checklist
pytest tests/shared_storage— 17 passed).tests/shared_storage/, plus a dedicated CI job).CHANGELOG.md.