Skip to content

fix: local-path contract for Lexicon/Engine, one-way NAS sync, and cleanup - #49

Open
rancur wants to merge 2 commits into
mainfrom
fix/path-contract-and-sync
Open

fix: local-path contract for Lexicon/Engine, one-way NAS sync, and cleanup#49
rancur wants to merge 2 commits into
mainfrom
fix/path-contract-and-sync

Conversation

@rancur

@rancur rancur commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Why

The library had drifted across two roots and Engine DJ had lost its database. Three compounding failures, fixed at the source rather than papered over with a periodic repair job.

1. The worker wrote to the share ROOT, not Database/. 339 audio files across 189 artist folders ended up outside the library that Lexicon and Engine actually read. MUSIC_LIBRARY_PATH is now /music/Database, and it's wired through docker-compose.yml — it was hardcoded there, so setting it in .env did nothing.

The bind mount deliberately still points at the share root. Remapping it instead would have broken all ~4,300 existing /music/Database/... file_path rows and Plex's path translation. This was the one non-obvious call in the change.

2. Lexicon was handed /Volumes/* paths, which Engine DJ refuses. That — not SMB — is why new tracks never reached Engine: 40 rows carried a /Volumes/Macintosh HD/ symlink prefix while only 3 were actually on SMB. lexicon_library_path is now /Users/willcurran/Music/Database.

3. Synology Drive two-way synced the entire music share. It conflict-copied Engine's live 595 MB SQLite database 12 times (~9.4 GB) and jammed permanently retrying SoundSwitch project files the server rejects. Replaced with scripts/sync-nas-to-mac.sh — one-way NAS→Mac, so conflict copies are structurally impossible.

How

sync-nas-to-mac.sh is a hybrid: change detection over SSH (the NAS walks its own disk, ~0.9 s) and transfer over SMB. A full SMB scan takes 5m46s; an incremental pass takes 1.3 s. A 6 h reconcile is the safety net. Rsync-over-SSH isn't available — DSM refuses rsync --server for non-admin users.

Local paths cost replication lag, so tasks/sync_gate.py holds each import until the file has landed on the Mac, keyed off a heartbeat the sync agent publishes into Input/. It fails open on every degenerate case (missing, malformed, stale, or error heartbeat) and bounds the hold — a gate that can deadlock the pipeline is worse than the lag it prevents. 10 tests pin that contract down.

scripts/repoint-lexicon-local.sh gains local-mode execution and the ~/Music/<Artist>~/Music/Database/<Artist> mapping. It's now marked a one-shot: it fixed this once on 2026-07-13 and the problem returned because nothing automated it. Now that new tracks arrive canonical, finding work again is a regression signal, not routine maintenance.

New tooling

  • scripts/consolidate-share-root.py — the root → Database/ migration. Non-destructive on collisions: byte-identical copies are quarantined rather than deleted, differing ones kept side by side with a __fromroot suffix.
  • scripts/dedupe-report.py — read-only duplicate/quality analysis. Separates redundant Lexicon rows from real duplicate files from archive upgrade candidates, and reads remix descriptors from the parent folder as well as the filename, so a Maduk remix isn't reported as a duplicate of the original.

Cleanup

  • Delete MatcherService / DownloaderService / VerifierService — 463 lines referenced nowhere; that logic lives in sync-worker/tasks/.
  • main.py hardcoded version="2.1.0" while VERSION said 2.10.1; now reads /app/VERSION.
  • admin.py compared versions as strings, so "2.9.0" > "2.10.1" was True — the update banner was inverted across exactly the boundary this deployment was sitting on.
  • deep-repair.sh computed a repair verdict then dispatched to a commented-out stub, so it could never have alerted anyone. Wired to WAXFLOW_ALERT_WEBHOOK.
  • backup-lexicon.sh deleted (self-documented no-op; backup-lexicon-db.sh is the real one).
  • bump-version.sh now updates every version source, not just VERSION.
  • Stale /Users/openclaw/spotify-lexicon-sync/ path in the backup plist.
  • CLAUDE.md gains a path-contract section and no longer claims "no test suite exists" (there are 22 test modules).

Not included

A CodeQL change adding javascript-typescript (sync-web's ~2,700 lines of TS are unscanned) is held back — the gh token lacks workflow scope. Patch is at ~/WaxFlow-Backups/codeql-typescript.patch.

Verification

  • sync_gate tests: 10/10 pass.
  • All 5,852 Lexicon rows verified on local /Users/... paths, zero /Volumes/*.
  • Consolidation verified: 339 files accounted for, share root holds only the keep-list, 0 sync gaps between NAS and Mac.
  • Not yet verified in production: the rebuilt containers. The NAS Docker daemon has a wedged container lock (a docker compose restart stuck for 5h43m, predating this work) and needs a daemon restart before the new images can run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HmUiLHPmKoz215WAWV5eHe

…eanup

The library had drifted across two roots and Engine DJ had lost its database.
Three compounding failures, all now fixed at the source rather than papered over
with a periodic repair job.

1. The worker wrote finished audio to the share ROOT, not Database/. 339 files
   across 189 artist folders ended up outside the library Lexicon and Engine read.
   MUSIC_LIBRARY_PATH is now /music/Database and is wired through docker-compose
   (it was hardcoded there, so setting it in .env did nothing). The bind mount
   deliberately still points at the share root so Plex path translation and the
   ~4,300 existing /music/Database/... file_path rows keep resolving -- remapping
   the mount instead would have broken every one of them.

2. Lexicon was handed /Volumes/* paths, which Engine DJ refuses. That, not SMB,
   is why new tracks never reached Engine: 40 rows carried a
   /Volumes/Macintosh HD/ symlink prefix while only 3 were actually on SMB.
   lexicon_library_path is now /Users/willcurran/Music/Database.

3. Synology Drive two-way synced the whole music share. It conflict-copied
   Engine's live 595 MB SQLite database 12 times (~9.4 GB) and jammed permanently
   on SoundSwitch project files. Replaced with scripts/sync-nas-to-mac.sh: one-way
   NAS->Mac, so conflict copies are structurally impossible. SSH-side change
   detection plus SMB transfer takes 1.3 s per pass versus 5m46s for a full SMB
   scan, with a 6 h reconcile as the safety net.

Local paths cost replication lag, so tasks/sync_gate.py holds each import until
the file has landed on the Mac, keyed off a heartbeat the sync agent publishes.
It fails open on every degenerate case -- a gate that can deadlock the pipeline is
worse than the lag it prevents. 10 tests cover that contract.

scripts/repoint-lexicon-local.sh gains local-mode execution and the
~/Music/<Artist> -> ~/Music/Database/<Artist> mapping, and is marked a one-shot:
it fixed this once on 2026-07-13 and the problem returned because nothing
automated it. Now that new tracks arrive canonical, finding work again is a
regression signal.

Also in this change:
- scripts/consolidate-share-root.py: the root -> Database/ migration, non-
  destructive on collisions (byte-identical copies are quarantined, differing
  ones kept side by side).
- scripts/dedupe-report.py: read-only duplicate/quality analysis. Separates
  redundant Lexicon rows from real duplicate files from archive upgrade
  candidates, and detects remix descriptors in the parent folder as well as the
  filename so different mixes are not reported as duplicates.
- Delete MatcherService/DownloaderService/VerifierService (463 lines, referenced
  nowhere; that logic lives in sync-worker/tasks/).
- main.py read its version from /app/VERSION instead of a hardcoded "2.1.0".
- admin.py compared versions as strings, so "2.9.0" > "2.10.1" was True and the
  update banner was inverted across exactly the boundary this deploy sat on.
- CodeQL scanned Python only; sync-web's TypeScript was never analysed.
- deep-repair.sh computed a verdict then dispatched to a commented-out stub, so
  it could never have alerted anyone. Wired to WAXFLOW_ALERT_WEBHOOK.
- backup-lexicon.sh removed (self-documented no-op; backup-lexicon-db.sh is real).
- bump-version.sh now updates every version source, not just VERSION.
- CLAUDE.md documents the path contract and corrects "no test suite exists".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmUiLHPmKoz215WAWV5eHe


def _db(**config) -> str:
path = tempfile.mktemp(suffix=".db")
Two problems found by running the finished system, not by reading it.

1. LEXICON RE-ADDS THE SYMLINK PREFIX. The path-contract fix stops WaxFlow
   emitting /Volumes paths, but it is NOT sufficient: Lexicon canonicalises an
   imported location through the boot-volume symlink, rewriting the clean
   /Users/willcurran/Music/Database/... path WaxFlow hands it into
   /Volumes/Macintosh HD/Users/... — which Engine DJ refuses.

   Proven live: the first import after the cutover (id 11288, 2026-08-09 16:26)
   was stored with that prefix despite WaxFlow sending a clean /Users path. This
   is the true origin of the 40 Engine-invisible rows, and it means
   repoint-lexicon-local.sh is a recurring chore, not the one-shot the previous
   commit claimed.

   Since it cannot be prevented at the source, make it impossible to MISS: a
   read-only (mode=ro, safe while Lexicon runs) count of /Volumes/* locations on
   every sync pass, logged loudly with the fix command. This exact drift went
   unnoticed from March to August and cost the Engine library.

2. THE HEARTBEAT WAS FILLING #recycle. The share has Synology's recycle bin
   enabled, and writing the heartbeat as write-tmp-then-mv made every replace a
   deletion — one #recycle entry every 120 s, forever. Truncate in place instead.
   A torn read is harmless: sync_gate.py fails open on unparseable JSON by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmUiLHPmKoz215WAWV5eHe
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.

2 participants