fix(images): make single-image and intermediate deletion transactional - #9361
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
|
…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.
A delete unit reads an image's subfolder, deletes its record, then purges its files at that subfolder, and a move unit does the opposite: relocates files and repoints the record. Interleaved, the delete purges the path its snapshot named while the files sit at the new one — permanent orphans, unrecoverable because the record is gone and a clean purge drops the journal (JPPhoto, PR invoke-ai#9361). ImageMoveService gains a shared image-mutation lock, held across each full plan-relocate-repoint batch cycle in move_all_images() and each per-job unit in startup_recovery(). ImageService holds the same lock across the bodies of delete(), delete_images_on_board(), delete_intermediates(), __clean_up_failed_save() and create()'s record-save-through-file-save span. create() needs it too: the record is visible to the move planner the moment it commits, and under the date strategy its subfolder can disagree with the move target by a day (local clock vs UTC created_at), so a relocation landing mid-save would name a subfolder the files were never written to. The failed-save cleanup additionally journals both the subfolder the save captured and the one the record names now, since partial files can be left at either. The request-entry maintenance guard stays: the lock is what makes the guard's check-then-act safe once a request has passed it. Tests drive both real services against one db and disk store from two threads; each fails against the unfixed source. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Thanks — both reviews addressed. One timing note first: your 17:53 review landed against the pre-round-4 code; its blocker (recovery dropping the journal without a re-check) and the Blocker — delete vs background subfolder moveConfirmed exactly as you described, same-process: the route guard is check-then-act, so a Fixed with the coordination you suggested: a shared image-mutation lock ( My adversarial pass then found the lock had to cover Tests (all in Two things I found along the way and did not fix here
Related and worth a separate look: the One behavioural note: a delete that passed the guard just before a move reservation now blocks on the lock (bounded per batch cycle, but up to a full move-all in total) instead of racing it. That's the point — the alternative was the orphan — but it means a delete can wait out a large maintenance run, and |
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
invokeai/app/services/image_files/image_files_disk.py:389ignores directoryfsyncfailures, and:323removes the journal without syncing purge-directory changes. A single-process crash can leave the DB row gone while files reappear, with no journal for recovery. Effect: permanent orphaned files. Likelihood: Low but real. Recovery: manual filesystem sweep only. Test: injectfsyncfailure and crash-order persistence, then restart; current tests do not cover this ordering. Docs:docs/src/content/docs/features/gallery.mdx.
Suggestions:
- Consider propagating POSIX directory-sync failures and syncing affected parent directories before removing the journal.
…e dropping the journal Two gaps in the delete journal's crash-consistency story: - `__fsync_directory` swallowed every fsync error, so a journal whose durability was unknown still licensed the record deletion. It now raises on real errors (EIO, ENOSPC, ...) and tolerates only the errnos that say the filesystem cannot sync a directory at all (EINVAL/ENOTSUP/ENOSYS/EBADF, the PostgreSQL rule). begin_delete()/stage_delete() fail closed: no journal, no files moved, image intact. - Every commit path unlinked the files and dropped the journal without making the unlinks durable. A filesystem that persists the journal's removal ahead of unlinks in other directories could bring the files back after a power loss with no journal left to find them. Commit (both token shapes), rollback and startup recovery now fsync the parent directories of every touched file before removing the journal; a failed sync keeps the journal for the next startup. Tests inject directory-fsync failures (EIO vs tolerated errnos), assert the sync-before-rmtree ordering on each path, and drive the failed-commit and failed-recovery journals through a restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PS1h4vJZLhfxMfPHNQRJTa
|
Thanks — both halves of this are real and are fixed in 7237282. Directory fsync failures were swallowed. One nuance on the framing: a process crash cannot bring files back or lose a journal — the kernel already owns those writes. What this closes is the power-loss / EIO case, which is the one the journal exists for. Purges were not durable before the journal was dropped. Every path that discards a journal — Tests ( Docs: Cost: one directory fsync per distinct parent directory touched per commit (typically two: the subfolder and its thumbnail mirror), issued once per batch for Two trade-offs worth stating plainly, both from the fresh-context adversarial pass on this diff:
|
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
invokeai/app/services/images/images_default.py:172-203: If journaling fails withEIO/ENOSPC, cleanup still deletes the record, then fallback deletion fails at the same journal step. Any remaining partial file becomes an unjournaled orphan.Effect:permanent orphan.Likelihood:Low but real.Recovery:manual sweep.Test:Force save failure after leaving a file, make directoryfsyncraiseEIO, and verify the record is not deleted without a durable journal.Docs:docs/src/content/docs/features/gallery.mdx.
Suggestions:
- Instead of deleting the record after
begin_delete()fails, preserve the record/files or retry until a durable purge journal exists.
…ournaled __clean_up_failed_save() deleted the record even when begin_delete() had raised, then fell back to a journal-less stage+commit that fails at the same directory-fsync step. Any file that survived the save became an orphan with no journal to find it. Like every other delete path, the cleanup now removes the record only once a durable journal exists. When journaling fails it logs and leaves the half-created image whole: the record is what lets a later, ordinary delete find and clear it. The dead journal-less fallback is gone with it. Tests: a mock-level test that the record is never deleted after a failed begin_delete(), and a real-storage test that leaves a file behind, makes directory fsync raise EIO during the save's cleanup, checks record + file survive with no journal, and then clears the image with a normal delete once the disk is healthy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PS1h4vJZLhfxMfPHNQRJTa
|
Agreed, and fixed in 983ea55. That was the one path still willing to delete a record without a durable journal.
I chose "leave it whole" over "retry until a journal exists": the failure here is the storage refusing to fsync its directories, and spinning on that inside the mutation lock (which the cleanup holds) would block every other delete and the mover for as long as the volume stays broken. A visible entry the user can delete later is the recoverable outcome; an invisible orphan is not. Tests, in
Both fail against the previous commit. Docs updated in From the adversarial pass on this diff, two things worth knowing, neither changed here:
|
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