Skip to content

Add DASH_SECRET_KEY and DASH_SHARED_STORAGE platform hooks - #4026

Open
T4rk1n wants to merge 5 commits into
devfrom
feat/platform-streaming-env
Open

T4rk1n wants to merge 5 commits into
devfrom
feat/platform-streaming-env

Conversation

@T4rk1n

@T4rk1n T4rk1n commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Streaming callbacks (#3931) and shared storage (#3930) break once an app runs on several workers or pods, and a hosting platform (Plotly Cloud, Dash Enterprise, self-hosted) can't fix that without editing app code. This adds two env vars, following the existing DASH_* convention, and a warning for the failure that is silent today.

Changes

  • DASH_SECRET_KEY: the signing secret is now looked up as server.secret_key > DASH_SECRET_KEY > secret saved in the background-callback store > random per process. Used for Dash's own signing only and never copied onto server.secret_key, so Flask sessions are untouched. An empty value counts as unset.
    • Without it, on 4.5.0rc0 with gunicorn -w 4 and no secret_key, most downlink polls return 403 (25 of 40 measured).
  • DASH_SHARED_STORAGE: picks the backend when the app doesn't pass shared_storage=: local, none, diskcache:///abs/path, or a redis:// / rediss:// URL.
    • shared_storage now has a sentinel default, so an explicit argument, None included, always wins.
    • The value becomes a factory. Nothing is built or connected until app.shared_storage is first read.
    • A missing dash[redis] / dash[diskcache] raises the same ImportError as the explicit path, at Dash() construction.
    • cluster:// is reserved and raises "not supported in this version". Anything else raises InvalidConfig naming the variable and value, with URL credentials replaced by ***.
  • Warning: when a stream request (uplink, downlink, cancel; all three backends) sends a token that fails verification, log a warning once per process pointing at server.secret_key / DASH_SECRET_KEY. Still 403. A request with no token is not logged.

No behavior change for apps that set neither variable.

Tests

  • Unit: secret precedence at all four levels, every storage scheme, garbage values, explicit argument beats env, matching ImportError text, credential redaction, warning fires once.
  • tests/streaming/test_stream_wsgi.py: gunicorn -w 4.
    • With DASH_SECRET_KEY: every poll is 200 and a stream completes in the browser. Fails before this change.
    • Without it: polls on the worker that served the page are 200, all others 403. Keeps the regression visible.
    • The test app tags responses with the worker pid, and polls go out concurrently until at least two workers have answered. Sent one by one, they tend to all land on one idle worker, and the test would prove nothing.
  • dash_duo test selecting Redis through DASH_SHARED_STORAGE. Skips without Redis, like the other Redis tests.

A hosting platform can now give every worker and pod the same signing
secret (DASH_SECRET_KEY) and pick the shared-storage backend
(DASH_SHARED_STORAGE) without editing the app. Without a shared secret,
multi-worker stream requests 403 silently; the first failure in each
process now logs a warning.
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Dash performance benchmarks

✅ all within thresholds

scenario metric p90 (ms) median growth baseline p90 note
✅ callback_chain chain_ms 421.2 406.6 0.9x 499.1
✅ callback_chain graph_ms 1.5 1.5 1.0x 2.4
✅ callback_fanout fanout_ms 81.3 75.1 0.88x 92.5
✅ deep_nesting render_ms 57.4 54.6 1.04x 56.8
✅ full_children_replace replace_ms 5446.8 1959.2 18.88x 4697.1
✅ initial_render_large render_ms 573.8 548.7 1.01x 694.4
✅ initial_render_small render_ms 99.2 94.7 0.97x 104.0
✅ patch_append_nested append_ms 131.9 89.4 2.13x 192.3
✅ patch_append_toplevel append_ms 150.4 91.2 2.36x 140.2
✅ patch_scalar_update_large update_ms 146.1 132.7 1.02x 202.9
✅ wildcard_all_resolve wildcard_ms 272.6 258.0 0.94x 313.6
✅ wildcard_all_resolve graph_ms 1.2 1.2 1.0x 1.3

growth = late-third / early-third per-op time; ~1 is flat, a large value means the per-op cost scales with accumulated state.

machine scale vs baseline: 0.99x - divided out of the baseline ratios so they compare like for like (the absolute warn/fail ceilings are left un-scaled); calibrated on initial_render_small.

@T4rk1n
T4rk1n marked this pull request as ready for review October 5, 2026 14:40
@T4rk1n
T4rk1n requested a review from camdecoster October 5, 2026 14:40

@camdecoster camdecoster left a comment

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.

Approved. I made a few small comments, but nothing blocking. Is the docs update for the env vars on Jessica's radar?

Could you please address the merge conflicts?

Comment thread dash/_shared_storage/_env.py Outdated
scheme = urlparse(raw).scheme.lower()
if scheme in ("redis", "rediss"):
_require_redis()
return functools.partial(RedisSharedStorage, url=raw)

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.

Should users be able to set the redis key prefix when using the env var? I don't think they'll be able to with the current implementation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a key_prefix query param on the Redis URL, e.g. redis://host:6379/0?key_prefix=myapp. It's stripped before the URL goes to redis-py, other query params pass through.

Comment thread dash/_shared_storage/_env.py Outdated
Comment on lines +20 to +22
def _redact(value: str) -> str:
# Keep credentials in a URL out of the error message.
return re.sub(r"(://)[^/@]*@", r"\1***@", value)

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 regex pattern can still leak secrets. Try running the following through:

valkey://default:Zx9/Qm+4kP@cache.internal:6379/0

I'd recommend just removing this function and skipping including a redacted secret. A description of the error should be enough to point users in the right direction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, removed _redact. Errors no longer include the value at all, just the reason. Added your URL to the test.

_require_redis()
return functools.partial(RedisSharedStorage, url=raw)
if scheme == "diskcache":
parsed = urlparse(raw)

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 won't parse % codes in URLs as is (though maybe that's rare?).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed, the diskcache path is now unquoted. Redis URLs were already fine since redis-py decodes user/password itself; added tests for both.

@sonarqubecloud

sonarqubecloud Bot commented Oct 9, 2026

Copy link
Copy Markdown

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