fix: atomic import swap, disk/DB replace consistency, guarded pipeline loop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-11 15:27:48 +02:00
parent fe973343ad
commit 88ef4149d9
5 changed files with 119 additions and 26 deletions
+20 -10
View File
@@ -46,19 +46,29 @@ def _find_cover(staging: str) -> str | None:
def import_album(staging: str, final: str) -> str: def import_album(staging: str, final: str) -> str:
"""Move the album's audio files (+ one cover image) from `staging` into a fresh `final`, """Atomically place the album's audio (+ one cover) at `final`, replacing any existing folder.
replacing any existing folder there. Non-audio artifacts and nested subfolders are left Assembles into a sibling temp dir, then swaps via rename so a mid-assembly failure never
behind (the caller removes `staging`). Tolerates a missing `staging`. Returns `final`.""" destroys the existing copy. Non-audio artifacts/subfolders in `staging` are left behind.
if os.path.isdir(final): Tolerates a missing `staging`. Returns `final`."""
shutil.rmtree(final) os.makedirs(os.path.dirname(final), exist_ok=True)
os.makedirs(final, exist_ok=True) tmp = final + ".importing"
if not os.path.isdir(staging): shutil.rmtree(tmp, ignore_errors=True)
return final os.makedirs(tmp)
if os.path.isdir(staging):
for name in sorted(os.listdir(staging)): for name in sorted(os.listdir(staging)):
src = os.path.join(staging, name) src = os.path.join(staging, name)
if os.path.isfile(src) and os.path.splitext(name)[1].lower() in _AUDIO_EXT: if os.path.isfile(src) and os.path.splitext(name)[1].lower() in _AUDIO_EXT:
shutil.move(src, os.path.join(final, name)) shutil.move(src, os.path.join(tmp, name))
cover = _find_cover(staging) cover = _find_cover(staging)
if cover is not None: if cover is not None:
shutil.move(cover, os.path.join(final, "cover.jpg")) shutil.move(cover, os.path.join(tmp, "cover.jpg"))
# swap: keep the old copy until the new one is assembled, then rename into place
if os.path.isdir(final):
old = final + ".old"
shutil.rmtree(old, ignore_errors=True)
os.rename(final, old)
os.rename(tmp, final)
shutil.rmtree(old, ignore_errors=True)
else:
os.rename(tmp, final)
return final return final
+4
View File
@@ -40,8 +40,12 @@ def run_forever() -> None:
time.sleep(IDLE_SLEEP) time.sleep(IDLE_SLEEP)
continue continue
print(f"worker: claimed job {job_id}", flush=True) print(f"worker: claimed job {job_id}", flush=True)
try:
run_pipeline(conn, job_id, adapters, resolver=resolver, tagger=tagger, run_pipeline(conn, job_id, adapters, resolver=resolver, tagger=tagger,
upgrade_cutoff=mcfg.quality_cutoff) upgrade_cutoff=mcfg.quality_cutoff)
except Exception as e: # one bad job must not take down the worker loop
print(f"worker: pipeline failed for job {job_id}: {e}", flush=True)
conn.rollback()
if mcfg.enabled: if mcfg.enabled:
try: try:
reconcile(conn, job_id, mcfg) reconcile(conn, job_id, mcfg)
+23 -5
View File
@@ -80,6 +80,16 @@ def _already_in_library_at_cutoff(conn: psycopg.Connection, target: MBTarget, cu
return cur.fetchone() is not None return cur.fetchone() is not None
def _library_quality(conn: psycopg.Connection, target: MBTarget) -> int | None:
with conn.cursor() as cur:
cur.execute(
'SELECT "qualityClass" FROM "LibraryItem" WHERE artist = %s AND album = %s',
(target.artist, target.album),
)
row = cur.fetchone()
return row[0] if row else None
def _persist_candidates(conn: psycopg.Connection, job_id: str, candidates: list[Candidate]) -> None: def _persist_candidates(conn: psycopg.Connection, job_id: str, candidates: list[Candidate]) -> None:
with conn.cursor() as cur: with conn.cursor() as cur:
for c in candidates: for c in candidates:
@@ -211,16 +221,24 @@ def run_pipeline(
return return
final = album_dir(dest_root, target) final = album_dir(dest_root, target)
import_album(staging, final) # clean audio(+cover) move into the library, replacing any prior copy existing_q = _library_quality(conn, target)
new_q = quality_class(winner.quality)
_set_state(conn, job_id, "imported", "import")
if existing_q is None or new_q > existing_q:
import_album(staging, final) # clean audio(+cover) move, atomically replacing any prior copy
if tagger is not None: if tagger is not None:
try: try:
tagger.tag_album(final, target) tagger.tag_album(final, target)
except Exception as e: # a tagging failure must not discard a good download except Exception as e: # a tagging failure must not discard a good download
print(f"pipeline: tagging failed for job {job_id}: {e}", flush=True) print(f"pipeline: tagging failed for job {job_id}: {e}", flush=True)
# 6. import (DB)
_set_state(conn, job_id, "imported", "import")
_import(conn, job_id, target, winner, final) _import(conn, job_id, target, winner, final)
else:
# an existing copy is already at >= this quality — keep it untouched, just complete the request
with conn.cursor() as cur:
cur.execute(
"UPDATE \"Request\" SET status = 'completed' WHERE id = %s",
(_request_id(conn, job_id),),
)
conn.commit()
finally: finally:
shutil.rmtree(staging, ignore_errors=True) # a partial/failed download never persists shutil.rmtree(staging, ignore_errors=True) # a partial/failed download never persists
+12
View File
@@ -41,6 +41,18 @@ def test_import_album_tolerates_missing_staging(tmp_path):
assert os.path.isdir(final) and os.listdir(final) == [] assert os.path.isdir(final) and os.listdir(final) == []
def test_import_album_leaves_no_temp_siblings(tmp_path):
final = tmp_path / "Artist" / "Album"
final.mkdir(parents=True)
(final / "old.flac").write_bytes(b"old")
staging = tmp_path / "stg"
staging.mkdir()
(staging / "01 New.flac").write_bytes(b"new")
import_album(str(staging), str(final))
assert sorted(os.listdir(final)) == ["01 New.flac"] # replaced
assert os.listdir(tmp_path / "Artist") == ["Album"] # no .importing / .old leftovers
def test_clear_staging_root_removes_staging(tmp_path): def test_clear_staging_root_removes_staging(tmp_path):
stg = tmp_path / ".staging" / "j1" stg = tmp_path / ".staging" / "j1"
stg.mkdir(parents=True) stg.mkdir(parents=True)
+49
View File
@@ -68,3 +68,52 @@ def test_retry_imports_clean_with_no_orphans(conn, tmp_path):
final = tmp_path / "Radiohead" / "In Rainbows" final = tmp_path / "Radiohead" / "In Rainbows"
# exactly the 3 real tracks — the attempt-1 "orphan-a.flac" is NOT present # exactly the 3 real tracks — the attempt-1 "orphan-a.flac" is NOT present
assert sorted(os.listdir(final)) == ["0 track.flac", "1 track.flac", "2 track.flac"] assert sorted(os.listdir(final)) == ["0 track.flac", "1 track.flac", "2 track.flac"]
def test_existing_equal_quality_copy_is_kept_not_replaced(conn, tmp_path):
# An existing lossy (q1) copy on disk + DB. A monitored upgrade job whose only candidate is also
# q1 (FakeYouTube) must NOT replace the files or the DB row.
import os as _os
from lyra_worker.adapters.fakes import FakeYouTube
from lyra_worker.library import album_dir
from lyra_worker.types import MBTarget
target = MBTarget(artist="A", album="B")
final = album_dir(str(tmp_path), target)
_os.makedirs(final, exist_ok=True)
open(_os.path.join(final, "existing.opus"), "wb").close()
with conn.cursor() as cur:
cur.execute(
"INSERT INTO \"MonitoredRelease\" (id, \"artistMbid\", \"artistName\", \"rgMbid\", album, "
"\"secondaryTypes\", monitored, state, \"currentQualityClass\", \"createdAt\") "
"VALUES (gen_random_uuid()::text, 'a1', 'A', 'rg1', 'B', '{}', true, 'grabbed', 1, now()) RETURNING id"
)
rel_id = cur.fetchone()[0]
cur.execute(
"INSERT INTO \"Request\" (id, artist, album, status, \"monitoredReleaseId\", \"createdAt\") "
"VALUES (gen_random_uuid()::text, 'A', 'B', 'pending', %s, now()) RETURNING id",
(rel_id,),
)
req = cur.fetchone()[0]
cur.execute(
"INSERT INTO \"LibraryItem\" (id, \"requestId\", artist, album, path, source, format, "
"\"qualityClass\", \"importedAt\") "
"VALUES (gen_random_uuid()::text, %s, 'A', 'B', %s, 'youtube', 'OPUS', 1, now())",
(req, final),
)
cur.execute(
"INSERT INTO \"Job\" (id, \"requestId\", state, \"currentStage\", attempts, \"createdAt\", \"updatedAt\") "
"VALUES (gen_random_uuid()::text, %s, 'requested', 'intake', 0, now(), now()) RETURNING id",
(req,),
)
job_id = cur.fetchone()[0]
conn.commit()
run_pipeline(conn, job_id, [FakeYouTube()], dest_root=str(tmp_path), upgrade_cutoff=2)
with conn.cursor() as cur:
cur.execute('SELECT state FROM "Job" WHERE id = %s', (job_id,))
assert cur.fetchone()[0] == "imported"
cur.execute('SELECT count(*) FROM "LibraryItem" WHERE artist=%s AND album=%s AND source=%s', ("A", "B", "youtube"))
assert cur.fetchone()[0] == 1 # the original row is untouched
assert _os.path.isfile(_os.path.join(final, "existing.opus")) # the existing files were kept