fix: address review — injective mirror roots, root relocation, full preview coverage, EXDEV-safe rollback

Codex review on #1124:

- P1: mirror layout root component is now <basename>-<roothash>
  (sha256 of the normalized root path), so two roots sharing a basename
  no longer map to the same mirror directory and overwrite each other's
  sidecars
- P1: changing sidecar_storage_path while centralized no longer strands
  assets in the old root — new relocate_root migration direction moves
  the whole mirror tree, rewrites preview_url prefixes inside sidecars,
  reconciles scanner caches, and prunes the emptied old tree; the
  settings UI detects the path change and offers the relocation
- P2: migration enumerates the same preview candidates as
  find_preview_file — case-insensitive variants (model.WEBP) and the
  legacy .example.0.jpeg suffix — instead of exact lowercase
  PREVIEW_EXTENSIONS only
- P2: _rollback_model_staging restores staged files with the
  EXDEV-tolerant mover, so a failed undoable-delete staging no longer
  strands a cross-filesystem centralized sidecar copy

Tests: same-basename root injectivity, mixed-case/example preview
migration, relocate_root happy path + guards + route 400, frontend
relocation prompt flow. Verified end-to-end in a sandboxed standalone
server: uppercase/legacy previews migrate, root relocation moves the
tree and the list API serves the new locations immediately without a
rescan.
This commit is contained in:
Will Miao
2026-09-26 12:24:17 +08:00
parent 16430aef21
commit a6fca8612f
22 changed files with 592 additions and 71 deletions
+19 -3
View File
@@ -12,7 +12,7 @@ setting:
- ``centralized``: sidecars and previews live under a configurable root
(``sidecar_storage_path`` setting, default ``<settings_dir>/sidecars``),
mirroring the library-relative directory structure:
``<root>/<library>/<root_basename>/<rel_dir>/<name>.metadata.json``.
``<root>/<library>/<root_basename-roothash>/<rel_dir>/<name>.metadata.json``.
All helpers are pure path computations: no directory scans and no file I/O
on the hot path. Settings lookups go through ``SettingsManager.get`` (a dict
@@ -21,6 +21,7 @@ read); config roots come from the already-initialized ``config`` singleton.
from __future__ import annotations
import hashlib
import logging
import os
import re
@@ -145,10 +146,25 @@ def _normalize_for_match(path: str) -> str:
return os.path.normpath(os.path.abspath(path))
def root_mirror_component(root_path: str) -> str:
"""Return the mirror path component identifying a model root.
``<sanitized basename>-<hash>`` where the hash is a short digest of the
normalized absolute root path. Two roots sharing a basename (e.g.
``/mnt/a/loras`` and ``/mnt/b/loras``) would otherwise map to the same
mirror directory and overwrite each other's sidecars.
"""
normalized = _normalize_for_match(root_path)
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8]
return f"{sanitize_path_component(os.path.basename(normalized))}-{digest}"
def resolve_centralized_dir(model_path: str) -> Optional[str]:
"""Return the centralized mirror directory for ``model_path``.
The mirror layout is ``<sidecar_root>/<library>/<root_basename>/<rel_dir>``
The mirror layout is
``<sidecar_root>/<library>/<root_basename-roothash>/<rel_dir>``
where ``rel_dir`` is the model's directory relative to the model root that
contains it. The longest matching root wins so nested roots resolve to the
most specific mirror. Returns ``None`` when centralized storage is inactive
@@ -201,7 +217,7 @@ def resolve_centralized_dir_for_dir(
library = "default"
rel_dir = os.path.relpath(normalized_dir, best_root)
parts = [root, sanitize_path_component(library), sanitize_path_component(os.path.basename(best_root))]
parts = [root, sanitize_path_component(library), root_mirror_component(best_root)]
if rel_dir and rel_dir != os.curdir:
parts.extend(sanitize_path_component(part) for part in rel_dir.split(os.sep) if part not in ("", os.curdir))
return os.path.join(*parts)