fix(images): make single-image and intermediate deletion transactional - #9361
fix(images): make single-image and intermediate deletion transactional#9361lstein wants to merge 5 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.
…ge-deletion # Conflicts: # tests/app/services/images/test_images_default.py
…omoted-image orphan race Addresses JPPhoto's round-2 merge blocker on PR invoke-ai#9361. The prior revision staged every intermediate file, conditionally deleted the records, then restored the files of any image promoted out of intermediate status mid-operation. That restore is unfixably racy: while a promoted image's files sit in our staging directory, a concurrent single-image or board delete can stage-empty (find no files to move) and then remove the record; our restore then puts the files back with no record referencing them and no staging dir for startup recovery — a permanent orphan. Holding the record-store write transaction across the restore (the suggested fix) narrows but does not close the window, because the competing delete's file-staging happens under no lock and can precede the restore. delete_intermediates() now deletes records first and files second. The conditional DELETE is atomic and returns exactly the names it removed; we then purge only those files, best-effort (a filesystem failure orphans one file but never aborts the remaining purges or undoes the committed deletions). A promoted image is never deleted and its files are never staged, so there is no restore step for a concurrent delete to race, and a concurrent delete of that image operates on real files in the output folder and stays consistent. delete_intermediates_by_names() now returns just the deleted names instead of (deleted, retained); the retained set is no longer needed. Tests rewritten to the records-first contract, including a regression test that concurrently deletes a promoted image right after the conditional DELETE keeps it and asserts its files are not resurrected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Round-2 blocker (promoted-file orphan) fixed in Your suggestion — restore the retained files while holding the record-store write transaction — narrows the window but doesn't close it: the competing single/board delete's file-staging ( So I dropped the stage-then-restore approach entirely and went records-first: |
There was a problem hiding this comment.
Merge blockers:
-
invokeai/app/services/images/images_default.py:410-422: Records commit before file purge. A staging failure, or crash before line 414, leaves files without records or recovery journal; service still returns success. Test: failstage_delete()afterdelete_intermediates_by_names()succeeds; assert record absent, files present, no.delete_*. Docs:docs/src/content/docs/features/gallery.mdx. -
invokeai/app/services/images/images_default.py:298-326: Concurrent deletes can both stage one image; one DB delete succeeds while the other fails and rolls back its nonempty token, restoring files after the record is gone. Test: barrier after both staging calls, make one record delete fail and the other succeed, then assert absent record plus present files.
Suggestions:
-
Instead of records-first deletion, use a durable deletion journal or coordinated per-image lock; preserve retryability across crashes and filesystem failures.
-
Consider making staged-delete ownership explicit; a failed delete must not restore files after another request removed the record.
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