Skip to content

Commit 606c690

Browse files
committed
Implement session persistence using a UUID cookie; add two-layer state management with in-memory registry and diskcache fallback
1 parent dbc9863 commit 606c690

4 files changed

Lines changed: 733 additions & 102 deletions

File tree

.github/session-management.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# dvue Session Management
2+
3+
## Problem
4+
5+
`panel serve` creates a new Python object for every HTTP request. Navigating
6+
away and back, or restarting the browser, loses all UI state because the
7+
application callable is re-executed in a fresh Bokeh Document.
8+
9+
---
10+
11+
## Design: Manager Registry + Cookie + diskcache Fallback
12+
13+
### Core Idea
14+
15+
Split the application into two lifetimes:
16+
17+
| Object | Lifetime | Notes |
18+
|---|---|---|
19+
| **Manager** (`param.Parameterized`) | Persistent, per user | Holds `time_range`, catalog, math refs, filter params — no Bokeh models |
20+
| **DataUI** (Bokeh Document) | Per session (per browser tab open) | Creates fresh Bokeh models each time; wraps the manager |
21+
22+
A server-side dict (`_MANAGER_REGISTRY`) maps a stable user UUID to the
23+
manager instance. When a returning user opens a new Bokeh session, the
24+
existing manager is retrieved from the registry and wrapped in a fresh
25+
`DataUI`. All param state is already correct — no deserialization needed.
26+
27+
### User Identity: `dvue_user_id` Cookie
28+
29+
- Value: `uuid4().hex` (32 hex chars)
30+
- Lifetime: 365 days (persistent across browser restarts)
31+
- Set by `_SessionAwareDocHandler.get()` on first visit
32+
33+
**Critical timing:** `_SessionAwareDocHandler` overrides `DocHandler.get()`
34+
and injects the generated UUID into `self.request.cookies` (Tornado
35+
`SimpleCookie`) *before* calling `super().get()`. This makes the cookie
36+
visible to `pn.state.cookies` inside the session factory on the very first
37+
visit — before the browser has sent the cookie back.
38+
39+
```python
40+
class _SessionAwareDocHandler(DocHandler):
41+
async def get(self, *args, **kwargs):
42+
user_id = self.get_cookie("dvue_user_id")
43+
if not user_id:
44+
user_id = uuid4().hex
45+
self.set_cookie("dvue_user_id", user_id, expires_days=365, path="/")
46+
self.request.cookies["dvue_user_id"] = user_id # inject for same-request reads
47+
await super().get(*args, **kwargs)
48+
49+
per_app_patterns[0] = (r"/?", _SessionAwareDocHandler)
50+
```
51+
52+
### Session Lifecycle
53+
54+
```
55+
HTTP GET arrives
56+
57+
├─ _SessionAwareDocHandler.get()
58+
│ Set / inject dvue_user_id cookie
59+
│ Call super().get() → Bokeh creates new session → calls make_app()
60+
61+
└─ make_app() (called per session by Panel/Bokeh)
62+
63+
├─ user_id = pn.state.cookies.get("dvue_user_id")
64+
65+
├─ user_id in _MANAGER_REGISTRY?
66+
│ YES → reuse existing manager (all params already set)
67+
68+
└─ NO → create new manager
69+
user_id in diskcache?
70+
YES → _restore_params(mgr, saved) ← server-restart recovery
71+
NO → use defaults
72+
_MANAGER_REGISTRY[user_id] = entry
73+
74+
Create DataUI(manager) → fresh Bokeh Document each time
75+
Register pn.state.onload(_on_load)
76+
77+
pn.state.onload fires (WebSocket opened)
78+
Replay plot_history: for each saved group call plot_cb sequentially
79+
Restore current_selection to display_table
80+
Wire param watchers → _save_state(user_id, ...) on every change
81+
```
82+
83+
### Multi-Browser-Tab Behavior (same user)
84+
85+
Same `dvue_user_id` cookie → same manager instance. Changing `time_range`
86+
in tab A immediately affects tab B because both `DataUI` instances watch the
87+
same `param` object. This is intentional ("sync behavior").
88+
89+
Different users (different cookies) get independent manager instances.
90+
91+
### Two-Layer Persistence
92+
93+
| Layer | When active | What is stored |
94+
|---|---|---|
95+
| **Registry** (in-memory) | Server still running | `manager` instance + `plot_history` + `current_selection` |
96+
| **diskcache** (disk) | Server restart recovery | Picklable params only: `time_range`, `current_selection`, `plot_history` |
97+
98+
diskcache advantages over hand-rolled JSON: built-in TTL, file-locking safe
99+
for concurrent writes, already a Panel dependency (`pn.state.as_cached`).
100+
101+
**Limitation:** diskcache cannot store live Panel/HoloViews/Bokeh objects.
102+
Only plain picklable Python values (dicts, lists, ISO date strings) are stored.
103+
104+
### Plot Tab Restore
105+
106+
Panel widgets (`pn.Tabs`, `pn.Row`, `hv.HoloViews` panes, etc.) maintain their
107+
Python-level state (`.objects`, param values) **independently of any Bokeh
108+
Document**. When `template.servable()` is called for a new Bokeh session,
109+
Panel creates fresh Bokeh models for the new document that mirror the current
110+
Python state — including all plot tabs already present in `_display_panel`.
111+
112+
This means: for the registry hit path (server running), no replay logic is
113+
needed. The `DataUI` object is the same Python object and all its tab content
114+
is automatically mirrored into the new Bokeh Document.
115+
116+
For the diskcache fallback (server restart), the `DataUI` is recreated fresh.
117+
Only the last `current_selection` is saved and a single plot callback is
118+
re-triggered. Multiple open tabs are not restored after a server restart;
119+
this is an acceptable limitation.
120+
121+
### Why `run_server.py` / `pn.serve(callable)`, Not `panel serve script.py`
122+
123+
`per_app_patterns[0]` must be patched before `BokehServer.__init__()`.
124+
With `panel serve script.py`:
125+
- `--setup` runs after the server starts → too late
126+
- Script module-level code runs per session → too late
127+
128+
Only a programmatic launch (`python run_server.py` or `python script.py`)
129+
executes the patch at the right moment. `pn.serve()` must receive a
130+
**callable** (`make_app`), not a file path, so the module-level manager
131+
creation code runs only once.
132+
133+
### Known Limitations
134+
135+
1. `setup_url_sync` on the manager accumulates `param.watch` watchers across
136+
sessions (one per session for the same user). Dead watchers (from ended
137+
sessions) fire harmlessly because they guard `if not pn.state.location:
138+
return`. Net effect: redundant URL writes, not incorrect behavior.
139+
2. `pn.Tabs(dynamic=True)` renders tab content lazily on the client. Layer
140+
1 (registry) avoids this entirely by creating a fresh DataUI each session.
141+
3. `num_procs=1` required: `_MANAGER_REGISTRY` is an in-process dict.
142+
Multi-process deployments must use an external store (Redis, shared memory)
143+
for the registry. The diskcache fallback works with any `num_procs`.
144+
145+
---
146+
147+
## Files
148+
149+
| File | Role |
150+
|---|---|
151+
| `examples/ex_url_state_reset.py` | Phase 0 proof-of-concept (simple selection registry) |
152+
| `examples/ex_tsdataui.py` | Phase 1 full demo (manager registry, plot_history) |
153+
| `dvue/session_persistence.py` | *(planned)* Reusable module extracted from examples |
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Plan: Panel Session Persistence via Persistent UUID Cookie
2+
3+
**Status:** Phase 0 (proof-of-concept) in progress — `examples/ex_url_state_reset.py`
4+
5+
---
6+
7+
## Background
8+
9+
Panel's `panel serve` creates a brand-new Python object (and Bokeh Document) for
10+
every HTTP request. Navigating away and back, or restarting the browser, loses all
11+
UI state. The goal is to persist UI state across browser restarts without requiring
12+
the user to log in.
13+
14+
---
15+
16+
## What Already Exists in dms_datastore_ui
17+
18+
`repoui.py` already:
19+
- Uses `--unused-session-lifetime 2592000000` (30-day TTL) to keep sessions alive
20+
- Uses `--session-token-expiration 2592000000` for JWT tokens
21+
- Writes the Bokeh session ID to the URL query string (`_write_session_id()`)
22+
- Uses `reconnect=True` in `pn.extension()`
23+
24+
**Missing piece:** URL query string is lost on browser restart (only persists for
25+
F5/bookmark). A persistent cookie survives browser restarts.
26+
27+
---
28+
29+
## How Panel's `reuse_sessions` Works (Critical)
30+
31+
- `config.reuse_sessions = True` + `config.session_key_func`
32+
- `DocHandler.get_session()` calls `session_key_func(request)` → looks up
33+
`state._sessions[key]`. If found, returns the *existing* live session (no
34+
re-instantiation!).
35+
- `DocHandler.get()`: if `old_request` flag is True **and** the returned session
36+
matches `state._sessions[key]` → generates a **fresh JWT token** so the returning
37+
browser can open a new WebSocket to the *same* Bokeh Document.
38+
- `session.block_expiration()` is called automatically when storing in
39+
`state._sessions`.
40+
- `old_request` is evaluated *before* `get_session()` is called — so the UUID must
41+
be injected into `request.cookies` in a `get()` override, not `get_session()`.
42+
43+
---
44+
45+
## Two-Layer Persistence
46+
47+
| Scenario | Layer | Mechanism |
48+
|---|---|---|
49+
| Browser restart, server still running | Layer 1 — live object reuse | UUID cookie → `state._sessions[uuid]` → existing Panel Document served |
50+
| Server restart | Layer 2 — param restore | UUID cookie → `{store_dir}/{uuid}.json``manager.param.update()` in `onload` |
51+
52+
---
53+
54+
## Cookie: `dvue_user_id` (generic name for dvue; `dms_user_id` for the app)
55+
56+
- Value: `uuid4().hex` (32 hex chars, same format as Bokeh session IDs)
57+
- `expires_days=365`, `path='/'`
58+
- **First visit:** generated by `SessionAwareDocHandler.get()`, set in *response*
59+
**and** injected into `self.request.cookies` so it is visible to `session_key_func`
60+
and `pn.state.cookies` within the *same* request.
61+
- **Subsequent visits:** browser sends cookie in request → picked up naturally.
62+
63+
### Critical Timing Detail
64+
65+
`DocHandler.get()` evaluates `old_request` before calling `get_session()`, so the
66+
UUID injection must happen in a `get()` override:
67+
68+
```python
69+
class SessionAwareDocHandler(DocHandler):
70+
async def get(self, *args, **kwargs):
71+
if not self.get_cookie('dvue_user_id'):
72+
user_id = uuid4().hex
73+
self.set_cookie('dvue_user_id', user_id, expires_days=365, path='/')
74+
# Inject into request.cookies (SimpleCookie) so session_key_func sees it
75+
# on the *first* visit, before state._sessions has been populated.
76+
self.request.cookies['dvue_user_id'] = user_id
77+
await super().get(*args, **kwargs)
78+
```
79+
80+
`session_key_func` reads `r.cookies['dvue_user_id'].value` (Tornado Morsel).
81+
Panel's `_generate_token_payload` includes all cookies →
82+
`pn.state.cookies.get('dvue_user_id')` works inside `onload`. ✓
83+
84+
### `session_key_func` (set before `pn.serve()`):
85+
86+
```python
87+
from panel.config import config
88+
config.reuse_sessions = True
89+
config.session_key_func = lambda r: (
90+
r.cookies['dvue_user_id'].value
91+
if 'dvue_user_id' in r.cookies
92+
else r.path # fallback: no cookie → isolated session per path
93+
)
94+
```
95+
96+
---
97+
98+
## Why `run_server.py` instead of `panel serve` CLI
99+
100+
`per_app_patterns[0]` must be patched **before** `BokehServer.__init__()`.
101+
102+
With `panel serve script.py`:
103+
- `--setup` script runs *after* the server starts (IOLoop callback) → too late
104+
- Script module-level code runs per session → too late
105+
- Only a programmatic launch (`python run_server.py` calling `pn.serve()`) executes
106+
the patch at the right moment.
107+
108+
---
109+
110+
## Files (dvue scope)
111+
112+
| File | Change |
113+
|---|---|
114+
| `examples/ex_url_state_reset.py` | **Phase 0** proof-of-concept: programmatic launch, cookie handler, live session reuse + JSON fallback |
115+
| `dvue/session_persistence.py` | **Phase 1** reusable module: `SessionAwareDocHandler`, `SessionStateStore`, `install_session_handler()`, `configure_session_management()` |
116+
117+
## Files (dms_datastore_ui scope — after dvue is approved)
118+
119+
| File | Change |
120+
|---|---|
121+
| `dms_datastore_ui/session_persistence.py` | Import and re-export from dvue, or copy with app-specific cookie name |
122+
| `run_server.py` | Programmatic launch; patches before `pn.serve()` |
123+
| `repoui.py` | Import store, restore state in `onload`, add param watchers |
124+
| `run_server.sh` | Change `panel serve repoui.py ...``python run_server.py` |
125+
126+
---
127+
128+
## Params to Persist (dms_datastore_ui)
129+
130+
`DatastoreUIMgr` / `TimeSeriesDataUIManager`: `time_range`, `repo_level`, active
131+
filter values (station / param / agency / subloc), tabulator selected-row indices.
132+
Layout/tab state is client-side only and cannot be round-tripped through JSON.
133+
134+
Use a **whitelist** of param names (not a blacklist) — safer against future param
135+
additions.
136+
137+
---
138+
139+
## Verification Checklist
140+
141+
1. First visit → UUID cookie set in browser; session stored under UUID in
142+
`state._sessions`.
143+
2. Close browser, reopen, navigate to server root (no query string) → cookie sent
144+
→ existing Panel Document served → all widget state intact.
145+
3. Stop server, restart → cookie sent → no session in `state._sessions` → new
146+
session created → `pn.state.onload` reads cookie → loads JSON → restores
147+
filters / time range.
148+
4. Two different browsers → two different UUIDs → independent sessions, no
149+
cross-contamination.
150+
5. Confirm `pn.state.cookies.get('dvue_user_id')` is non-None inside `onload`
151+
(including on the very first visit, because we inject into `request.cookies`
152+
before Panel processes the token payload).
153+
154+
---
155+
156+
## Further Considerations
157+
158+
1. **Memory eviction:** sessions blocked from expiry grow unbounded with many users.
159+
Add TTL eviction: track last-access time in JSON store; in a scheduled task,
160+
remove `state._sessions[uuid]` entries inactive for > N days.
161+
2. **`num_procs=1` required:** multi-process shards `state._sessions` across OS
162+
processes. Layer 1 (live object reuse) does not work with `num_procs > 1`.
163+
Layer 2 (JSON restore) works with any `num_procs`.
164+
3. **`_write_session_id()` redundancy:** once cookie persistence works, writing the
165+
Bokeh session ID to the URL is redundant for returning users. Keep for
166+
backward compatibility (bookmarked `?bokeh-session-id=` URLs still work).

0 commit comments

Comments
 (0)