Redesign phase A: SQLite storage, per-frame data model, device identity
Replaces the single global config.json (whole-file pydantic model under one RLock) with SQLite via SQLAlchemy 2.0: users/sessions/frames/links/ pending-claims/battery_log tables (models.py), a per-frame lock registry (db.frame_locked) succeeding config.locked(), and hand-rolled schema versioning (migration.py). A pre-database deployment's config.json is imported verbatim as frame #1 on first boot and left untouched as the rollback path; the old single firmware.bin slot becomes per-frame firmware/<id>.bin. Routes split out of the 900-line main.py into routers/device.py (the frozen /frame/* protocol) and routers/api.py (web UI, still on the old single-frame paths for now). Device auth moves to require_device, which already speaks the full multi-frame protocol: per-frame device tokens pushed via /frame/config and acknowledged on first use, self- registration of unknown device ids as unclaimed frames, pending-claim attachment, and the legacy-token migration window that keeps the currently-deployed firmware (no id, shared MANAGEMENT_TOKEN) resolving to frame #1 -- including the one-time binding of its device id when it first reports one after a future OTA. Externally identical for existing deployments: same paths, same token semantics, same response shapes -- verified with a migration fixture, the legacy-device curl suite, a 20-way concurrent-advance smoke test, and a mutate-restart-assert persistence check against a fake Immich. photo_queue.py ports nearly verbatim onto the Frame ORM row (MutableList JSON columns make its in-place list mutations dirty-track); quiet-hours math extracted unchanged into quiet_hours.py.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user