|
| 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