fix(metadata): fill local file facts when self-heal recreates sidecar

Refresh after manual .metadata.json deletion rebuilds the payload without
file_name/size/modified, which are required by BaseModelMetadata.from_dict.
The recreated sidecar then fails to parse and the scanner skips the model.

- load_metadata_payload fills missing file facts from os.stat
- hydrate_model_data restores every missing key from the cache snapshot
  only when the sidecar is missing entirely (disk stays authoritative
  otherwise), preferring the cached import timestamp for modified
- save_metadata fills file facts on write so no write path can produce
  an unparseable sidecar
This commit is contained in:
Will Miao
2026-08-11 14:57:23 +08:00
parent e2c45905f0
commit f1d3ac0cdc
2 changed files with 276 additions and 1 deletions
+62 -1
View File
@@ -55,6 +55,32 @@ class MetadataManager:
logger.error(f"{error_type} in metadata file: {metadata_path}. Error: {str(e)}. Skipping model to preserve existing data.")
return None, True # should_skip = True
@staticmethod
def _fill_local_file_facts(payload: Dict[str, Any], file_path: str) -> None:
"""Fill missing local file facts (``file_name``/``size``/``modified``) from disk.
These three fields are part of the required metadata schema but describe
the local file, not remote metadata. Payloads rebuilt by the self-heal
refresh flow (sidecar deleted, then recreated from remote data) lack
them, which makes the recreated sidecar unparseable by
``BaseModelMetadata.from_dict`` and causes the scanner to skip the model.
Fill them from the actual file whenever absent.
"""
if not file_path:
return
if payload.get("file_name") and "size" in payload and "modified" in payload:
return
try:
stat_result = os.stat(file_path)
except OSError:
return
if not payload.get("file_name"):
payload["file_name"] = os.path.splitext(os.path.basename(file_path))[0]
if "size" not in payload:
payload["size"] = stat_result.st_size
if "modified" not in payload:
payload["modified"] = stat_result.st_mtime
@staticmethod
async def load_metadata_payload(file_path: str) -> Dict[str, Any]:
"""
@@ -96,6 +122,11 @@ class MetadataManager:
if file_path:
payload.setdefault("file_path", normalize_path(file_path))
# Required schema fields that are local filesystem facts. When the
# sidecar is missing (e.g. deleted and being recreated by the
# self-heal refresh flow), restore them so the recreated sidecar
# and cache entries stay parseable.
MetadataManager._fill_local_file_facts(payload, file_path)
return payload
@@ -104,6 +135,14 @@ class MetadataManager:
"""
Replace the provided model data with the authoritative payload from disk.
Preserves the cached folder entry if present.
When the sidecar is missing entirely (self-heal after manual deletion),
the disk payload is nearly empty and the cache snapshot is the only
source for the schema fields required by ``BaseModelMetadata.from_dict``
(file_name/model_name/size/modified/sha256/base_model/preview_url), so
every missing key is restored from it to keep any recreated sidecar
parseable and avoid data loss on failed refreshes. When the sidecar
exists, disk data stays authoritative and no cache key is resurrected.
"""
file_path = model_data.get("file_path")
@@ -111,12 +150,29 @@ class MetadataManager:
return model_data
folder = model_data.get("folder")
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
sidecar_exists = os.path.exists(metadata_path)
cached = model_data.copy()
payload = await MetadataManager.load_metadata_payload(file_path)
if folder is not None:
payload["folder"] = folder
model_data.clear()
model_data.update(payload)
if not sidecar_exists:
for key, value in cached.items():
if key not in model_data and key != "folder":
model_data[key] = value
# The schema defines `modified` as the import timestamp; keep the
# cache's value over the stat-derived fallback from
# load_metadata_payload.
if "modified" in cached:
model_data["modified"] = cached["modified"]
# file_name/size are local file facts; prefer fresh stat values over
# the possibly stale cache snapshot.
MetadataManager._fill_local_file_facts(model_data, file_path)
return model_data
@staticmethod
@@ -155,7 +211,12 @@ class MetadataManager:
metadata_dict['file_path'] = normalize_path(metadata_dict['file_path'])
if 'preview_url' in metadata_dict:
metadata_dict['preview_url'] = normalize_path(metadata_dict['preview_url'])
# Local file facts are required schema fields; fill them when a
# payload rebuilt without them (e.g. self-heal) is being persisted.
if metadata_dict.get("file_path"):
MetadataManager._fill_local_file_facts(metadata_dict, metadata_dict["file_path"])
# Write to temporary file first
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(metadata_dict, f, indent=2, ensure_ascii=False)