STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
session.py
Go to the documentation of this file.
1"""Per-user live working state, keyed on the caller's identity.
2
3The saved-config *library* (``/api/configs``, ``backend/userdata.py``) was already
4per-user, but the app's *live* working state -- the config currently loaded, its
5runner, an in-flight optimization, the controller instance -- used to be process
6globals. Two people connected at once therefore shared one design and clobbered
7each other: uploads overwrote each other, an optimization refused to start while
8another user's ran, results landed in a single slot the wrong user could read,
9and one user's "stop" cancelled another's run.
10
11This module gives each user their own ``UserSession`` holding all of that live
12state. Identity is ``X-Auth-Email`` (via ``userdata.current_user``); with no Caddy
13in front (local dev) there is no header, so everything collapses onto a single
14``local`` session and the app behaves exactly as it did as a single-user global.
15
16Endpoints obtain their session with ``session = Depends(get_session)`` and read
17and write through it (``session.app_state``, ``session.optimizer``,
18``session.controller``) instead of module globals. Because the heavy optimizer
19jobs run inside nested closures, capturing ``session`` there propagates the right
20user into the thread pool for free -- no ContextVar plumbing.
21"""
22
23from __future__ import annotations
24
25import asyncio
26import os
27import threading
28import time
29import uuid
30from dataclasses import dataclass, field
31from pathlib import Path
32from typing import Callable, Optional, TypeVar
33
34from fastapi import Request
35from starlette.concurrency import run_in_threadpool
36
37from backend import userdata
38from backend.state import AppState
39from engine.pipeline.io import load_config
40
41# backend/session.py -> backend -> <project root>. The default config lives here.
42_PROJECT_ROOT = Path(__file__).resolve().parents[1]
43_DEFAULT_CONFIG_PATH = _PROJECT_ROOT / "configs" / "default.yaml"
44
45# Drop a user's live session after this many seconds idle, so memory does not
46# grow without bound as people come and go. The next request rebuilds it from the
47# default config -- the same state a fresh visitor gets.
48SESSION_TTL_SECONDS = int(os.environ.get("SESSION_TTL_SECONDS", str(2 * 60 * 60)))
49
50# Cap on concurrent *heavy* jobs (optimizer runs, controller sims) across ALL
51# users. Per-user state removes cross-user corruption; this keeps N users from
52# each kicking off a CPU-bound optimization at once and melting the box. Total,
53# not per user. Acquired by the streaming endpoints around the run.
54MAX_CONCURRENT_JOBS = int(os.environ.get("MAX_CONCURRENT_JOBS", "3"))
55JOB_SEMAPHORE = asyncio.Semaphore(MAX_CONCURRENT_JOBS)
56
57T = TypeVar("T")
58
59
60def _fresh_status() -> dict:
61 """A blank optimizer job-status block (one per layer)."""
62 return {
63 "running": False,
64 "progress": 0.0,
65 "stage": "",
66 "message": "",
67 "results": None,
68 "error": None,
69 }
70
71
72@dataclass
74 """Per-user optimizer job state (was the module globals in optimizer.py).
75
76 ``stop_event`` is created fresh for each run so a user's cancel only touches
77 their own optimization; ``stop_event_lock`` guards the swap.
78 """
79
80 optimization_status: dict = field(default_factory=_fresh_status) # layer 1
81 layer2_status: dict = field(default_factory=_fresh_status)
82 layer3_status: dict = field(default_factory=_fresh_status)
83 stop_event: Optional[threading.Event] = None
84 stop_event_lock: threading.Lock = field(default_factory=threading.Lock)
85
86
87@dataclass
89 """Per-user controller instance (was the module globals in control.py)."""
90
91 controller: object | None = None
92 controller_logger: object | None = None
93
94
96 """One user's live working state. Created with the default config loaded, so
97 ``app_state.has_config()`` is true on first touch -- the same starting point
98 the app used to establish at boot for its single global."""
99
100 def __init__(self, user: str) -> None:
101 #: The identity this session belongs to (sanitized email, or "local" in
102 #: dev). A path-safe segment -- userdata already sanitized it -- so it is
103 #: reusable for per-user scratch paths (e.g. config upload staging).
104 self.user = user
105 #: New for every session object. A backend restart or an idle eviction makes a fresh
106 #: session holding configs/default.yaml; the design bar compares this to the epoch it
107 #: loaded the design into, so it puts the design back instead of autosaving the
108 #: default over it (2026-10-01: a restart saved methalox over an ethalox design).
109 self.epoch = uuid.uuid4().hex
110 #: New for every wholesale config replacement (POST /api/config/load, an upload); None
111 #: until the first one, i.e. while the session still holds configs/default.yaml. The
112 #: session is one per user, shared by every tab, so a tab that opened design X can load X
113 #: over the design Y another tab holds. A tab compares this with the token its own last
114 #: load got back and never autosaves a config some other tab put here; a read-only tab
115 #: puts its design back after a restart only while this is still None.
116 self.load_token: str | None = None
117 #: Held while a solve runs on this session's runner (``run_compute``). The solves run
118 #: off the event loop now, so two of them -- the page's Forward evaluate and its
119 #: Time-Series run land a second apart -- would otherwise share one runner, whose
120 #: chamber solver keeps per-call state; on the loop they were one at a time anyway.
121 self.compute_lock = threading.Lock()
125 self.last_used = time.monotonic()
127
128 def _load_default_config(self) -> None:
129 """Load configs/default.yaml, mirroring the old main.py lifespan. Best
130 effort: a missing or bad default leaves the session config-less, exactly
131 as the startup path did."""
132 if not _DEFAULT_CONFIG_PATH.exists():
133 return
134 try:
135 config_obj = load_config(str(_DEFAULT_CONFIG_PATH))
136 self.app_state.set_config(config_obj, str(_DEFAULT_CONFIG_PATH))
137 except Exception as exc: # pragma: no cover - matches old best-effort load
138 print(f"Warning: Could not load default config for session: {exc}")
139
140 def touch(self) -> None:
141 self.last_used = time.monotonic()
142
143
145 """Thread-safe ``user -> UserSession`` map with idle eviction."""
146
147 def __init__(self) -> None:
148 self._sessions: dict[str, UserSession] = {}
149 self._lock = threading.Lock()
150
151 def get(self, user: str) -> UserSession:
152 now = time.monotonic()
153 with self._lock:
154 self._evict_expired(now)
155 session = self._sessions.get(user)
156 if session is None:
157 session = UserSession(user)
158 self._sessions[user] = session
159 session.last_used = now
160 return session
161
162 def _evict_expired(self, now: float) -> None:
163 """Drop sessions idle past the TTL. Called under the lock."""
164 if SESSION_TTL_SECONDS <= 0:
165 return
166 expired = [
167 user
168 for user, s in self._sessions.items()
169 if now - s.last_used > SESSION_TTL_SECONDS
170 ]
171 for user in expired:
172 del self._sessions[user]
173
174
175registry = SessionRegistry()
176
177
178async def run_compute(session: UserSession, fn: Callable[..., T], /, *args, **kwargs) -> T:
179 """Run a solve on the session's runner in the thread pool, one at a time per session.
180
181 Forward evaluate and the Time-Series runs are seconds of CPU (a Time-Series run is ~10 s
182 on a laptop, ~23 s on a CI runner). As ``async def`` endpoints calling the runner inline
183 they ran on the event loop and froze every other request for that long -- /api/health
184 included, so a page opened while another page's load-time Time-Series run was going sat on
185 "Connecting", and the design bar's autosave could not reach the server either (the
186 Playwright failures of 2026-10-17). Off the loop the server keeps answering; the lock keeps
187 a session's solves serial, as they were.
188 """
189
190 def locked() -> T:
191 with session.compute_lock:
192 return fn(*args, **kwargs)
193
194 return await run_in_threadpool(locked)
195
196
197async def replace_config(session: UserSession, config, config_path: Optional[str] = None) -> str:
198 """Put a whole new config in the session: off the event loop, with a new ``load_token``.
199
200 Every route that swaps the design wholesale (an upload, a design opened, a YAML handed to a
201 Layer or the Time-Series tab) goes through here, so none of them forgets either half.
202 ``set_config`` builds the runner -- a cold CEA cache is minutes of CPU, which inline on the
203 loop froze every request. The token is how a second tab of the same user (one session per
204 user) sees that this is no longer the design it loaded and stops autosaving into it; a
205 replacement that kept the old token let that tab write the newcomer over its own design.
206 Returns the new token, for the response.
207 """
208 await run_in_threadpool(session.app_state.set_config, config, config_path)
209 session.load_token = uuid.uuid4().hex
210 return session.load_token
211
212
213async def gated_stream(agen):
214 """Hold JOB_SEMAPHORE for the lifetime of an async generator (an SSE stream).
215
216 Wrap the generator handed to ``StreamingResponse`` so a heavy streamed job
217 (a controller simulation) counts against the global concurrency cap for as
218 long as it runs, and releases the slot when the stream ends or the client
219 disconnects. Lets us cap those without reindenting the long loops that
220 produce them.
221 """
222 async with JOB_SEMAPHORE:
223 async for item in agen:
224 yield item
225
226
227def get_session(request: Request) -> UserSession:
228 """FastAPI dependency: the caller's live session.
229
230 Same identity key as the saved-config library (``X-Auth-Email`` in prod via
231 Caddy, ``local`` in dev). Never raises and never denies -- auth is Caddy's
232 job; this only decides whose in-memory state to use.
233 """
234 return registry.get(userdata.current_user(request))
UserSession get(self, str user)
Definition session.py:151
None _evict_expired(self, float now)
Definition session.py:162
None _load_default_config(self)
Definition session.py:128
None __init__(self, str user)
Definition session.py:100
T run_compute(UserSession session, Callable[..., T] fn, *args, **kwargs)
Definition session.py:178
UserSession get_session(Request request)
Definition session.py:227
str replace_config(UserSession session, config, Optional[str] config_path=None)
Definition session.py:197
gated_stream(agen)
Definition session.py:213
dict _fresh_status()
Definition session.py:60