fix(images): make single-image and intermediate deletion transactional - #9361
fix(images): make single-image and intermediate deletion transactional#9361lstein wants to merge 9 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.
…ge-deletion Conflicts were additive-only: - images.py: main's DeleteImagesResult gained failed_images; this branch replaced the swallow-everything handler with 404/500. Kept both. - image_records_sqlite.py: main landed the same 'storage error is not not-found' fix in get()/get_metadata() and added exists(). Kept main's wording. - test_images.py: both sides appended new helpers and tests. Kept both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XwVyhSN5AUBXGACM8yaLKD
…s gone Round-3 review (JPPhoto) found two ways the delete paths could still strand files on disk with nothing referencing them. B1 — delete_intermediates() commits the record deletions before purging the files, so a crash or a filesystem failure in that window left orphans with no trace, and the call still reported success. Deletion now writes a durable journal first: DiskImageFileStorage.begin_delete() fsyncs a manifest naming every image about to be purged, commit_delete() purges and drops it, and abandon_delete() discards it when the record deletion failed. Startup recovery reconciles any journal that outlives its operation by asking the record store: an image whose record survives keeps its files, an image whose record is gone has its files purged. A purge that fails keeps its journal and is retried at the next startup instead of being logged and forgotten. B2 — single-image delete staged the files before deleting the record, so two concurrent deletes of the same image could interleave such that the one that failed restored files the other had already unreferenced. delete() is now records-first over the same journal and moves nothing, so there is no restore to race. The one remaining staging user (delete_images_on_board, which keeps its documented per-item failure contract) is covered by rollback_delete(): it re-checks the record after restoring and purges instead of orphaning. That check is race-free because every deleter purges an image's files strictly after its record is committed as gone. Recovery now probes with image_records.exists() rather than a deserializing get(), and the manifest carries a list so one journal covers a whole intermediates sweep. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XwVyhSN5AUBXGACM8yaLKD
…e journal Three of them break the invariant the rollback re-check rests on — that every deleter purges an image's files strictly after its record is committed as gone. 1. commit_delete() on a staged token only removed the staging directory. stage_delete() captures whatever is on disk at that instant, so a second board delete racing the first gets an EMPTY token; if that second one is the one whose delete_many() succeeds, it removes the record and purges nothing, while the first one's rollback — re-checking a record that is still present at that moment — puts the files back. Permanent orphan, no journal. commit_delete() now purges the live paths too: committing means no file for that image survives, whichever request moved them. 2. create()'s cleanup after a failed save purged the files before deleting the record, so a board delete rolling back in that window was told to restore an image that was about to lose its record. It is now records-first over a journal like every other path. 3. begin_delete() fsynced the manifest and the journal directory but not the journal directory's own entry in the output folder, while SQLite does fsync the record deletion — so a power loss could drop the journal and keep the deletion. Both directories are now fsynced, and stage_delete() does the same before it moves any file (previously it fsynced neither, so a lost manifest stranded staged files in a directory naming nothing). Recovery also no longer aborts startup on a stray .delete_* entry that is not a directory, and says so when a journal has no manifest instead of silently walking past files it cannot attribute. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XwVyhSN5AUBXGACM8yaLKD
|
Thanks — both blockers were real, and they share a root cause: the delete paths had no durable record of intent, so any failure between the record write and the file write was invisible. Fixed in c2d7dfc (plus c4c09b5, below), and B1 —
|
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
invokeai/app/services/image_files/image_files_disk.py:485-497: Recovery checksexists()before restoring staged files, then drops the journal without rechecking. A concurrent delete can remove the record and purge while files remain staged; recovery then restores orphan files and deletes the only journal. Effect: permanent unreferenced image/thumbnail. Likelihood: Low, but reachable with shared output storage. Recovery: restart cannot find it. Test: raceexists()before restore, delete the record, then assert no files remain. Docs:docs/src/content/docs/features/gallery.mdxpromises startup cleanup.
Other findings/issues:
invokeai/app/api/routers/images.py:239-252:ImageRecordNotFoundExceptionfromimages.delete()becomes HTTP 500. If another request deletes the image after DTO lookup, the requested postcondition is already satisfied but the client receives a failure. Effect: false error/toast and retry churn. Likelihood: Medium with double-clicks or multiple clients. Test: delete betweenget_dto()and servicedelete(); expect 404 or idempotent success.
Suggestions:
- Instead of manual recovery restore, reuse
rollback_delete()or recheck record existence after restoring and before removing the journal. - Consider catching
ImageRecordNotFoundExceptionaround service deletion and treating it as 404/idempotent success. Focused storage/service/record tests passed; router run did not complete under xdist, and the context collector could not reach GitHub API.
…te that lost the race Startup recovery restored a staged image's files after a single `exists()` check and then dropped the journal. Another Invoke sharing the output folder could delete the record while the files sat staged — its purge finds nothing — and the restore then stranded the files with no record and no journal to find them by. Recovery now re-checks the record after a restore, exactly as rollback_delete() already does, and purges when it is gone. A record-store fault on the re-check keeps the journal, like every other lookup in the recovery loop. The delete route answered 500 when another request deleted the image between its DTO lookup and the service call. The image is gone, which is what the client asked for; it now answers 404 the way the lookup would have. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfBJJqxqmM3b6fKE1FiYGt
|
Thanks — both findings confirmed and fixed in cdf8458. Blocker — recovery restores without re-checking the recordReal, with one qualification worth recording: within a single Invoke it's unreachable, because The fix is the one I didn't reuse Tests:
|
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
invokeai/app/services/images/images_default.py:434-445: PR releases the shared DB lock afterget_intermediates(). In one Invoke, Clear Intermediates can passinvokeai/app/api/routers/images.py:267-278, then background move starts viainvokeai/app/api/routers/image_moves.py:62-72, moves files to a new subfolder, and updates the row. The delete then removes that row using stale subfolder data andinvokeai/app/services/image_files/image_files_disk.py:306-323purges only the old path.Effect:orphaned full image and thumbnail; journal is removed.Likelihood:Plausible same-process race between admin operations.Recovery:manual cleanup only.Test:Pause afterget_intermediates(), complete same-process folder move, resume, assert row and both new-path files are gone. Focused current-head tests passed but lack this race; context collector could not reach GitHub API.Docs:docs/src/content/docs/features/gallery.mdx.
Other findings/issues:
- None.
Suggestions:
- Consider a shared image-mutation lock or reservation covering snapshot, journal, record deletion, and purge, coordinated with image-folder maintenance.
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