perf(video): stream uploads into a single on-disk copy - #9396
Conversation
Declaring `file: UploadFile` makes Starlette parse the multipart body into its own spooled temp file before the route runs; the route then copied that into a named temp file it could hand to ffmpeg and `videos.create`. Every in-flight upload therefore held TWO full-size copies in temp storage (up to 2 x MAX_UPLOAD_SIZE x MAX_CONCURRENT_VIDEO_UPLOADS = 4 GB) for the whole probe/thumbnail/create phase. invoke-ai#9163 only shrank the overlap window by closing the spool right after the copy loop; the spool's own path can't be reused, because once rolled over it is an unlinked anonymous file with no path. The route now parses the body itself with python_multipart's streaming parser (already a FastAPI dependency), writing the `file` part directly into the one temp file and buffering only the small `metadata` field. Peak temp usage per upload is halved. Two behavioral improvements fall out of streaming the body: - The filename/MIME gate fires from the part headers, so an unsupported file is rejected before any of its bytes reach the disk instead of after the whole body has been spooled. - MAX_UPLOAD_SIZE is enforced as the bytes arrive rather than after. Parsing runs in the thread pool (it writes to disk), and the request schema is pinned with `openapi_extra` so the documented multipart contract is byte-for- byte what the `file` + `metadata` parameters generated — the only OpenAPI change is that the body schema is now inline rather than a `Body_upload_video` component, which nothing references. Deferred non-blocker from PR invoke-ai#9163. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same fields, types and requiredness — the body schema is now inline rather than a Body_upload_video component (nothing references it). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Merge blockers:
invokeai/app/api/routers/videos.py:406-419: A denied or unknownboard_idraises before_stream_video_upload()readsrequest.stream(). The upload middleware has already incremented its global and per-user counters, but the route returns immediately and itsfinallyreleases those counters while a chunked client can keep sending the body, defeating the 429, idle-timeout, and duration accounting. Test: drive the ASGI stack with the maximum number of forbidden-board uploads whosereceive()keeps yielding slowhttp.requestchunks, then start another upload; it must remain 429 until the first requests disconnect, but this route returns 403 and releases the slots.
Other findings/issues:
invokeai/app/api/routers/videos.py:299-305:_stream_video_upload()callsMultipartParser.finalize()and checks onlysaw_file_part;python-multipartfinalization does not verify that the parser reached its end state. A body ending immediately after file bytes, without the closing multipart boundary, therefore returnssaw_file_part=Trueand proceeds to MP4 validation/probe/create as if complete. This accepts malformed or interrupted uploads and can persist a truncated file when later media checks happen to pass. Test: send a valid file part withContent-Type: multipart/form-data; boundary=bbut omit--b--, then assert a 400/422 response and thatvideos.createis not called; current parser state isPART_DATA, yet the route continues.
Suggestions:
-
Consider explicitly aborting/draining rejected request bodies while retaining the middleware lease; this preserves early authorization rejection without a slow-upload quota hole.
-
Consider a sink-backed Starlette multipart parser or explicit parser-contract tests; this would reduce custom parsing logic and prevent future API/schema drift.
Addresses JPPhoto's review of invoke-ai#9396. Blocker 1 — a rejection that returns before the body is read released the upload middleware's concurrency leases while the client was still sending, so the 429 bound, idle timeout and duration cap stopped applying to it. Draining the body in the route does not work: FastAPI answers an invalid query string before the route body runs at all, and holding the drain would pin one of only MAX_CONCURRENT_VIDEO_UPLOADS slots for the full 30-minute duration cap per rejection — a cheaper denial of service than the hole it closed (measured: two slow `Content-Type: text/plain` requests wedged all uploads into 429s). VideoUploadLimitASGIMiddleware now asks the server to close the connection on any response sent before the body was read to completion, which ends the in-flight upload along with the response. This covers its own 401/413/429s and every early answer beneath it, including FastAPI's query validation. It is deliberately not seeded from Content-Length: h11 accepts `Content-Length: 0` alongside `Transfer-Encoding: chunked`, which would let a client suppress the close and then stream with no lease held and none of the caps applying. The byte-cap, idle-timeout and duration-cap aborts likewise leave the flag alone, since they fire precisely because the client is still uploading. Uploads that finish sending keep their connection (verified against a live uvicorn: two uploads over one socket). Blocker 2 — `MultipartParser.finalize()` is a documented no-op that does not check the parser reached its end state, so a body ending after the file bytes without the closing boundary looked complete and went on to be probed and persisted. The parser's `on_end` callback now proves completeness; a truncated body is a 422. This also catches the parser's silent `max_size` truncation. Also: a malformed body is a 422 rather than an unhandled 500; a client-side disconnect is a 400 rather than a 500 raised above the middleware; the Content-Type media type is compared case-insensitively (RFC 7231); and `MAX_CONCURRENT_VIDEO_UPLOADS` no longer claims two temp copies per upload. Per JPPhoto's second suggestion, a sink-backed Starlette parser is not available (MultiPartParser hardcodes SpooledTemporaryFile), so instead test_python_multipart_contract_the_upload_route_depends_on pins the three python_multipart behaviours the hand-rolled parsing rests on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVXZ215segxmicLV7tNy88
…o perf/video-upload-single-copy
|
Thanks — both blockers were real. Addressed in 38dd083, though the first one differently than suggested, for a reason worth spelling out. Blocker 1 — leases released while the client is still sendingConfirmed, and it's a regression this PR introduced: with I tried the suggested drain first and measured it. It doesn't work, for two independent reasons: It can't cover the paths that matter. So And it's a worse DoS than the hole.
The thing being protected (an unread connection) is not scarce; upload slots are. What I did instead: Two details that are load-bearing:
Uploads that finish sending keep their connection — verified live, two uploads over one socket, no close header. Worth flagging: behind a body-buffering reverse proxy (nginx's default, per the multi-user admin guide) Blocker 2 —
|
Conflicts were confined to the two generated API artifacts, both at the same spot: this branch removes the `Body_upload_video` component (the request body is inlined via `openapi_extra` so the route can parse the multipart stream itself), while main still declares it. Resolved in favour of this branch. Regenerating locally was not an option: this worktree's venv has fastapi 0.118.3 against pyproject's `>=0.141.1,<0.142` and no `sdnq` installed, so a full regeneration silently dropped main's Main_SDNQ_*/Qwen3Encoder_SDNQ_Config schemas and rewrote every binary field. The conflicted hunk was resolved by hand instead, and schema.ts was then regenerated from the resolved openapi.json with the locked frontend toolchain, which is reproducible. Main's newer FastAPI emits `contentMediaType: application/octet-stream` for binary fields where the old one emitted `format: binary`, so `openapi_extra` is updated to match (key order included). The inlined body is once again byte-identical to what the `file` + `metadata` parameters generate — the diff against main is now exactly the component removal plus the inlining, and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVXZ215segxmicLV7tNy88
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.
|
The 2026-08-29 review on this PR is the round-4 review of #9361 (transactional image deletion) — every cited path ( |
JPPhoto
left a comment
There was a problem hiding this comment.
Sorry about the earlier snafu. Here's a proper review. I am approving this and the following can be noted and either dealt with later on or ignored.
There are no merge blockers but there is a slight potential for a hostile attack:
invokeai/app/api/routers/videos.py:218,290-297: Tiny multipart chunks create one retainedbytesobject per metadata chunk. A 1 MiB field sent one byte at a time retains about 42 MiB in CPython; two concurrent uploads can add about 84 MiB. Effect: avoidable memory pressure or OOM. Likelihood: plausible hostile edge. Recovery: automatic after part completion, but OOM is unrecoverable. Test: feed 1 MiB metadata one byte perparser.write()and measure allocations.
Suggestions:
- Instead of storing metadata in
list[bytes], append to a boundedbytearray; this keeps the 1 MiB limit aligned with actual memory use and prevents per-chunk object amplification.
Summary
Follow-on to #9163 (deferred non-merge-blocker), and the last of that PR's deferred list.
Declaring
file: UploadFilemakes Starlette parse the multipart body into its own spooled temp file before the route body runs. The route then copied that into aNamedTemporaryFile, because ffmpeg andvideos.createneed a real path — and a rolled-overSpooledTemporaryFileis an unlinked anonymous file with no path to reuse.So every in-flight upload held two full-size copies in temp storage — up to
2 × MAX_UPLOAD_SIZE × MAX_CONCURRENT_VIDEO_UPLOADS= 4 GB — for the whole probe/thumbnail/create phase. #9163 only shrank the overlap by closing the spool right after the copy loop.Approach
The route now parses the body itself with
python_multipart's streaming parser (already a FastAPI dependency — this adds no new packages), writing thefilepart directly into the single temp file and buffering only the smallmetadatafield, under its own 1 MB cap. Peak temp usage per upload is halved.Two behavioral improvements fall out of streaming rather than spooling:
MAX_UPLOAD_SIZEis enforced as bytes arrive, not after they've all landed.Parsing runs in the thread pool, since the callbacks write to disk.
API contract
The request schema is pinned with
openapi_extrareproducing exactly what thefile+metadataparameters generated. The only difference in the regeneratedschema.tsis that the body type is now inline instead of aBody_upload_videocomponent — nothing references that component (the frontend builds itsFormDataby hand viaUploadVideoArg), and the field names, types and requiredness are unchanged.metadataJSON validation now runs after the body has streamed rather than before, since the field can legally arrive after the file part. Rejection is identical from the caller's point of view and still happens beforevideos.create.Testing
New tests in
test_video_upload_limits.py, driving the route with a chunked multipart body: single-copy write-through (byte-exact, in order), rejection from the part headers without consuming the rest of the body, missing-file-part → 422, oversized file part rejected mid-stream after ~the cap rather than the whole body, and the existing disk-full cleanup test ported to the new signature.test_configured_upload_slots_bound_peak_double_spool_usagebecomes..._peak_temp_storage_usageand now asserts the one-copy budget.The end-to-end multipart paths (malformed MP4 → 415, spoofed container → 415, malformed metadata → 422, valid metadata → 201) are already covered in
test_videos_multiuser.pyand pass unchanged.pytest tests/app/api tests/app/routers— 654 passed. Frontendlint:tsc/lint:prettierclean.🤖 Generated with Claude Code