"""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 Frame 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()