Skip to content

feat: make the retag backfill safe to run unattended - #55

Merged
rancur merged 12 commits into
mainfrom
feat/backfill-safe-unattended
Sep 23, 2026
Merged

rancur merged 12 commits into
mainfrom
feat/backfill-safe-unattended

Conversation

@rancur

@rancur rancur commented Sep 23, 2026

Copy link
Copy Markdown
Owner

v2.5.0 shipped the retag backfill with gaps stated in its own release notes, and an operator instruction to "run it with limit under supervision". limit didn't even paginate — no ORDER BY, no offset, so successive batches re-selected the same rows. This closes the gaps and removes that instruction.

Plan: docs/superpowers/plans/2026-09-23-backfill-safe-unattended.md

What changed

  • Idempotence — the tagger skips files already carrying their target tags. A re-run costs ~nothing instead of 498 GB, which is what makes resumption meaningful rather than theoretical. A retitled mix, or one whose artwork was regenerated, is still re-tagged (cover art is compared by content, not presence).
  • Real pagination + a resume cursor — deterministic ordering shared by the pre-flight count and the run, offset, and a cursor persisted after each mix so a kill -9 leaves something usable.
  • Durable activity events — retag_run_started / retag_mix_processed / retag_run_completed / retag_run_failed, carrying both legs' status and reason, so the log alone answers "was every file tagged?"
  • A staging sweeper — reclaims orphaned multi-GB temps, refuses any directory not named .fadeout-tagging, refuses anything outside the allowlist, and never touches a file that could belong to a run in flight.

Five review rounds, and what they caught

Each round found something the previous round's tests couldn't see. Worth recording, because the pattern is the point:

  1. Following the documented flow tagged nothing. A dry run wrote the cursor; the real run resumed past everything and returned 202 started:true, 0/0 processed, at info level.
  2. The sweeper deleted files it was actively writing. shutil.copy2 propagates the source's mtime, so a freshly staged copy of a 200-day-old archival FLAC reported as 4800 hours old instantly. The test named "the most important test in this file" faked its timestamps with os.utime, so it never built a file the production way.
  3. The same bug returned wearing a different flag. disabled counted as terminal success — so a real run with the setting off advanced the cursor across the whole backlog, and enabling the flag then resuming tagged nothing. Identical shape to (1).
  4. A failed rename advanced the cursor and was never retried — permission_denied, read_only_fs, exactly what resume exists for.
  5. Four fixes had no test at all. Re-introducing each left all 115 tests green, including the delete-primitive bug: a "report-only" dry run performing real deletions, reintroducible by a one-token edit.

Tests

125 passing, ruff clean. Every load-bearing fix is mutation-guarded and verified individually — reverting in pairs is how round 4 hid a gap where one test passed with either half of its own fix removed.

Operational preconditions

  • Do not pass limit with resume=true — confirmed livelock past a permanently non-terminal mix. Documented in the README, and the README's own example command was fixed (it previously told you to do exactly this).
  • offset runs must not be mixed with resume runs.
  • Confirm tag_source_files is ON before the real run — with it off the pass is wasted and no cursor is persisted.
  • Expect resume_cursor_ignored: true on the first real resume after a dry run. That's the mode guard working.

Known, non-blocking

any_alarming_skip still matches a reason string, so a future rewording would produce a false warn on a healthy run — false alarm only, no data risk. The structural "terminal" key has no test of its own.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UoFoJWJX28B7nM6HfriV7M

Barry Allen and others added 12 commits September 22, 2026 23:44
Add already_tagged(path, tags, cover_art_path), a header-only mutagen
read that compares an existing FLAC's Vorbis comments and embedded-
picture presence against the target tag set. Wire it into
tag_sources_for_mix so an already-tagged file returns status="skipped",
reason="already tagged" instead of paying a full out-of-place rewrite
of a multi-gigabyte file. A dry run checks the same condition so its
report matches what a real run would actually do -- overstating the
work here is exactly the false-green this project has been burned by
before a 498 GB operation.

This is the foundation the resume cursor in Task 2 builds on: making a
re-run nearly free is what makes the backfill safe to interrupt and
repeat without a human watching it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoFoJWJX28B7nM6HfriV7M
…ackfill

_run_retag and the candidate-count query both used .limit(n) with no
ORDER BY and no offset, so a second batch re-selected the same rows the
first already touched -- the documented "run it with limit under
supervision" workflow for the 498 GB retag backfill never actually
paginated.

- Add offset/resume query params to POST /api/catalog/retag.
- Add an identical, deterministic ORDER BY (created_at, id) + OFFSET to
  both the pre-flight candidate count and _run_retag's selection, via a
  single shared statement builder so the two can never drift apart.
- Persist {last_mix_id, processed, total, dry_run, updated_at} to
  AppSettings.settings_json["retag_progress"] after every mix (not just
  at the end), committing immediately so a kill -9 mid-run leaves a
  usable cursor.
- resume=true resolves that cursor into an offset and continues past
  it; with no saved cursor (or one pointing at a since-deleted mix) it
  starts from the beginning rather than erroring.

The single-flight 409 guard is untouched: the resume-cursor DB read
happens in an earlier, separate async-with block, so the check-then-set
window between the guard and the _retag_task assignment stays
await-free.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoFoJWJX28B7nM6HfriV7M
Today the retag backfill's per-mix results live only in the module-level
_retag_state dict, wiped on process restart -- after an unattended run
(or any run followed by a container restart) there was no way to answer
"what did it actually do to my files?" for an operation that rewrites
~498 GB of irreplaceable FLAC masters.

_run_retag now emits durable events through app.services.activity_log:
one retag_run_started event with dry_run/limit/offset/resume and the
candidate total, exactly one retag_mix_processed event per mix carrying
its mix_id and both the rename and tag outcome (error level when either
leg failed, info otherwise), and one retag_run_completed event with the
final summary. Every emit goes through a new _emit_retag_activity helper
that wraps the call in its own try/except, following the same pattern as
handlers._emit_activity and auth._emit_auth -- an activity-log failure
must never abort a run touching irreplaceable masters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoFoJWJX28B7nM6HfriV7M
.fadeout-tagging accumulates multi-gigabyte temps when a retag run dies
between staging and promotion. Nothing reclaimed them, the directory is
hidden from the watcher, and the resulting disk shortage surfaced only as
a routine "skipped" from the free-space check.

Add source_tagger.sweep_staging(root, older_than_hours, dry_run), guarded
by basename == TEMP_DIRNAME, source_renamer.allowed_roots() (reused, not
duplicated), and mtime age -- a fresh temp may belong to a run in flight
and is never removed. Symlinks are never followed or removed; a failure
on one entry logs and continues rather than aborting the sweep.

_run_retag now sweeps each watch root's staging directory before
selecting candidates and records what it reclaimed via the existing
best-effort activity-log helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoFoJWJX28B7nM6HfriV7M
Updates README's retag/backfill section to match what actually shipped on
this branch: already-tagged files are skipped instead of fully rewritten,
limit+offset genuinely paginate (ordered selection), resume=true continues
from a persisted cursor, every run emits durable activity-log events, and
orphaned .fadeout-tagging/ staging copies are swept automatically at the
start of each run.

Removes the "run it with limit under supervision" guidance and the "limit
does not paginate" warning -- both existed only because of the four gaps
this branch closes, and left in place they would calibrate operators on
stale, now-false claims. Adds a v2.6.0 CHANGELOG entry in the existing
style.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoFoJWJX28B7nM6HfriV7M
…y content

Blocker 4: shutil.copy2's copystat carries the SOURCE file's mtime onto a
freshly staged copy, so a copy of a months-old archival recording reported
an age of thousands of hours the instant it was staged -- the sweeper's
"never removes a file younger than the threshold" guard did not protect
it at all, and a sweep landing while write_tagged_copy was still working
could delete it mid-write. write_tagged_copy now stamps the staged copy
with the current time immediately after copying, and sweep_staging now
ages off the more recent of st_mtime/st_ctime (ctime cannot be backdated
by copystat) rather than st_mtime alone -- belt and braces on a delete
primitive.

test_staging_sweeper.py's existing "old file" tests relied on os.utime
backdating alone, which cannot touch ctime (a bare os.utime call always
bumps ctime to the moment of the call, regardless of the mtime/atime it
sets) -- so they'd have silently started failing under the new ctime-aware
guard. They now use a fake_stat fixture that substitutes a controlled
st_ctime at the exact os.stat call sweep_staging makes. A new test stages
a copy through the REAL write_tagged_copy from a genuinely old-mtime
source and simulates a sweep landing mid-write (the actual race the
review reproduced) -- the test this bug needed from the start, since a
synthetic backdated-mtime fixture can't reproduce copystat's behaviour,
and calling sweep_staging only after write_tagged_copy returns can't
observe the bug either (audio.save()'s own write naturally self-heals
mtime by the time the function returns).

Finding 6: already_tagged() compared only embedded-picture PRESENCE
against cover_art_path's existence, so a mix whose artwork was
regenerated at the same path -- same file, different bytes -- kept its
stale embedded art forever. It now compares size and a SHA-256 digest of
the embedded picture against the file on disk; the picture bytes are
already in memory from the header read this function does anyway, so this
adds no extra I/O against the multi-gigabyte source.
…-reporting work

Blocker 1: a dry run's resume cursor could govern a real run's
resume=true. The README's own documented flow -- a bare (dry-run) POST
over the whole backlog, then a real run -- left the real run resuming
past a cursor the dry run wrote, believing every mix up to it was already
done: it touched nothing while still reporting {"started": true}. The
persisted cursor now records the dry_run it was written under, and
resume=true against a cursor from the other mode is treated as no cursor
at all (starts from the beginning) rather than resolved into an offset.
Chose this over namespacing the cursor by mode to keep the persisted
shape unchanged (existing tests read a single flat cursor) -- the POST
response's new resume_cursor_ignored field says when this happened.

Blocker 2: the resume cursor advanced past every mix regardless of
outcome, so a failed write or a skip for a condition that can resolve
itself (missing source, outside the allowed roots, insufficient space)
was skipped forever by a later resume=true. It now only advances past
tag outcomes that are terminal-success (written, disabled with the
setting off, or already carrying the target tags/art) -- and, since the
cursor is an OFFSET into the ordered candidate list rather than a
per-mix ledger, once one mix in a run isn't terminal-success the cursor
stops advancing for the rest of that run too, even past later mixes that
succeed (letting a later success push it forward would still silently
skip the earlier stuck mix on the next resume). The run itself is
unaffected -- it always continues to the next mix in the same pass. Dry
runs always count as terminal (nothing is written, so there's nothing to
protect, and Blocker 1 already stops a dry run's cursor from governing a
real run regardless).

Blocker 3: the durable retag_mix_processed event carried only
rename_status/tag_status, not the reason that actually distinguishes
benign from alarming -- tag_sources_for_mix returns "skipped" for both
"already tagged" (benign) and missing-source/outside-roots/insufficient-
space (alarming), so {skipped: 80} couldn't tell them apart. The event
now carries rename_reason and tag_reason too, and retag_run_completed is
now logged at warn when any tag skip's reason wasn't "already tagged"
(previously warn fired only on failures). _run_retag also gained a real
except: a crash now emits retag_run_failed before re-raising, so a
crashed run is distinguishable in the durable log from one still running
(the existing finally still always clears the running flag).

Blocker 5: the staging sweep's own dry_run was dropped on the floor --
sweep_staging was called with only staging_root, positionally, so its
dry_run parameter silently defaulted to False regardless of the retag
call's own dry_run. A bare, "report-only" POST was therefore performing
real multi-gigabyte deletions. dry_run is now plumbed through properly.

Finding 7: the sweep only ever visited each watch root's own flat
.fadeout-tagging/ directory, but a mix stored in a subdirectory (e.g.
<watch_audio>/2023/foo.flac) stages its own copy alongside itself, which
a flat sweep never visited. Sweep targets are now the union of the flat
watch-root directories and dirname(<candidate's source>)/.fadeout-tagging/
for every mix this run actually selected as a candidate -- candidates are
now selected before the sweep runs (previously after) so their source
paths are available to derive targets from.

LOW: GET /retag/status could report the previous run's numbers while a
new run's staging sweep was still in progress, because the running flag
was set after the sweep rather than before. It's now set (along with the
rest of the reset state) before the sweep starts.

