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
@@ -52,10 +52,12 @@ from ...utils.file_utils import find_preview_file, get_preview_extension
from ...utils.metadata_manager import MetadataManager
from ...utils.sidecar_paths import (
METADATA_SUFFIX,
ROOT_MAP_FILENAME,
STORAGE_MODE_CENTRALIZED,
get_configured_sidecar_root,
get_sidecar_root,
get_storage_mode,
relocate_root_map,
resolve_centralized_dir_for_dir,
)
@@ -200,14 +202,19 @@ class SidecarMigrationUseCase:
)
files: List[Tuple[str, str]] = []
source_map_path = os.path.join(old, ROOT_MAP_FILENAME)
if os.path.isdir(old):
for dirpath, _dirnames, filenames in os.walk(old):
rel = os.path.relpath(dirpath, old)
target_dir = new_root if rel == os.curdir else os.path.join(new_root, rel)
for filename in filenames:
files.append(
(os.path.join(dirpath, filename), os.path.join(target_dir, filename))
)
source = os.path.join(dirpath, filename)
# The identity map is handled by relocate_root_map below:
# _transfer's keep-newer rule would let a destination map
# written before the relocation displace it.
if source == source_map_path:
continue
files.append((source, os.path.join(target_dir, filename)))
errors: List[Dict[str, str]] = []
counters: Dict[str, Any] = {"moved": 0, "conflicts": 0}
@@ -243,6 +250,23 @@ class SidecarMigrationUseCase:
errors.append({"model": os.path.basename(src), "error": str(exc)})
await emit("processing", processed=index, current=os.path.basename(src))
# The identity map names the directories just moved, so it travels with
# them and wins over any map the destination acquired beforehand.
# A failure here strands the moved metadata, so it is a real error.
try:
if not relocate_root_map(old, new_root):
errors.append(
{
"model": ROOT_MAP_FILENAME,
"error": "sidecar root map could not be written to the new root",
}
)
except Exception as exc:
self._logger.error(
"Sidecar root relocation failed for the root map: %s", exc, exc_info=True
)
errors.append({"model": ROOT_MAP_FILENAME, "error": str(exc)})
old_prefix = old.replace(os.sep, "/").rstrip("/") + "/"
new_prefix = new_root.replace(os.sep, "/").rstrip("/") + "/"
for sidecar in moved_sidecars:
+73 -10
View File
@@ -357,11 +357,8 @@ def _persistable(sidecar_root: str) -> bool:
return bool(probe) and os.access(probe, os.W_OK)
def _save_root_map(sidecar_root: str, state: _RootMapState) -> bool:
"""Atomically persist the root map; returns False when it cannot be written."""
if state.persist_disabled:
return False
def _write_root_map(sidecar_root: str, entries: Dict[str, Dict[str, object]]) -> bool:
"""Atomically persist ``entries`` as the root map for ``sidecar_root``."""
path = _root_map_path(sidecar_root)
# Snapshot before serializing: sample directories are appended from the
@@ -375,7 +372,7 @@ def _save_root_map(sidecar_root: str, state: _RootMapState) -> bool:
"last_path": entry.get("last_path", ""),
"sample_rel_dirs": list(entry.get("sample_rel_dirs") or []),
}
for root_id, entry in state.entries.items()
for root_id, entry in entries.items()
},
}
temp_path = f"{path}.tmp"
@@ -385,12 +382,23 @@ def _save_root_map(sidecar_root: str, state: _RootMapState) -> bool:
json.dump(payload, handle, indent=2, ensure_ascii=False)
os.replace(temp_path, path)
except OSError as exc:
logger.warning("sidecar_paths: cannot persist the root map %s: %s", path, exc)
return False
return True
def _save_root_map(sidecar_root: str, state: _RootMapState) -> bool:
"""Persist a reconciled state; disables persistence when it cannot write."""
if state.persist_disabled:
return False
if not _write_root_map(sidecar_root, state.entries):
state.persist_disabled = True
logger.warning(
"sidecar_paths: cannot persist the root map %s (%s); mirror directory "
"names fall back to path-derived components",
path,
exc,
"sidecar_paths: mirror directory names fall back to path-derived "
"components for %s",
sidecar_root,
)
return False
state.dirty_samples = False
@@ -398,6 +406,61 @@ def _save_root_map(sidecar_root: str, state: _RootMapState) -> bool:
return True
def relocate_root_map(source_root: str, destination_root: str) -> bool:
"""Carry the root map from a relocated sidecar root to its destination.
Call this after the mirror tree itself has been moved. Entries recorded
under ``source_root`` win over any identity the destination picked up on
its own: resolving against the new sidecar path *before* the relocation
writes a map that names mirrors after the current model-root path, while
the directories actually being moved are still named after the pinned
identity. Destination-only entries are preserved, and the source file is
always removed so the emptied tree can be pruned.
Returns False only when a source map existed but could not be written to
the destination — the caller must surface that, since the moved metadata
would otherwise be unreachable. Cached state is dropped either way so the
next resolution reloads the merged map.
"""
source_path = _root_map_path(source_root)
source_entries = _load_root_map(source_root)
with _ROOT_MAPS_LOCK:
if source_entries:
destination_path = _root_map_path(destination_root)
merged: Dict[str, Dict[str, object]] = {}
if os.path.exists(destination_path):
merged.update(_load_root_map(destination_root))
source_paths = {
_normalize_for_match(str(entry["last_path"]))
for entry in source_entries.values()
if entry.get("last_path")
}
preserved = {
root_id: entry
for root_id, entry in merged.items()
if not entry.get("last_path")
or _normalize_for_match(str(entry.get("last_path"))) not in source_paths
}
preserved.update(source_entries)
if not _write_root_map(destination_root, preserved):
return False
if os.path.exists(source_path):
try:
os.remove(source_path)
except OSError as exc: # pragma: no cover - defensive cleanup
logger.debug(
"sidecar_paths: cannot remove relocated root map %s: %s",
source_path,
exc,
)
reset_root_map_cache()
return True
def _new_root_id() -> str:
return uuid.uuid4().hex[:8]