Skip to content

perf(video): stream uploads into a single on-disk copy - #9396

Open
lstein wants to merge 5 commits into
invoke-ai:mainfrom
lstein:perf/video-upload-single-copy
Open

perf(video): stream uploads into a single on-disk copy#9396
lstein wants to merge 5 commits into
invoke-ai:mainfrom
lstein:perf/video-upload-single-copy

Conversation

@lstein

@lstein lstein commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-on to #9163 (deferred non-merge-blocker), and the last of that PR's deferred list.

Declaring file: UploadFile makes Starlette parse the multipart body into its own spooled temp file before the route body runs. The route then copied that into a NamedTemporaryFile, because ffmpeg and videos.create need a real path — and a rolled-over SpooledTemporaryFile is 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 the file part directly into the single temp file and buffering only the small metadata field, under its own 1 MB cap. Peak temp usage per upload is halved.

Two behavioral improvements fall out of streaming rather than spooling:

  • The filename/MIME gate fires from the part headers, so an unsupported file is rejected before any of its bytes reach the disk — previously the whole body was spooled first, then rejected.
  • MAX_UPLOAD_SIZE is 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_extra reproducing exactly what the file + metadata parameters generated. The only difference in the regenerated schema.ts is that the body type is now inline instead of a Body_upload_video component — nothing references that component (the frontend builds its FormData by hand via UploadVideoArg), and the field names, types and requiredness are unchanged.

metadata JSON 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 before videos.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_usage becomes ..._peak_temp_storage_usage and 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.py and pass unchanged.

pytest tests/app/api tests/app/routers — 654 passed. Frontend lint:tsc / lint:prettier clean.

🤖 Generated with Claude Code

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>
@github-actions github-actions Bot added api python PRs that change python files frontend PRs that change frontend files python-tests PRs that change python tests labels Jul 28, 2026
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>
@lstein lstein added the 6.14.1 label Aug 5, 2026
@lstein lstein moved this to 6.14.1: Bug fixes to 6.14.0 in Invoke - Community Roadmap Aug 5, 2026

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge blockers:

  • invokeai/app/api/routers/videos.py:406-419: A denied or unknown board_id raises before _stream_video_upload() reads request.stream(). The upload middleware has already incremented its global and per-user counters, but the route returns immediately and its finally releases 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 whose receive() keeps yielding slow http.request chunks, 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() calls MultipartParser.finalize() and checks only saw_file_part; python-multipart finalization does not verify that the parser reached its end state. A body ending immediately after file bytes, without the closing multipart boundary, therefore returns saw_file_part=True and 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 with Content-Type: multipart/form-data; boundary=b but omit --b--, then assert a 400/422 response and that videos.create is not called; current parser state is PART_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.1 api frontend PRs that change frontend files python PRs that change python files python-tests PRs that change python tests

Projects

Status: 6.14.1: Bug fixes to 6.14.0

Development

Successfully merging this pull request may close these issues.

2 participants