fix(sidecars): carry the identity map through a sidecar root move

A model root that moved earlier is mirrored under a pinned name derived
from its old path. If anything resolved against the new sidecar path before
the relocation ran, the destination got a map naming the mirror after the
*current* path. migrate_root's keep-newer transfer then dropped the source
map, so the moved metadata stayed orphaned under the pinned component while
reads followed the new name and rebuilt defaults — losing favorites, notes
and tags a second time.

The identity map is now relocated by relocate_root_map() instead of the
generic transfer: entries recorded under the old sidecar root win for the
roots they describe, destination-only entries are preserved, and the cache
is dropped so the next resolution reloads the merged map. A merge that
cannot be written is reported as a migration error rather than silently
stranding the moved metadata.

Reported by the Codex review on #1131.
This commit is contained in:
Will Miao
2026-09-28 21:25:48 +08:00
parent c3a9350155
commit b90d60f043
5 changed files with 238 additions and 15 deletions
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
from typing import Any, Dict, List
@@ -12,7 +13,11 @@ import pytest
from py.config import config
from py.services.settings_manager import get_settings_manager
from py.services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase
from py.utils.sidecar_paths import reset_root_map_cache, root_mirror_component
from py.utils.sidecar_paths import (
get_metadata_path,
reset_root_map_cache,
root_mirror_component,
)
def _normalize(path) -> str:
@@ -490,6 +495,61 @@ async def test_migrate_root_relocates_tree_and_reconciles(
assert not old_root.exists()
@pytest.mark.asyncio
async def test_migrate_root_preserves_a_reanchored_identity(
library_root: Path, sidecar_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""Relocating the sidecar root must not displace a pinned mirror name.
A root that moved earlier is mirrored under a name derived from its *old*
path. If anything resolves against the destination sidecar root before the
relocation runs, it writes a map naming the mirror after the *current*
path; the relocation must keep the pinned entry, because the directories
actually being moved are named after it.
"""
_set_mode("centralized")
first_sidecars = tmp_path / "first_sidecars"
get_settings_manager().set("sidecar_storage_path", str(first_sidecars))
reset_root_map_cache()
model = _write_model(library_root / "sub", "model")
pinned = root_mirror_component(str(library_root))
original = Path(get_metadata_path(str(model)))
original.parent.mkdir(parents=True, exist_ok=True)
original.write_text(json.dumps({"favorite": True}), encoding="utf-8")
# The model root moves; the recorded identity is re-anchored to it.
moved_root = tmp_path / "relocated" / "loras"
shutil.copytree(library_root, moved_root)
monkeypatch.setattr(config, "loras_roots", [str(moved_root)], raising=False)
reset_root_map_cache()
moved_model = moved_root / "sub" / "model.safetensors"
assert Path(get_metadata_path(str(moved_model))) == original
assert root_mirror_component(str(moved_root)) == pinned
# Settings now point at the destination and something resolves first: the
# destination map is written naming the mirror after the *current* path.
get_settings_manager().set("sidecar_storage_path", str(sidecar_root))
reset_root_map_cache()
before_move = Path(get_metadata_path(str(moved_model)))
assert before_move.parent.parent.name != pinned
assert not before_move.exists()
use_case = _make_use_case([str(moved_model)])
summary = await use_case.migrate_root(str(first_sidecars), force=True)
assert summary["success"] is True
# The metadata travelled with the tree and is still found under the
# pinned identity that names it.
relocated = sidecar_root / original.relative_to(first_sidecars)
assert relocated.exists()
assert json.loads(relocated.read_text(encoding="utf-8")) == {"favorite": True}
reset_root_map_cache()
assert Path(get_metadata_path(str(moved_model))) == relocated
@pytest.mark.asyncio
async def test_migrate_root_guards(
library_root: Path, sidecar_root: Path, tmp_path: Path
+76
View File
@@ -24,6 +24,7 @@ from py.utils.sidecar_paths import (
get_unmatched_sidecar_components,
is_centralized,
is_metadata_path,
relocate_root_map,
resolve_centralized_dir,
resolve_centralized_dir_for_dir,
resolve_metadata_path,
@@ -32,6 +33,15 @@ from py.utils.sidecar_paths import (
)
def _write_map(root: Path, entries: dict) -> Path:
root.mkdir(parents=True, exist_ok=True)
path = root / ROOT_MAP_FILENAME
path.write_text(
json.dumps({"version": 1, "roots": entries}), encoding="utf-8"
)
return path
def _normalize(path: Path) -> str:
return str(path).replace(os.sep, "/")
@@ -467,6 +477,72 @@ class TestRootIdentityMap:
assert Path(get_metadata_path(str(model))) == expected
assert json.loads(expected.read_text(encoding="utf-8")) == {"favorite": True}
def test_relocate_root_map_prefers_the_source_entries(self, tmp_path: Path):
"""A map written at the destination before a relocation must not win."""
source = tmp_path / "old-sidecars"
destination = tmp_path / "new-sidecars"
_write_map(
source,
{
"aaaa1111": {
"component": "loras-pinned",
"basename": "loras",
"last_path": "/models/loras",
"sample_rel_dirs": [],
}
},
)
_write_map(
destination,
{
"bbbb2222": {
"component": "loras-recomputed",
"basename": "loras",
"last_path": "/models/loras",
"sample_rel_dirs": [],
},
"cccc3333": {
"component": "vae-other",
"basename": "vae",
"last_path": "/models/vae",
"sample_rel_dirs": [],
},
},
)
assert relocate_root_map(str(source), str(destination)) is True
merged = json.loads(
(destination / ROOT_MAP_FILENAME).read_text(encoding="utf-8")
)["roots"]
assert merged["aaaa1111"]["component"] == "loras-pinned"
assert "bbbb2222" not in merged # superseded for the same root path
assert merged["cccc3333"]["component"] == "vae-other"
assert not (source / ROOT_MAP_FILENAME).exists()
def test_relocate_root_map_without_a_source_map_is_a_noop(self, tmp_path: Path):
source = tmp_path / "old-sidecars"
destination = tmp_path / "new-sidecars"
_write_map(
destination,
{
"bbbb2222": {
"component": "loras-keep",
"basename": "loras",
"last_path": "/models/loras",
"sample_rel_dirs": [],
}
},
)
assert relocate_root_map(str(source), str(destination)) is True
kept = json.loads(
(destination / ROOT_MAP_FILENAME).read_text(encoding="utf-8")
)["roots"]
assert kept["bbbb2222"]["component"] == "loras-keep"
def test_corrupt_map_file_is_ignored_and_rebuilt(
self, model_roots: dict, centralized: Path
):