Skip to content

feat(file_storage): direct, resumable multipart uploads (#454) - #483

Merged
antosubash merged 6 commits into
mainfrom
feat/multipart-uploads-454
Oct 15, 2026
Merged

antosubash merged 6 commits into
mainfrom
feat/multipart-uploads-454

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Part of the #447-#459 enhancement batch (the design spec lands with #481).

Changes

  • DirectUploadBackend capability (separate protocol, implemented by the S3 and filesystem backends) so large uploads go in parts instead of one request.
  • S3: presigned part URLs with a signed ContentLength, s3_public_endpoint_url for browser-facing endpoints, and a CSP connect-src provider so the browser can PUT straight to the bucket.
  • Filesystem: parts are relayed through the app (PUT .../parts/{n}), assembled with a temp file and atomic rename.
  • upload_session table + Alembic migration; endpoints to create a session, get part URLs, PUT a part, complete, abort and resume.
  • Quota is counted across open sessions (reservations) and re-checked at complete, under a per-tenant Postgres advisory lock.
  • Complete is idempotent: row lock, loser returns the same file with created=False; FileUploaded is published once.
  • Janitor: reaps expired sessions, a scoped S3 orphan sweep (only this module's key shape, only upload ids with no active session), and purge of finished session rows.
  • compute_checksum task when background_tasks is installed.
  • Browser resumable queue in the Files page with a resume prompt (Resume / Start over / Dismiss).

Behaviour changes

  • checksum_sha256 is now nullable (computed asynchronously after a multipart upload).
  • quota_bytes is now enforced for the session path.
  • Direct S3 uploads need a bucket CORS rule (PUT, exposing ETag) for the app origin.

Security review

  • HIGH: S3 orphan sweep was bucket-wide. Fixed: scoped to this module's key shape and to upload ids without an active session.
  • HIGH: concurrent complete/abort had no row lock (double assembly, double FileUploaded). Fixed with FOR UPDATE + status re-check.
  • HIGH: rejection paths (quota, too large, size mismatch) were rolled back with the HTTPException, leaving the session active. Fixed: returned as a committed response; regression test.
  • MEDIUM: quota reservation TOCTOU. Fixed with a Postgres advisory lock.
  • MEDIUM: compute_checksum retried forever on a deleted object. Fixed.
  • LOW: non-atomic etag sidecar fixed; docs corrected.
  • Checked sound: authorization (owner + tenant scoped, cross-tenant/colleague ids 404), path traversal, atomic assembly, fresh presign per attempt. Second review pass: clean.

Known limitations

  • Concurrent identical part PUTs on the filesystem backend can leave etag/data from different writers; the client retry heals it.
  • Two tabs uploading a file with identical name+size+mtime share a resume key.
  • A short part on the filesystem backend is stored then rejected with 400 until re-sent.
  • Resume requires re-selecting the file.
  • Cancel during the initial POST can leak a session until expiry (reaper collects).

Spec deviations

  • Separate DirectUploadBackend protocol rather than extending the base backend.
  • Filesystem backend relays parts instead of presigning.
  • 5 MiB minimum part size.
  • The checksum is not displayed in the UI, so no "-" placeholder was needed.

Verification

  • make lint: pass.
  • make test-js: 73 files, 570 tests passed.
  • make test-py: 4135 passed, 14 skipped, 1 xfailed.
  • Postgres: pytest -p no:anyio modules/file_storage 274 passed, 1 skipped (the opt-in real-S3 test).
  • make migrations-roundtrip-pg: upgrade, downgrade base, upgrade, alembic check clean.
  • moto-backed S3 tests in the file_storage suite; real S3 (RustFS) exercised through the opt-in test and browser QA.
  • Browser QA 6/6 scenarios passed (filesystem single/multipart, resume, cancel, limits, S3 70 MiB on RustFS, es fallback). Not browser-verified: S3 resume/cancel, per-file quota error on the small single-upload path.
  • P3, not addressed: the presigned S3 download redirect has no Content-Disposition (pre-existing, saves under the storage key).

Closes #454

https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4

Backend capability (create/presign/list/complete/abort/head) implemented for
S3 (presigned upload_part straight to the bucket, s3_public_endpoint_url for
the browser-facing host, CSP connect-src provider) and the filesystem
(parts relayed through PUT /uploads/{id}/parts/{n}, concatenated on
complete). Sessions are owner-scoped and tenant-scoped; the backend's part
listing is the source of truth for resume. checksum_sha256 becomes nullable;
a compute_checksum task and an hourly reaper ship in tasks.py, loaded only
when background_tasks is installed.

Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
…hardening (#454)

Browser: files above direct_upload_threshold_bytes upload via sessions, 4
parts at a time with per-part retry/backoff, localStorage resume keyed by
name+size+lastModified and a Resume upload / Start over prompt.

Hardening from security review: open sessions reserve their declared size
against the quota; complete re-checks max_direct_upload_bytes and the quota
against the stored object's size (head), deleting it on violation; S3 part
URLs sign ContentLength; part numbers are limited to 1..total_parts.

Docs and changelog (checksum_sha256 is now nullable).

Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
Lock the session row in complete/abort, commit rejected completes, scope the S3
orphan sweep to this module's keys and untracked uploads, serialise quota
reservations on Postgres, tolerate a deleted object in compute_checksum, write
FS part etags atomically.

Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
… follow-up)

Purge finished upload sessions past upload_session_retention_days, release a
session whose create was cancelled in flight, persist the abort of an expired
session, and cover the Postgres quota/complete locks with concurrency tests.

Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7515d2f
Status: ✅  Deploy successful!
Preview URL: https://9dd07d8c.simple-module-python.pages.dev
Branch Preview URL: https://feat-multipart-uploads-454.simple-module-python.pages.dev

View logs

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ✅ Completed 2026-10-15T12:39:12.673405Z 3a37d7e PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@antosubash
antosubash merged commit 26bcf8f into main Oct 15, 2026
14 checks passed
@antosubash
antosubash deleted the feat/multipart-uploads-454 branch October 18, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

file_storage: no multipart / presigned PUT uploads for large or resumable files

1 participant