From dbd4b915fd2d02f605dae7804fb030abc7f4a514 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 27 Jul 2026 20:50:33 -0400 Subject: [PATCH] fix(api): run single-video delete/update off the event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `delete_video` and `update_video` were declared `async def` while doing blocking SQLite and filesystem work. FastAPI runs an `async def` handler directly on the event loop, so each call stalled every other request and socket event for its duration. Their batch siblings (`delete_videos_from_list`, `star_videos_in_list`, `unstar_videos_in_list`, `delete_uncategorized_videos`) were already converted to sync `def` — which FastAPI offloads to the threadpool — during the #9163 review; these two single-item routes were deferred to this follow-on. Deferred non-blocker from PR #9163. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/videos.py | 9 +++++++-- tests/app/routers/test_videos_multiuser.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py index c535aa3b931..29e8b99cdf7 100644 --- a/invokeai/app/api/routers/videos.py +++ b/invokeai/app/api/routers/videos.py @@ -346,8 +346,12 @@ async def upload_video( pass +# Declared sync (`def`, not `async def`) so FastAPI runs it in the threadpool: every call +# below is blocking SQLite/filesystem work, which would stall the event loop — and with it +# every other request and socket event — for the duration of the delete. The batch +# siblings below are sync for the same reason. @videos_router.delete("/i/{video_name}", operation_id="delete_video", response_model=DeleteVideosResult) -async def delete_video( +def delete_video( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of the video to delete"), ) -> DeleteVideosResult: @@ -451,8 +455,9 @@ def delete_uncategorized_videos( ) +# Sync for the same reason as delete_video: the update is a blocking SQLite write. @videos_router.patch("/i/{video_name}", operation_id="update_video", response_model=VideoDTO) -async def update_video( +def update_video( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of the video to update"), video_changes: VideoRecordChanges = Body(description="The changes to apply to the video"), diff --git a/tests/app/routers/test_videos_multiuser.py b/tests/app/routers/test_videos_multiuser.py index 8282346b114..346173039f5 100644 --- a/tests/app/routers/test_videos_multiuser.py +++ b/tests/app/routers/test_videos_multiuser.py @@ -28,10 +28,12 @@ VideoNamesBatch, _is_mp4_file, delete_uncategorized_videos, + delete_video, delete_videos_from_list, get_video_thumbnail, star_videos_in_list, unstar_videos_in_list, + update_video, ) from invokeai.app.api_app import app from invokeai.app.services.invoker import Invoker @@ -932,3 +934,19 @@ def test_delete_uncategorized_videos_is_offloaded_by_fastapi() -> None: ) def test_video_batch_mutations_are_offloaded_by_fastapi(handler: Any) -> None: assert not inspect.iscoroutinefunction(handler) + + +@pytest.mark.parametrize( + "handler", + [ + delete_video, + update_video, + ], +) +def test_single_video_mutations_are_offloaded_by_fastapi(handler: Any) -> None: + """Single-item mutations do blocking SQLite/disk work, same as their batch siblings. + + Declared ``async def``, they would run that work directly on the event loop and stall + every other request and socket event until the delete/update finished. + """ + assert not inspect.iscoroutinefunction(handler)