"""Engine, sessions, and the per-frame lock that replaces the old whole-config.json RLock. Single uvicorn worker (see Dockerfile) -- handlers are sync and run in the threadpool, so this is ordinary multi-threading in one process: the same regime the old config.locked() RLock handled, now scoped per frame. """ from __future__ import annotations import os import threading from contextlib import contextmanager from typing import Iterator from sqlalchemy import create_engine, event from sqlalchemy.orm import Session, sessionmaker from .models import WIDGET_CONFIG_MODELS, Frame, Widget DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db") _is_sqlite = DATABASE_URL.startswith("sqlite") engine = create_engine( DATABASE_URL, connect_args={"check_same_thread": False} if _is_sqlite else {}, ) if _is_sqlite: @event.listens_for(engine, "connect") def _sqlite_pragmas(dbapi_connection, _record): cursor = dbapi_connection.cursor() cursor.execute("PRAGMA journal_mode=WAL") cursor.execute("PRAGMA foreign_keys=ON") cursor.execute("PRAGMA busy_timeout=5000") cursor.close() # expire_on_commit=False so a Frame resolved by the require_device # dependency (which commits its last_seen touch) stays usable in the # route handler without a re-select per attribute. Freshness inside # mutation spans is handled explicitly by frame_locked()'s refresh. SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) def get_db() -> Iterator[Session]: """FastAPI dependency: one session per request (FastAPI caches the dependency, so require_device and the route handler share it).""" db = SessionLocal() try: yield db finally: db.close() # One lock per frame id, created on demand. Guarded by a module lock so # two threads can't race to create different Lock objects for the same # frame (which would defeat the whole point). _frame_locks: dict[int, threading.Lock] = {} _frame_locks_guard = threading.Lock() def _get_lock(frame_id: int) -> threading.Lock: with _frame_locks_guard: lock = _frame_locks.get(frame_id) if lock is None: lock = threading.Lock() _frame_locks[frame_id] = lock return lock @contextmanager def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]: """Serializes a whole read-modify-write span on one frame -- the direct successor of the old config.locked(). The refresh() inside the lock is what makes it correct: without it the session could hold attribute state read *before* another thread's committed write, and saving would silently clobber it (the same lost-update race the old pattern's 're-read inside the lock' comment guarded against).""" with _get_lock(frame_id): frame = db.get(Frame, frame_id) if frame is None: raise LookupError(f"Frame {frame_id} does not exist") db.refresh(frame) yield frame db.commit() @contextmanager def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]: """Same lock/refresh/commit dance as frame_locked, additionally resolving and refreshing the widget's own per-type config row (PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig/ TaskWidgetConfig, see models.WIDGET_CONFIG_MODELS). Deliberately still locks at *frame* granularity -- the exact same per-frame threading.Lock frame_locked uses, not a separate per-widget lock -- simplest, avoids a new class of multi-lock deadlock bugs, and this project's actual concurrency needs are tiny (a handful of users per household frame). threading.Lock is not reentrant: a caller executing several widget actions in one pass (e.g. a button press assigned multiple (widget, action) pairs, see routers/device.py) MUST call this once per action, sequentially, never nested inside an outer frame_locked/widget_locked span for the same frame -- nesting would deadlock instantly, not just misbehave.""" with frame_locked(db, frame_id) as frame: widget = db.get(Widget, widget_id) if widget is None or widget.frame_id != frame_id: raise LookupError(f"Widget {widget_id} does not belong to frame {frame_id}") config_model = WIDGET_CONFIG_MODELS[widget.widget_type] config = db.get(config_model, widget_id) if config is None: raise LookupError(f"Widget {widget_id} has no {widget.widget_type} config row") db.refresh(config) yield frame, widget, config