1"""Per-user live working state, keyed on the caller's identity.
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.
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.
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.
23from __future__
import annotations
30from dataclasses
import dataclass, field
31from pathlib
import Path
32from typing
import Callable, Optional, TypeVar
34from fastapi
import Request
35from starlette.concurrency
import run_in_threadpool
37from backend
import userdata
42_PROJECT_ROOT = Path(__file__).resolve().parents[1]
43_DEFAULT_CONFIG_PATH = _PROJECT_ROOT /
"configs" /
"default.yaml"
48SESSION_TTL_SECONDS = int(os.environ.get(
"SESSION_TTL_SECONDS", str(2 * 60 * 60)))
54MAX_CONCURRENT_JOBS = int(os.environ.get(
"MAX_CONCURRENT_JOBS",
"3"))
55JOB_SEMAPHORE = asyncio.Semaphore(MAX_CONCURRENT_JOBS)
61 """A blank optimizer job-status block (one per layer)."""
74 """Per-user optimizer job state (was the module globals in optimizer.py).
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.
80 optimization_status: dict = field(default_factory=_fresh_status)
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)
89 """Per-user controller instance (was the module globals in control.py)."""
91 controller: object |
None =
None
92 controller_logger: object |
None =
None
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."""
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():
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:
138 print(f
"Warning: Could not load default config for session: {exc}")
145 """Thread-safe ``user -> UserSession`` map with idle eviction."""
151 def get(self, user: str) -> UserSession:
152 now = time.monotonic()
159 session.last_used = now
163 """Drop sessions idle past the TTL. Called under the lock."""
164 if SESSION_TTL_SECONDS <= 0:
169 if now - s.last_used > SESSION_TTL_SECONDS
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.
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.
191 with session.compute_lock:
192 return fn(*args, **kwargs)
194 return await run_in_threadpool(locked)
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``.
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.
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
214 """Hold JOB_SEMAPHORE for the lifetime of an async generator (an SSE stream).
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
222 async with JOB_SEMAPHORE:
223 async for item
in agen:
228 """FastAPI dependency: the caller's live session.
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.
234 return registry.get(userdata.current_user(request))
UserSession get(self, str user)
None _evict_expired(self, float now)
None _load_default_config(self)
None __init__(self, str user)
T run_compute(UserSession session, Callable[..., T] fn, *args, **kwargs)
UserSession get_session(Request request)
str replace_config(UserSession session, config, Optional[str] config_path=None)