Skip to content

Migrate db and fix retention sweep#12

Merged
RobXYZ merged 3 commits into
mainfrom
fix/state-db-and-retention-defer
May 16, 2026
Merged

Migrate db and fix retention sweep#12
RobXYZ merged 3 commits into
mainfrom
fix/state-db-and-retention-defer

Conversation

@RobXYZ

@RobXYZ RobXYZ commented May 16, 2026

Copy link
Copy Markdown
Owner

Fixed

  • DB now lives in the /config volume rather than the recordings
    mount for better performance when the recordings are on
    slower storage.
  • The startup retention sweep runs in the background rather than
    blocking the UI when there's a large delete backlog.

Changed

  • Retention sweep logs progress: a header when the time-phase has
    work, then a line every 10 deletions. Previously silent until the
    end-of-sweep summary.

Migration

  • An existing DB at ${RECORDINGS}/.viofosync.db is copied to
    ${CONFIG_DIR}/viofosync.db on first boot under v2.1. The legacy
    file is renamed to .viofosync.db.migrated on the recordings
    volume as a recoverable fallback.

RobXYZ added 3 commits May 16, 2026 12:32
The SQLite state DB used to live at $RECORDINGS/.viofosync.db, putting
every per-write fcntl() syscall through the NAS-backed recordings
mount. On Synology DSM with NFS, that costs up to 30 s per write while
NFS write-back flushes the freshly-downloaded MP4 — observed
empirically.

Two new public helpers in web/db.py:

  * default_db_path() — resolves the new location, $CONFIG_DIR/viofosync.db
    (default /config/viofosync.db, a local volume).
  * migrate_legacy_db_path(new_path) — on first boot, copies any DB
    at the legacy $RECORDINGS/.viofosync.db location to the new path
    and renames the legacy file to .viofosync.db.migrated so an
    operator has a recoverable copy. Idempotent.

The migration is called explicitly from web/app.py's lifespan, NOT
from Database.__init__. This keeps test isolation tight: test code
constructing Database(tmp_path) never reads the host's RECORDINGS env
var.

Implementation details:

  * Atomic main-file copy via tmp = new_path + ".part" + os.replace,
    so a crash mid-copy leaves the new path absent and the next boot
    retries cleanly.
  * -wal / -shm sidecar copy failures log WARNING and continue
    (SQLite reconstructs from the main file alone if these are missing
    or stale).
  * Legacy-rename failure logs WARNING and continues — harmless,
    leaves the operator with the original file still in place.
  * Main-copy failure propagates: loud startup failure is preferable
    to silently opening an empty fresh DB.

Tests: 7 new in tests/test_db_migration.py covering happy path,
no-overwrite, legacy-missing, sidecar-failure-non-fatal, the
Database.__init__ isolation guarantee, and both default_db_path
branches.

docker-compose.yml comment updated to direct operators to /config
for the state DB.
The startup retention sweep used to run synchronously inside the
FastAPI lifespan, before `yield`. On a deployment with a large
backlog of >max_days clips, this blocked the UI for tens of minutes
(1126 clips ≈ 210 GB observed on Synology). uvicorn stayed at
"Waiting for application startup" the entire time, with no progress
signal because retention.sweep() only logged totals at the end.

Changes:

  * Move the startup sweep into asyncio.create_task(_background_retention())
    after the existing initial-scan task. The UI is reachable within
    seconds of "Started server process" regardless of backlog size.
  * Add an INFO header log when the time-phase has work, so the operator
    can see the sweep started:
        "retention sweep: N clip(s) older than D days — examining"
    Says "examining" rather than "deleting" because the count is the
    pre-filter row total; RO-protected rows are excluded inside the
    loop. The end-of-sweep summary reports the real deleted count.
  * Add an every-10-deletions progress log:
        "retention sweep: N/M clip(s) deleted (X.X MB freed so far)"
    so a long sweep is visibly progressing rather than silent.
  * Promote `from .services import retention as _ret_mod` to module
    scope in web/app.py (was inline).
  * Extend the shutdown finally block to cancel app.state.retention_task
    alongside the existing initial_scan_task.

Tests: 3 new in tests/test_retention.py (header logs when work,
silence when no work, every-10 progress). 2 new in
tests/test_lifespan_retention.py (background task created during
lifespan, source-inspection that shutdown cancels retention_task).

No change to retention eligibility rules. The sync-worker's
post-cycle piggyback retention call (web/services/sync_worker.py)
is intentionally untouched — current cadence (runs after each
productive sync cycle) is adequate for the always-on dashcam use
case.
@RobXYZ RobXYZ merged commit cffc66b into main May 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant