Catch a wrong playlist write before it is published - #1
Closed
jrgutier wants to merge 2 commits into
Closed
Conversation
A conversion on a real stick published two playlists that each contained one track appearing nowhere in the corresponding source playlist, and `convert` still exited 0. Only a later `verify` found it. Re-running convert over the same export.pdb produced a clean database, which destroyed the evidence and left the cause unknown. The root cause is NOT fixed here — it is not identified. What is fixed is that this class of corruption could be published silently. What was ruled out ------------------ The reader is a pure function of the file bytes (`data = path.read_bytes()`, no mmap; `_walk_table_pages` walks that buffer), and repeated parses of the stick returned identical playlist membership every time. The writer only ever drops entries relative to `pl.track_rb_ids`; it cannot add one. rb ids on this library are exactly 1..3673 with no skips, so the identity map returned by `insert_tracks` (EngineTrack carries no rb_id) is correct here, and the positional repair in build.py is correct when ids have gaps — not the bug. Four faithful rebuilds with artwork on, through the full copy-to-USB path, produced byte-identical playlist chains. The failure did not recur. Two gates --------- `insert_playlists` now reads its own nextEntityId chains back and raises if they disagree with what it meant to write. The walk follows the chain rather than row order because the spurious row was spliced second-to-last, where an `ORDER BY id` comparison cannot see it. Every row in the table is checked, not just the lists we filled: a stray row on a folder or an empty playlist is exactly as wrong, and scoping to intended lists would leave that invisible. That check runs inside the writing transaction, so it can only prove SQLite agreed at that moment. build.py therefore re-checks the copy that actually crossed to the target volume, after fsync and before `os.replace`. A failure there leaves the user's previous m.db byte-for-byte intact and surfaces through the existing FatalError path as exit 2. Reproduction harness -------------------- tools/repro_playlist_determinism.py bisects the pipeline so a recurrence is attributable rather than mysterious: the reader stage hashes the raw export.pdb bytes alongside the parse, separating an unreliable read from a non-deterministic parser; the writer stage reads once and rebuilds N times from that provably identical input. It preserves the baseline and any diverging m.db, since a re-run is what lost the original evidence. It does not use a symlinked shadow root. `engine_track_path` resolves both paths, so a symlink collapsed to the real stick, `relative_to` raised, and mapper/track.py degraded to the raw path — every track still "converted" and the harness reported a confident verdict about a configuration nobody ships. It now runs against the real drive root, redirects only the output directory, and asserts the written paths still look like a real conversion's. Tests ----- The corruption tests splice a row mid-chain, orphan a row, delete an entry and repair its predecessor, and place a row on an unintended list. The wiring is covered too: removing the gate call from insert_playlists turns the suite red, which was not true of the first version of these tests. 693 passing (687 before), ruff clean. Verified end to end on a 3,673-track stick: convert wrote 45 playlists and verify reports 0 discrepancies, so neither gate misfires on a real library. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AxFS98fHa9GwJzPnfLPVQr
The two oracles disagreed about what a valid PlaylistEntity chain is. The writer's new gate treats a row the chain never reaches as fatal and refuses to publish. verify walked the same structure, returned whatever it reached, and reported the library clean — so verify would pass a database convert would refuse to write. On a real stick that eventually shows up as one tool contradicting the other about the same file. One walker ---------- rb2engine/chain.py is now the only implementation. It raises ChainInconsistent (a RuntimeError subclass, so the writer's documented contract is unchanged) when the chain forks or fails to account for every row. They still need different reactions, and that is the point of raising rather than returning a flag: the writer must abort before publishing, while verify must record the finding and keep checking the rest of the library. What they can no longer do is disagree about whether there is anything to react to. verify now reports it --------------------- _entity_track_order returns (order, problem). A problem becomes its own `playlist[NAME].chain` discrepancy rather than surfacing indirectly as whatever track order a truncated walk happened to produce — and it is reported in its own right, because a broken chain is a defect even when the set of tracks still matches what the source expected. This removes the fourth copy of the walk as well; the harness uses the shared one and folds any chain problem into its fingerprint, so a run that corrupts a chain can no longer compare equal to a run that did not. Tests ----- Both chain tests were confirmed red against the old lenient walker and green after: the pre-existing cycle test (which now names the orphaned row instead of asserting on a truncated order) and a new test that strands a row behind a successor id that does not exist, leaving the rest of the chain well-formed so only the row count reveals it. 694 passing (693 before), ruff clean. verify on a 3,673-track stick still reports 0 discrepancies, so the stricter walk does not misfire on a real library. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AxFS98fHa9GwJzPnfLPVQr
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What happened
A conversion on a real stick published two playlists that each contained one track appearing nowhere in the corresponding source playlist, and
convertstill exited 0. Only a laterverifycaught it. Re-running convert over the sameexport.pdbproduced a clean database — which destroyed the evidence and left the cause unknown.The root cause is not fixed here, because it is not identified. What is fixed is that this class of corruption could be published silently, and that the two oracles disagreed about what "correct" even means.
What was ruled out
data = path.read_bytes(), no mmap;_walk_table_pageswalks that buffer). Repeated parses returned identical playlist membership every time, with matching byte hashes.insert_playlistscan only ever drop entries relative topl.track_rb_ids— it cannot add one.insert_tracks(EngineTrackcarries norb_id) is correct here; the positional repair inbuild.pyis also correct when ids have gaps. Not the bug.One initial condition could not be recreated: the failing run was the only conversion that started from the older pre-existing database, and that artifact is gone.
The two gates
In-transaction (
writer/playlists.py) —insert_playlistsreads its ownnextEntityIdchains back and raises if they disagree with intent. It walks the chain rather than row order because the spurious row was spliced second-to-last, where anORDER BY idcomparison cannot see it. Every row in the table is checked, not just the lists we filled — a stray row on a folder or empty playlist is exactly as wrong.Post-copy, pre-swap (
writer/build.py) — the in-transaction check can only prove SQLite agreed at that moment, so the copy that actually crossed to the target volume is re-checked after fsync and beforeos.replace. A failure there leaves the previousm.dbbyte-for-byte intact and surfaces through the existingFatalErrorpath as exit 2.One chain walker
The walk existed in four places that had drifted apart. The writer treated an unreachable row as fatal;
verifyreturned the shorter, tidier list and reported the library clean — soverifywould pass a databaseconvertrefuses to write.rb2engine/chain.pyis now the only implementation. It raisesChainInconsistent(aRuntimeErrorsubclass, so the writer's contract is unchanged) when a chain forks or fails to account for every row. Raising rather than returning a flag is deliberate: the callers need different reactions — the writer aborts before publishing,verifyrecords the finding and keeps checking — but they can no longer disagree about whether there is anything to react to.verifynow reports it as its ownplaylist[NAME].chaindiscrepancy, in its own right, because a broken chain is a defect even when the set of tracks still matches the source.Reproduction harness
tools/repro_playlist_determinism.pybisects the pipeline so a recurrence is attributable rather than mysterious. The reader stage hashes the rawexport.pdbbytes alongside the parse, separating an unreliable read from a non-deterministic parser; the writer stage reads once and rebuilds N times from that provably identical input. It preserves the baseline and any divergingm.db, since a re-run is exactly what lost the original evidence.It deliberately does not use a symlinked shadow root.
engine_track_pathresolves both paths, so a symlink collapsed to the real stick,relative_toraised, andmapper/track.pydegraded to the raw path — every track still "converted" and the harness reported a confident verdict about a configuration nobody ships. It now runs against the real drive root, redirects only the output directory, and asserts the written paths still look like a real conversion's.Testing
Every check here was confirmed to fail before it passed:
insert_playliststurns the suite red (it did not in the first version of these tests).verifyreports 0 discrepancies, so none of the stricter checks misfire on a real library.One pre-existing test changed meaning: the cycle test now asserts the
.chaindiscrepancy naming the orphaned row, instead of whatever track order the truncated walk happened to produce.🤖 Generated with Claude Code
https://claude.ai/code/session_01AxFS98fHa9GwJzPnfLPVQr