Also fixes the test that enshrined the Blocker 1 bug:
test_resume_true_continues_after_last_mix_id's setup call previously
passed only limit=2, defaulting to a DRY run -- so it actually proved a
dry run advances a real run's cursor, not what its name claims. Both
calls are now explicit real runs (dry_run=false). Adds tests for the
opposite-mode resume guard (Blocker 1) and for both failed and
skip-for-space mixes not advancing the cursor, including across a later
success in the same run (Blocker 2).
…ag backfill

Three statements were false against the code as shipped: the bare-POST
"without touching anything" claim (the staging sweep ignored dry_run --
Blocker 5), the "never removes a file younger than the threshold" claim
in both the sweep_staging docstring and the CHANGELOG (the copy2-mtime
bug -- Blocker 4), and the cover-art "matches what the mix now has" claim
(it only compared presence -- Finding 6). All three now hold given the
fixes in the two preceding commits; this updates the wording to describe
the actual (now-accurate) behaviour, including the cursor mode-mismatch
guard (Blocker 1), the terminal-success cursor gating (Blocker 2), the
reason fields and retag_run_failed event (Blocker 3), and the nested-
directory sweep targets (Finding 7). Also drops the "by default" phrasing
around older_than_hours, since nothing currently exposes it as a
parameter -- chose to correct the wording over adding a query parameter,
since the threshold isn't otherwise operator-tunable today and adding one
wasn't part of what the review asked for.

Adds a CHANGELOG entry for this fix wave.
Gate 1 (Blocker 1 recurrence): a real run whose tag leg comes back
"disabled" no longer counts as terminal-success for the resume cursor.
Before this, an operator running the real backfill with tag_source_files
off would see the cursor race to the end of the backlog under dry_run=False
(so the mode guard didn't catch it); enabling the flag and resuming then
touched zero files.

Gate 2: the cursor decision now inspects BOTH legs. Renamed
_tag_outcome_is_terminal_success to _mix_outcome_is_terminal_success and
added a rename-status check (via the existing _rename_outcome_status) --
a failed rename no longer lets a successful tag silently advance the
cursor past it.

Gate 3: retag_run_started's activity-log context now carries
resume_cursor_ignored, so a refused resume is distinguishable from a
fresh start after the process is gone, not just in the HTTP response.

Optional structural fix: tag_sources_for_mix now marks its "already
tagged" skip with "terminal": True at the one call site that actually
knows the answer; the cursor decision prefers that key and falls back to
the old reason-string match only when it's absent.

Adds tests for four previously-untested fixes from the last wave (cover-art
content-hash comparison, sweep_staging's dry_run plumbing -- the
delete-primitive bug, nested sweep targets, and the retag_mix_processed
reason fields / alarming-skip warn level), and splits the mtime/ctime
staging-sweep test in two so each half of that fix is guarded
independently -- the combined test passed with either half reverted.

README updated: the disabled-is-terminal claim is gone, the "does not
advance the cursor" claim now covers renames too, and two new warnings
cover the resume+limit livelock and mixing offset runs with resume runs.

Verified: 125/125 targeted tests pass, ruff clean, and all 8 required
mutations (Gate 1, Gate 2, cover-art hash, sweep dry_run plumbing, nested
sweep targets, event reason fields, the os.utime stamp, ctime-aware aging)
were reverted individually and each broke exactly the test meant to catch
it, then restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoFoJWJX28B7nM6HfriV7M
The v2.6.1 entry described only the first fix wave. It omitted the three
changes that actually earned the unattended verdict: the rename leg now
gating the cursor, `disabled` no longer counting as terminal success on a
real run, and `resume_cursor_ignored` reaching the durable log.

Nothing in the entry was false, but release notes that omit the reason a
release exists are their own kind of stale documentation -- and this
project has now shipped that twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoFoJWJX28B7nM6HfriV7M
@rancur
rancur merged commit 42c11aa into main Sep 23, 2026
5 checks 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