fix(pending-delete): drop the staging container with its last batch (#1145)

Deleting a folder that holds models answered "A deletion is still pending inside
this folder. Wait for the undo window to expire." even though nothing was staged
and no window was running. `.lm-pending-delete` is only a container: the purge,
the undo and the staging rollback all removed the batch dir inside it and left the
container behind, and the folder manifest matched the directory *name* alone. Any
folder where a model had ever been deleted with the undo window was therefore
undeletable for good, and the same name match refused folder renames.

* `staging_dir_has_content()` is the shared answer to "is there still a batch
  here?": an empty container is a leftover, a quarantined batch (`*.orphaned`) is
  preserved data and still counts, and an unreadable directory counts too — never
  delete over something that could not be listed.
* The container is removed with its last batch (purge, undo, staging rollback,
  merged-loser cleanup). `rmdir` only ever succeeds on an empty directory, and the
  path is checked to really be a `.lm-pending-delete`, so a model folder is never
  at risk.
* A manifest-less batch dir with nothing in it is a crash leftover between
  `makedirs` and the manifest write: it is dropped instead of quarantined, which
  would have left an empty container that blocks deletion just the same.

Verified: purge, undo and the empty-manifest-less sweep all leave no container
behind, the container survives while another batch is staged, and an empty one no
longer blocks folder delete or rename while a real (or quarantined) batch still
does. 3818 backend tests pass.
This commit is contained in:
Will Miao
2026-10-12 10:34:55 +08:00
parent 3a718ce2c3
commit 5976224d75
4 changed files with 208 additions and 7 deletions
+19 -5
View File
@@ -12,7 +12,10 @@ from ..utils.recipes_paths import path_is_or_contains_recipes_dir
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots from ..services.model_lifecycle_service import _require_path_in_library_roots
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME from ..services.pending_delete_service import (
PENDING_DELETE_DIR_NAME,
staging_dir_has_content,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -881,7 +884,12 @@ class ModelMoveService:
excluded_paths = self._excluded_model_paths() excluded_paths = self._excluded_model_paths()
for dirpath, dirnames, filenames in os.walk(absolute_path): for dirpath, dirnames, filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames: # The container alone means nothing: an empty `.lm-pending-delete`
# is a leftover from a purge, an undo or a failed staging, and
# treating it as pending made the folder undeletable for good.
if PENDING_DELETE_DIR_NAME in dirnames and staging_dir_has_content(
os.path.join(dirpath, PENDING_DELETE_DIR_NAME)
):
pending_delete_job = True pending_delete_job = True
for name in dirnames: for name in dirnames:
@@ -1072,9 +1080,15 @@ class ModelMoveService:
@staticmethod @staticmethod
def _has_pending_delete_job(absolute_path: str) -> bool: def _has_pending_delete_job(absolute_path: str) -> bool:
"""Return True when a staged-delete batch lives inside the subtree.""" """Return True when a staged-delete batch lives inside the subtree.
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames: An empty ``.lm-pending-delete`` container does not count: it is a
leftover, not a batch, and a rename must not be refused because of one.
"""
for dirpath, dirnames, _filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames and staging_dir_has_content(
os.path.join(dirpath, PENDING_DELETE_DIR_NAME)
):
return True return True
return False return False
+46
View File
@@ -55,6 +55,28 @@ MANIFEST_FILE_NAME = "manifest.json"
# deleted by the sweep. # deleted by the sweep.
ORPHANED_SUFFIX = ".orphaned" ORPHANED_SUFFIX = ".orphaned"
def staging_dir_has_content(directory: str) -> bool:
"""Whether a staging directory still holds a batch (or a quarantined one).
``.lm-pending-delete`` is only a container: the batch dir inside it is what
carries the staged files, and the purge, the undo and the staging rollback
all remove it. An empty container left behind must not be mistaken for a
staged delete -- it used to make every later folder delete answer "A
deletion is still pending inside this folder" forever, with no undo window
to wait for and nothing in the UI to clear it (issue #1145 follow-up).
Quarantined batches (``*.orphaned``) are kept on purpose, so anything left
inside still counts. An unreadable directory counts as content too: never
delete over something we could not list.
"""
try:
return bool(os.listdir(directory))
except OSError:
return True
# Map scanner.model_type (singular) to the manifest page type values. # Map scanner.model_type (singular) to the manifest page type values.
_MODEL_TYPE_PAGE_MAP = { _MODEL_TYPE_PAGE_MAP = {
"lora": "loras", "lora": "loras",
@@ -201,6 +223,7 @@ class PendingDeleteService:
if batch_dir: if batch_dir:
self._rollback_model_staging(batch_dir, staged_pairs) self._rollback_model_staging(batch_dir, staged_pairs)
self._remove_empty_dir(batch_dir) self._remove_empty_dir(batch_dir)
self._remove_empty_staging_parent(batch_dir)
return None return None
except Exception as exc: # defensive - never block the delete flow except Exception as exc: # defensive - never block the delete flow
logger.warning( logger.warning(
@@ -266,6 +289,7 @@ class PendingDeleteService:
if batch_dir: if batch_dir:
self._rollback_recipe_staging(batch_dir, staged_pairs) self._rollback_recipe_staging(batch_dir, staged_pairs)
self._remove_empty_dir(batch_dir) self._remove_empty_dir(batch_dir)
self._remove_empty_staging_parent(batch_dir)
return None return None
except Exception as exc: # defensive - never block the delete flow except Exception as exc: # defensive - never block the delete flow
logger.warning( logger.warning(
@@ -454,6 +478,7 @@ class PendingDeleteService:
# Remove the manifest + batch dir only after all entries restored. # Remove the manifest + batch dir only after all entries restored.
self._remove_manifest(batch_dir) self._remove_manifest(batch_dir)
self._remove_empty_dir(batch_dir) self._remove_empty_dir(batch_dir)
self._remove_empty_staging_parent(batch_dir)
await self._forget_batch(batch_id) await self._forget_batch(batch_id)
# Clean up merged loser dirs (their staged files were restored # Clean up merged loser dirs (their staged files were restored
# above) and drop them from the registry too. # above) and drop them from the registry too.
@@ -832,6 +857,7 @@ class PendingDeleteService:
continue continue
self._remove_manifest(src) self._remove_manifest(src)
self._remove_empty_dir(src) self._remove_empty_dir(src)
self._remove_empty_staging_parent(src)
removed.append(os.path.basename(src)) removed.append(os.path.basename(src))
return removed return removed
@@ -842,6 +868,13 @@ class PendingDeleteService:
manifest = self._read_manifest(batch_dir) manifest = self._read_manifest(batch_dir)
if manifest is None: if manifest is None:
if not staging_dir_has_content(batch_dir):
# Crash leftover: the batch dir was created but nothing was ever
# staged into it. Quarantining an empty directory would leave a
# container that blocks folder deletion for good.
self._remove_empty_dir(batch_dir)
self._remove_empty_staging_parent(batch_dir)
return True
# Corrupted or manifest-less batch: quarantine, NEVER delete the # Corrupted or manifest-less batch: quarantine, NEVER delete the
# staged files (they may be the only copy of the user's data). # staged files (they may be the only copy of the user's data).
self._quarantine_batch_dir(batch_dir) self._quarantine_batch_dir(batch_dir)
@@ -885,6 +918,7 @@ class PendingDeleteService:
self._remove_manifest(batch_dir) self._remove_manifest(batch_dir)
self._remove_empty_dir(batch_dir) self._remove_empty_dir(batch_dir)
self._remove_empty_staging_parent(batch_dir)
self._remove_merged_batch_dirs(manifest) self._remove_merged_batch_dirs(manifest)
return True return True
@@ -1132,6 +1166,18 @@ class PendingDeleteService:
except OSError as exc: except OSError as exc:
logger.debug("Directory %s not empty or missing: %s", directory, exc) logger.debug("Directory %s not empty or missing: %s", directory, exc)
def _remove_empty_staging_parent(self, batch_dir: str) -> None:
"""Drop the staging container once its last batch is gone.
Only an empty ``.lm-pending-delete`` directory is removed (``rmdir``
refuses anything else), and only when the path really is one, so a model
folder is never at risk.
"""
parent = os.path.dirname(batch_dir)
if os.path.basename(parent) != PENDING_DELETE_DIR_NAME:
return
self._remove_empty_dir(parent)
def _new_batch_id(self) -> str: def _new_batch_id(self) -> str:
return uuid.uuid4().hex return uuid.uuid4().hex
+57 -2
View File
@@ -359,7 +359,9 @@ async def test_delete_folder_success_manifest_carries_excluded_model_count(tmp_p
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path): async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path) target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir() batch = target / ".lm-pending-delete" / "batch1"
batch.mkdir(parents=True)
(batch / "manifest.json").write_text("{}", encoding="utf-8")
scanner = FakeScanner([tmp_path]) scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora") service = ModelMoveService(scanner, "lora")
@@ -370,6 +372,43 @@ async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path:
assert target.exists() assert target.exists()
@pytest.mark.asyncio
async def test_delete_folder_ignores_an_empty_staging_container(tmp_path: Path):
"""An empty `.lm-pending-delete` is a leftover, not a pending deletion.
Every purge, undo and failed staging used to leave the container behind, and
the name match alone made the folder undeletable for good: "A deletion is
still pending inside this folder. Wait for the undo window to expire." with
no window left to wait for (issue #1145 follow-up).
"""
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert not target.exists()
@pytest.mark.asyncio
async def test_delete_folder_refuses_over_a_quarantined_batch(tmp_path: Path):
"""Quarantine preserves the user's staged files, so it still blocks."""
target = _make_nested(tmp_path)
quarantined = target / ".lm-pending-delete" / "batch1.orphaned"
quarantined.mkdir(parents=True)
(quarantined / "model.safetensors").write_bytes(b"user-data")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["code"] == "busy"
assert (quarantined / "model.safetensors").read_bytes() == b"user-data"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path): async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path):
scanner = FakeScanner([tmp_path]) scanner = FakeScanner([tmp_path])
@@ -669,7 +708,9 @@ async def test_rename_folder_refuses_symlinked_directory(tmp_path: Path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path): async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path) target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir() batch = target / ".lm-pending-delete" / "batch1"
batch.mkdir(parents=True)
(batch / "manifest.json").write_text("{}", encoding="utf-8")
scanner = FakeScanner([tmp_path]) scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora") service = ModelMoveService(scanner, "lora")
@@ -681,6 +722,20 @@ async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path:
assert scanner.renamed_folders == [] assert scanner.renamed_folders == []
@pytest.mark.asyncio
async def test_rename_folder_ignores_an_empty_staging_container(tmp_path: Path):
"""The leftover container must not block a rename either."""
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), "animation")
assert result["success"] is True
assert scanner.renamed_folders
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_rename_folder_requires_path(tmp_path: Path): async def test_rename_folder_requires_path(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora") service = ModelMoveService(FakeScanner([tmp_path]), "lora")
@@ -2298,3 +2298,89 @@ async def test_symlink5_cross_volume_merge_then_one_undo_restores_everything(
assert not (root_b / PENDING_DELETE_DIR_NAME / bid_b).exists() assert not (root_b / PENDING_DELETE_DIR_NAME / bid_b).exists()
staging = root_a / PENDING_DELETE_DIR_NAME staging = root_a / PENDING_DELETE_DIR_NAME
assert not staging.exists() or not any(staging.iterdir()) assert not staging.exists() or not any(staging.iterdir())
# ---------------------------------------------------------------------------
# (n) the staging CONTAINER is dropped with its last batch (issue #1145)
# ---------------------------------------------------------------------------
async def test_n_purge_removes_the_empty_staging_container(
tmp_path: Path, monkeypatch
) -> None:
"""`.lm-pending-delete` is a container, not a record of a pending delete.
Leaving it behind made every later folder delete (and rename) answer "A
deletion is still pending inside this folder" forever, because the folder
manifest only matched the directory name.
"""
root = tmp_path / "loras"
root.mkdir()
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "expired")
manifest_path = root / PENDING_DELETE_DIR_NAME / batch_id / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["expires_at"] = int(time.time()) - 10
manifest_path.write_text(json.dumps(manifest))
await service.purge_expired()
assert not (root / PENDING_DELETE_DIR_NAME).exists()
async def test_n_undo_removes_the_empty_staging_container(
tmp_path: Path, monkeypatch
) -> None:
root = tmp_path / "loras"
root.mkdir()
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "restored")
await service.undo(batch_id)
assert (root / "restored.safetensors").exists()
assert not (root / PENDING_DELETE_DIR_NAME).exists()
async def test_n_container_survives_while_another_batch_is_staged(
tmp_path: Path, monkeypatch
) -> None:
"""Only the last batch out drops the container."""
root = tmp_path / "loras"
root.mkdir()
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
expired_id = await _stage_simple(service, root, "expired")
fresh_id = await _stage_simple(service, root, "fresh")
manifest_path = root / PENDING_DELETE_DIR_NAME / expired_id / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["expires_at"] = int(time.time()) - 10
manifest_path.write_text(json.dumps(manifest))
await service.purge_expired()
staging = root / PENDING_DELETE_DIR_NAME
assert staging.is_dir()
assert not (staging / expired_id).exists()
assert (staging / fresh_id).exists()
async def test_n_empty_manifestless_batch_is_dropped_not_quarantined(
tmp_path: Path, monkeypatch
) -> None:
"""A crash between makedirs and the manifest leaves nothing to preserve."""
root = tmp_path / "loras"
root.mkdir()
staging = root / PENDING_DELETE_DIR_NAME
(staging / "batch1").mkdir(parents=True)
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
await service.purge_expired(scan_roots=True)
assert not staging.exists()