Skip to content

Shared storage: backend-agnostic state manager + pub/sub - #3930

Open
T4rk1n wants to merge 11 commits into
devfrom
feat/shared_storage
Open

Shared storage: backend-agnostic state manager + pub/sub#3930
T4rk1n wants to merge 11 commits into
devfrom
feat/shared_storage

Conversation

@T4rk1n

@T4rk1n T4rk1n commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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 (and app.shared_storage).

  • 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 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 Dash(...) 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 wire codec is msgspec (msgpack), never pickle, so bytes off the socket cannot execute code; values must be JSON-compatible (like dcc.Store).

Contributor Checklist

  • I have run the tests locally and they passed (pytest tests/shared_storage — 17 passed).
  • I have added tests, or extended existing tests, to cover any new features in this PR (unit + cross-process integration under tests/shared_storage/, plus a dedicated CI job).
  • I have added an entry in the CHANGELOG.md.

T4rk1n added 2 commits July 31, 2026 09:14
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).
T4rk1n added 9 commits July 31, 2026 09:15
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.
@sonarqubecloud

Copy link
Copy Markdown

@@ -0,0 +1,37 @@
"""Codec for the shared-storage socket transport.

Prefers ``msgspec`` (msgpack: fast, compact) and falls back to stdlib ``json``.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +19 to +22
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants