Summary
Since image_subfolder_move_items.image_name gained ON DELETE CASCADE (PR #34), deleting an image while a subfolder move job is in flight now succeeds and silently leaves the relocated file on disk with nothing in the database referencing it.
Before the cascade this same delete failed with a FOREIGN KEY constraint failed / ImageRecordDeleteException. That was itself a bug — it also made every previously-moved image permanently undeletable — so the cascade is a net improvement. This issue tracks the narrower leak it leaves behind.
Why it happens
Image deletion is not gated on an active move job. _has_active_job_for_image is consulted only when planning a move (invokeai/app/services/image_moves/image_moves_default.py:438), never on the delete path.
During perform_filesystem_moves the file has already been relocated to new_subfolder and the item row is state='moved', but images.image_subfolder still holds the old value until commit_database_updates runs. ImagesService.delete() (invokeai/app/services/images/images_default.py:382-386) deletes the file using the stale record.image_subfolder, then deletes the record. stage_delete (invokeai/app/services/image_files/image_files_disk.py:237) tolerates a missing file at the old location, so nothing surfaces the discrepancy.
Reproduction
- Set
image_subfolder_strategy to a non-flat value so move jobs run.
- Get a job into state
moving with an item x.png in state moved — file physically at <root>/new/x.png, while images.image_subfolder is still old.
- Delete
x.png through the API.
Observed: delete succeeds; the cascade removes the image_subfolder_move_items row; <root>/new/x.png remains on disk, unreferenced. _get_items(job_id) returns [].
Expected: either the delete is refused while a job is active for that image, or it removes the file at its current location.
Suggested fix
Gate ImagesService.delete() (and the bulk delete paths) on _has_active_job_for_image, or resolve the file's live location from the move item's new_path when an item is in state moved.
Context
Found during adversarial review of PR #34 and documented in the docstring of migration_2026_08_08_repair_image_subfolder_move_tables.py, which introduces the cascade. Deliberately not fixed there — the correct gate belongs in image_moves / images, not in a migration.
Summary
Since
image_subfolder_move_items.image_namegainedON DELETE CASCADE(PR #34), deleting an image while a subfolder move job is in flight now succeeds and silently leaves the relocated file on disk with nothing in the database referencing it.Before the cascade this same delete failed with a
FOREIGN KEY constraint failed/ImageRecordDeleteException. That was itself a bug — it also made every previously-moved image permanently undeletable — so the cascade is a net improvement. This issue tracks the narrower leak it leaves behind.Why it happens
Image deletion is not gated on an active move job.
_has_active_job_for_imageis consulted only when planning a move (invokeai/app/services/image_moves/image_moves_default.py:438), never on the delete path.During
perform_filesystem_movesthe file has already been relocated tonew_subfolderand the item row isstate='moved', butimages.image_subfolderstill holds the old value untilcommit_database_updatesruns.ImagesService.delete()(invokeai/app/services/images/images_default.py:382-386) deletes the file using the stalerecord.image_subfolder, then deletes the record.stage_delete(invokeai/app/services/image_files/image_files_disk.py:237) tolerates a missing file at the old location, so nothing surfaces the discrepancy.Reproduction
image_subfolder_strategyto a non-flatvalue so move jobs run.movingwith an itemx.pngin statemoved— file physically at<root>/new/x.png, whileimages.image_subfolderis stillold.x.pngthrough the API.Observed: delete succeeds; the cascade removes the
image_subfolder_move_itemsrow;<root>/new/x.pngremains on disk, unreferenced._get_items(job_id)returns[].Expected: either the delete is refused while a job is active for that image, or it removes the file at its current location.
Suggested fix
Gate
ImagesService.delete()(and the bulk delete paths) on_has_active_job_for_image, or resolve the file's live location from the move item'snew_pathwhen an item is in statemoved.Context
Found during adversarial review of PR #34 and documented in the docstring of
migration_2026_08_08_repair_image_subfolder_move_tables.py, which introduces the cascade. Deliberately not fixed there — the correct gate belongs inimage_moves/images, not in a migration.