fix(recipes): stop an all-missing scan from wiping the recipe cache

A scan that finds no recipe files at all is not a reliable deletion
signal: an unmounted drive, a recipes_path that silently falls back to
another LoRA root, or a cache shared with a second instance all look
exactly like a real wipe. The reconcile step treated them all as
deletions and overwrote the persistent cache with an empty one, so
DELETE FROM recipes destroyed the user's only record of their recipes
and the FTS index was rebuilt from the empty view (#1116).

Guard the prune:
- _reconcile_recipe_cache reports an all-missing result when every
  persisted recipe file is gone AND the stored rows match the recorded
  file stats. An internally inconsistent cache (leftover orphans) is
  stale, not evidence of a fresh disappearance, and still prunes.
- The caller keeps the stored cache and logs a warning naming the
  directory it scanned and the number of recipes it preserved, instead
  of writing the empty result. It also skips the FTS rebuild so the
  index stays aligned with the stored rows.
- save_cache gains skip_if_empty as a storage-level backstop: refuse to
  empty a populated cache. Intentional clears (manual rebuild) keep the
  default behaviour.
- Log the resolved scan directory per run so a support reader can tell a
  real wipe apart from a scan that looked elsewhere.

Partial orphans (ordinary manual deletions) keep pruning as before.
This commit is contained in:
Will Miao
2026-09-17 23:54:40 +08:00
parent 7d963b27b5
commit c55c6f0a41
3 changed files with 426 additions and 19 deletions
+32 -3
View File
@@ -170,20 +170,30 @@ class PersistentRecipeCache:
recipes: List[Dict[str, Any]],
json_paths: Optional[Dict[str, str]] = None,
image_id_map: Optional[Dict[str, str]] = None,
) -> None:
skip_if_empty: bool = False,
) -> bool:
"""Save all recipes to SQLite cache.
Args:
recipes: List of recipe dictionaries to persist.
json_paths: Optional mapping of recipe_id -> json_path for file stats.
image_id_map: Optional precomputed civitai image_id → recipe_id mapping.
skip_if_empty: When True, refuse to replace a non-empty cache with an
empty one. This is the storage-level backstop against a scan that
silently loses every recipe (unavailable drive / mis-resolved
recipes directory): overwriting both deletes the user's data and
destroys their only record of it. Intentional full clears (manual
rebuild) must pass ``skip_if_empty=False``.
Returns:
``True`` when the write happened, ``False`` when it was skipped.
"""
if not self.is_enabled():
return
return False
if not self._schema_initialized:
self._initialize_schema()
if not self._schema_initialized:
return
return False
try:
with self._db_lock:
@@ -192,6 +202,23 @@ class PersistentRecipeCache:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
if skip_if_empty and not recipes:
existing = conn.execute(
"SELECT COUNT(*) FROM recipes"
).fetchone()
if existing and existing[0]:
conn.rollback()
logger.warning(
"Refusing to persist an empty recipe cache: the "
"stored cache still holds %d recipe(s). The scan "
"found nothing, which usually means the recipes "
"path was unavailable or resolved elsewhere; "
"keeping the stored cache so the data stays "
"recoverable.",
existing[0],
)
return False
# Clear existing data
conn.execute("DELETE FROM recipes")
@@ -225,10 +252,12 @@ class PersistentRecipeCache:
conn.commit()
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
return True
finally:
conn.close()
except Exception as exc:
logger.warning("Failed to persist recipe cache: %s", exc)
return False
def get_file_stats(self) -> Dict[str, Tuple[float, int]]:
"""Return stored file stats for all cached recipes.