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
+57 -2
View File
@@ -359,7 +359,9 @@ async def test_delete_folder_success_manifest_carries_excluded_model_count(tmp_p
@pytest.mark.asyncio
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: 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])
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()
@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
async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path):
scanner = FakeScanner([tmp_path])
@@ -669,7 +708,9 @@ async def test_rename_folder_refuses_symlinked_directory(tmp_path: Path):
@pytest.mark.asyncio
async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: 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])
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 == []
@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
async def test_rename_folder_requires_path(tmp_path: Path):
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()
staging = root_a / PENDING_DELETE_DIR_NAME
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()