fix(images): make single-image and intermediate deletion transactional - #9361
fix(images): make single-image and intermediate deletion transactional#9361lstein wants to merge 3 commits into
Conversation
Addresses two review findings from JPPhoto: 1. Single-image deletion was nontransactional and reported failure as success. ImageService.delete() now stages the image and thumbnail via stage_delete(), deletes the database record, then commits the stage and fires on-deleted callbacks. A database failure rolls the staged files back to their original paths and re-raises; a failed rollback is logged without masking the database error; a failed final purge is logged but does not fail the deletion (startup recovery cleans the staging directory). The delete_image route no longer swallows exceptions into an empty 200 payload: a missing image returns 404 and a service failure returns 500, mirroring the reviewed video route. 2. Intermediate cleanup deleted records before files, so a filesystem failure orphaned files and aborted cleanup. delete_intermediates() is now all-or-nothing: every intermediate file is staged first (any staging failure rolls back all prior stages and aborts before any record is touched), records are then deleted in a single delete_many call, and stages are committed afterwards with per-item error isolation. Callbacks fire only for committed deletions and no .delete_* staging directories remain after success. The destructive ImageRecordStorage.delete_intermediates() DB method is replaced by a read-only get_intermediates() so listing and record deletion are separate steps. Test coverage: - Service: positive single-delete (files, thumbnail, record, callback exactly once, no staging dirs); staging failure; database failure with on-disk restore of image and thumbnail; rollback failure preserving the database error; purge failure logged without failing. - Service: positive multi-intermediate cleanup; first and later staging failures (mock orchestration plus on-disk restore proof); database failure restoring all staged files; one rollback failure not abandoning remaining rollbacks; commit failure logged with remaining commits attempted and callbacks fired for committed deletions. - Route: successful delete through a real ImageService with real disk storage and SQLite records; missing image returns 404; database failure returns 500 with image and thumbnail restored and the record intact. - DB: get_intermediates() returns pairs without deleting; deletion via delete_many() verified separately. The public-board delete authorization test now wires urls/image_files services and asserts the deleted payload, since the route no longer masks service failures behind an empty success response. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7aabfbb to
887ebfd
Compare
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
-
invokeai/app/services/images/images_default.py:379-387: Cleanup snapshots intermediates, then unconditionally deletes names after the DB window. If an image becomes non-intermediate meanwhile, its record and staged files are deleted. Test: stageimg, changeis_intermediatetoFALSEbeforedelete_many, assert record remains. -
invokeai/app/api/routers/images.py:214-217: Everyget_dto()failure becomes 404, including DB/URL failures for existing images. Test: makeget_dtoraiseRuntimeError; current route returns 404, expected 500.
Suggestions:
- Consider conditional
delete_many(... WHERE is_intermediate = TRUE)or one transaction covering selection and deletion.
JPPhoto's review raised two merge blockers. Intermediate cleanup snapshotted the intermediates, then deleted those names unconditionally after the database window. An image promoted out of intermediate status in between lost both its record and its staged files. Deletion now runs through `delete_intermediates_by_names()`, which carries the `is_intermediate` predicate on the DELETE itself rather than on a preceding SELECT — Python's legacy sqlite3 transaction control opens a transaction only before a write, so a SELECT there holds no read lock to rely on. The method reports `(deleted, retained)` so the service can tell a promoted record from one that is simply gone: only a record still present earns a file restore. Restoring files for a record deleted elsewhere would strand them with no row and no staging dir for startup recovery, so the rollback path re-checks existence and errs towards keeping the files when the database can't answer. The name lists are chunked to stay under SQLITE_MAX_VARIABLE_NUMBER, which the previous `delete_many(all_intermediates)` call could exceed on a large library. The delete route turned every `get_dto()` failure into a 404, so a database fault on a live image told the frontend to drop it. It now returns 404 only for `ImageRecordNotFoundException` and 500 otherwise. That split could not work on its own: the record store converted every `sqlite3.Error` from `get()` and `get_metadata()` into `ImageRecordNotFoundException`, so a fault on the primary lookup still read as "missing". Those two methods now raise not-found only when the row is genuinely absent. This also stops `__recover_staged_deletes` from purging a live image's staged files on a transient database fault. Tests cover the promotion race at both the store and the service level (including a promotion interleaved inside the call, and a record deleted between the database window and the rollback), chunk boundaries, and that a database fault reaches the route as 500 rather than 404. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — both blockers are fixed in 82cb7b3. Cleanup deleting images that stopped being intermediatesTook the second half of your suggestion (one transaction covering selection and deletion), then went further, because the obvious version of it doesn't actually hold. A new The predicate rides on the DELETE, not on a preceding SELECT. My first attempt did The method reports Also chunked the name list at 500 bound parameters. The previous Every
|
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
invokeai/app/services/images/images_default.py:428-430: Retained-row check and staged-file restore are separate operations. Concurrent single-image or board deletion can remove row after_record_still_exists()returns but beforerollback_delete(); cleanup then restores image and thumbnail, removes staging dir, and leaves permanent unreferenced files. Reproduced by deleting promoted row fromimage_records.get()after reading it:delete_intermediates()returned 0 with row absent, both files present, and zero staging dirs. Test: add this interleaving test; expect absent row to leave files purged.
Suggestions:
- Instead of releasing record-store transaction before retained-token rollback, restore retained files while same write transaction is held; competing deletes then run after restoration and can stage those files.
Summary
Follow-on PR 2 (items 2 and 3) from @JPPhoto's review of #9163 — the "Single-image deletion is nontransactional and reports failure as success" and "Intermediate-image cleanup deletes records before files" findings. (Item 1 of that list, the image list/names ownership filter, was folded into #9358 where it belongs thematically.)
Note
Stacked on #9163 — this branch is based on the WAN video branch because it reuses the
stage_delete/commit_delete/rollback_deletemachinery and startup recovery that only exist there. The diff will show #9163's changes until it merges; only the top commit (fcef797e26) is new. I'll rebase/retarget once #9163 lands.Single-image deletion (
ImageService.delete)Previously files were permanently removed before the DB record was deleted — a DB failure left a live record pointing at missing files, and the route swallowed the exception and returned HTTP 200 with an empty result (the frontend treated that as success and dropped the item from its cache).
Now, mirroring the reviewer-approved video pattern: stage image+thumbnail → delete record → commit stage → fire callbacks. On DB failure the staged files are rolled back to their original paths and the error re-raises; a failed rollback is logged without masking the DB error; a failed final purge is logged but doesn't fail the deletion (startup recovery cleans the staging dir). The
delete_imageroute returns 404 for a missing image and 500 on service failure instead of a success-shaped payload, mirroring the revieweddelete_videoroute.Intermediate cleanup (
ImageService.delete_intermediates)Previously records were deleted first, then files sequentially — a filesystem failure orphaned files and aborted cleanup of later entries.
Now all-or-nothing, favoring the existing integer response as the review suggested: stage every intermediate file first (any staging failure rolls back all prior stages with per-item isolation and aborts before any record is touched) → delete all records in one
delete_many(deleting exactly the staged names avoids racing an intermediate created mid-operation) → commit stages with per-item isolation → callbacks only for committed deletions. No.delete_*dirs remain after success. The destructive DB-layerdelete_intermediates()is replaced by a read-onlyget_intermediates()so listing and record deletion are separate steps (query-level only, no migration).Deliberately unchanged
delete_images_from_list/delete_uncategorized_imageskeep their per-image partial-success reporting — each per-image failure now goes through the transactionaldelete(), so no record/file divergence can occur; only the reporting style is preserved.delete_images_on_boardand the video services already used the staged pattern.Tests (per JPPhoto's specs)
tests/app/services/images/test_images_default.py, realDiskImageFileStorage+ mocked records): success deletes image, thumbnail, record, and fires callback exactly once with no staging dirs left; staging failure keeps the record; DB failure restores image and thumbnail on disk; rollback failure still surfaces the DB error; purge failure logged, not raised.tests/app/routers/test_images.py, real service + disk + SQLite): success returns the deleted name; missing image → 404; DB failure → 500 with image and thumbnail restored and the record intact — no success-shaped payload.get_intermediates()returns (name, subfolder) pairs without deleting.test_non_owner_can_delete_image_from_public_board) previously "passed" only because the route masked a service crash behind 200-empty; it now wires the needed services and asserts the actual deletion — strictly stronger.Full sweep of image/board/video service and route tests: 457 passed; ruff clean.
🤖 Generated with Claude Code