Files
espresso_frame/server/app/db.py
T
tfaour 8bc0749b42
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m5s
Widget system Phase 0: data model + migration
First step of replacing Frame.mode (one renderer owns the whole panel)
with an Android-home-screen-style widget system -- a frame will hold N
independently placed/sized widgets (photos/calendar/whiteboard), each
with its own config/state, plus fully user-assignable NEXT/BACK button
actions. Full plan at .claude/plans/prancy-snacking-iverson.md.

This phase is additive only and changes no existing behavior -- nothing
reads these new tables yet:

- models.py: Widget (placement) + PhotoWidgetConfig/CalendarWidgetConfig/
  WhiteboardWidgetConfig (per-type 1:1 extension tables, matching this
  codebase's existing convention of dedicated tables for naturally-scoped
  state rather than one wide table) + FrameButtonAction (ordered
  (widget, action) bindings per physical button).
- grid.py: pure snap-to-grid placement math, defined relative to the
  panel's long/short axis so it stays valid across
  logical_render_size(orientation)'s genuine width/height swap for
  portrait, not just a rotation applied at the end.
- db.py: widget_locked(), the widget-scoped equivalent of frame_locked()
  -- deliberately still locks at frame granularity (not a new per-widget
  lock) to avoid a new class of multi-lock deadlock bugs.
- migration.py: _migration_16 creates the new tables; a separate
  _ensure_widgets_backfilled() (ORM-based, not raw SQL -- much less
  error-prone for this much per-mode branching) gives every existing
  frame a widget reproducing its exact current mode/settings, so
  upgrading changes nothing about what a frame displays or what its
  buttons do. calendar_photo_inlay frames specifically get two widgets
  (calendar + photo, split like the old inlay did) rather than silently
  losing the photo half.

10 new tests covering fresh-install backfill, re-run idempotency, the
photo-inlay two-widget case, whiteboard's check_now button mapping, and
migration_16's actual CREATE TABLE path against a simulated pre-existing
database (not just the fresh-install create_all() shortcut). Full suite
(69 tests) passes.
2026-07-24 08:27:47 -04:00

118 lines
4.5 KiB
Python

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