fix(media): order the sidecar reap only where the view is mounted

Every apply and rollback printed "next: scripts/media-view.sh reap", but on
Dankserver there is nothing to reap: the metadata view has never been installed
(`media-view.sh status` -> NOT MOUNTED, `/var/lib/media-meta` absent) and the
live quadlets still bind the array read-only. All three relayout arms tonight
issued that order — 242,084 moves — and it pointed at a directory that does not
exist. Where no overlay is mounted the servers bind the array itself, so the
sidecars moved with the media.

`--metadata-view` (default /var/lib/media-meta/view) names the overlay and the
note now rides only when that path is a mountpoint. Red first: two new cases in
tests/media_relayout.bats (apply and rollback); the shared helper names a real
mountpoint so the pre-existing presence assertion keeps its meaning. 37 ok.
This commit is contained in:
Frank committed 2026-10-31 03:13:53 -07:00
1 parent 96944f0b17
commit 9eb85ea1f9
2 files changed
+55 -6

No files matched your search

+25 -5
View File
@@ -32,6 +32,12 @@ cross a filesystem (rename cannot do it and a silent copy at 24 TB would not).
--force relaxes only the guards that are opinion (journal already rolled back, --force relaxes only the guards that are opinion (journal already rolled back,
paths outside --root); it never allows overwriting a file. paths outside --root); it never allows overwriting a file.
--metadata-view names the overlay the media servers read their library through
(default /var/lib/media-meta/view). The sidecar follow-up printed after an apply
or rollback is conditioned on that path being a mountpoint: where no view is
mounted the servers bind the array itself, the sidecars moved with the media,
and a reap order would point at a directory that is not there.
The journal defaults to `media-relayout.jsonl` BESIDE the library root, never The journal defaults to `media-relayout.jsonl` BESIDE the library root, never
inside it: a log inside the library is a file Jellyfin will index. inside it: a log inside the library is a file Jellyfin will index.
""" """
@@ -114,13 +120,24 @@ def read_plan(path, root, force):
return mkdirs, keeps, moves return mkdirs, keeps, moves
# A relayout moves bytes on the raw array; the sidecars the media servers wrote # The media servers may read the library through the SSD overlay view
# live in the SSD overlay and stay at the old coordinates. Naming the follow-up # (scripts/media-view.sh). A relayout moves bytes on the raw array, so sidecars
# here is the point of use for that fact — see the Media Metadata View page. # the servers wrote through that view stay at the old coordinates — naming the
# follow-up here is the point of use for that fact, but only where the view
# exists: where it is not mounted the servers bind the array itself, the
# sidecars moved with the media, and a reap order points at a directory that
# is not there. See the Media Metadata View page for the host's state.
METADATA_VIEW = "/var/lib/media-meta/view"
METADATA_HINT = ("note: sidecars written through the metadata view did not follow" METADATA_HINT = ("note: sidecars written through the metadata view did not follow"
" these moves — next: scripts/media-view.sh reap") " these moves — next: scripts/media-view.sh reap")
def metadata_note(args):
"""Print the sidecar follow-up only when the overlay is really mounted."""
if os.path.ismount(args.metadata_view):
print(METADATA_HINT)
def do_apply(args, root, journal, mkdirs, keeps, moves): def do_apply(args, root, journal, mkdirs, keeps, moves):
"""Preflight everything, then folders, then renames. Never a partial start.""" """Preflight everything, then folders, then renames. Never a partial start."""
problems = [] problems = []
@@ -202,7 +219,7 @@ def do_apply(args, root, journal, mkdirs, keeps, moves):
f" {dirs_new} folders created, {len(keeps)} files stayed put") f" {dirs_new} folders created, {len(keeps)} files stayed put")
print(f"journal: {journal.path} — undo with: media-apply.py rollback" print(f"journal: {journal.path} — undo with: media-apply.py rollback"
f" --journal {journal.path} --root {root} --apply") f" --journal {journal.path} --root {root} --apply")
print(METADATA_HINT) metadata_note(args)
return 0 return 0
@@ -386,7 +403,7 @@ def do_rollback(args, root, journal):
t=datetime.datetime.now(datetime.timezone.utc).isoformat()) t=datetime.datetime.now(datetime.timezone.utc).isoformat())
print(f"rolled back: {undone} files back where the census found them," print(f"rolled back: {undone} files back where the census found them,"
f" {dirs_gone} empty folders removed, {recreated} swept folders recreated") f" {dirs_gone} empty folders removed, {recreated} swept folders recreated")
print(METADATA_HINT) metadata_note(args)
return 0 return 0
@@ -398,6 +415,9 @@ def main():
ap.add_argument("--journal") ap.add_argument("--journal")
ap.add_argument("--apply", action="store_true", help="actually change the library") ap.add_argument("--apply", action="store_true", help="actually change the library")
ap.add_argument("--force", action="store_true", help="relax the opinion guards") ap.add_argument("--force", action="store_true", help="relax the opinion guards")
ap.add_argument("--metadata-view", default=METADATA_VIEW,
help="overlay the media servers read through; the sidecar"
" note prints only when this path is a mountpoint")
args = ap.parse_args() args = ap.parse_args()
root = os.path.abspath(args.root) root = os.path.abspath(args.root)
+30 -1
View File
@@ -374,7 +374,10 @@ PY
# apply_plan — plan, then apply for real; the journal goes in the tmpdir. # apply_plan — plan, then apply for real; the journal goes in the tmpdir.
apply_plan() { apply_plan() {
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl" JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --apply # `/` is always a mountpoint, so the overlay note behaves as it does on a
# host that runs the metadata view.
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" \
--journal "$JOURNAL" --metadata-view / --apply
} }
@test "a run without --apply says what it would do and touches nothing" { @test "a run without --apply says what it would do and touches nothing" {
@@ -409,6 +412,32 @@ print(sum(1 for l in open(sys.argv[1]) if json.loads(l).get("op") == "move"))' "
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
# The overlay note is only true where the metadata view is really mounted. On a
# host whose media servers bind the array directly (dankserver today: both
# quadlets mount /mnt/entropy, /var/lib/media-meta does not exist) the sidecars
# moved with the media, and "next: run reap" is an order that cannot be carried
# out. --metadata-view is how the caller names that path; the default is the
# host's view path, and the note rides only when it is a mountpoint.
@test "an apply with no mounted metadata view does not order a reap" {
plan
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --apply
[ "$status" -eq 0 ]
[[ "$output" == *"moved"* ]]
[[ "$output" != *"media-view.sh reap"* ]]
}
@test "a rollback with no mounted metadata view does not order a reap" {
plan
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --apply
[ "$status" -eq 0 ]
run python3 "$APPLY" rollback --journal "$JOURNAL" --root "$LIB" --apply
[ "$status" -eq 0 ]
[[ "$output" == *"rolled back"* ]]
[[ "$output" != *"media-view.sh reap"* ]]
}
@test "a target already holding a different file stops the whole run" { @test "a target already holding a different file stops the whole run" {
plan plan
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl" JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"