Skip to content

feat: chunked / resumable upload across server, web, CLI and mobile - #1085

Open
Deeds67 wants to merge 2 commits into
mainfrom
feat/chunked-resumable-upload
Open

Deeds67 wants to merge 2 commits into
mainfrom
feat/chunked-resumable-upload

Conversation

@Deeds67

@Deeds67 Deeds67 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Adds chunked / resumable upload across the server and all four clients, so a large file no longer has to fit in a single HTTP request.

Closes the long-standing gap tracked upstream in immich-1674 (locked since 2024, never implemented; upstream/main still carries no tus or chunked upload code as of today).

Why

Two problems, one cause — every client uploads an asset as a single POST /assets whose body is the whole file:

  1. Reverse-proxy body limits. Cloudflare caps request bodies at 100 MB and it cannot be raised. Our own FAQ documents this as a hard wall, and the reverse-proxy guide tells self-hosters to set client_max_body_size 50000M, which is a workaround and is unavailable behind a managed proxy.
  2. No resumption. A failure at 95% of a 4 GB video discards 4 GB of transfer.

Design

A tus-shaped subset, hand-rolled inside Nest — POST create-session, HEAD offset, PATCH with application/offset+octet-stream, DELETE abort. Full design in specs/2026-09-08-chunked-upload-design.md.

The two decisions that shape everything else:

  • Finalize hands an UploadFile to the existing AssetMediaService.uploadAsset(). Asset creation, dedupe, quota, live-photo linking, shared links and spaces all run the existing code unchanged.
  • Session state is a JSON sidecar in the upload folder, and the partial file is the final file. No fork migration, so no revert-to-immich.sql blocks and no guard-spec update; no assembly copy, which matters at multi-gigabyte sizes. Uploads already land on local disk before the S3 backend sees them, so S3 multipart never enters the picture.

Chunks are written positionally, and the committed offset is derived from fs.stat().size rather than stored — so a replayed chunk rewrites identical bytes and there is no offset record to race.

The bug this nearly shipped

The design originally claimed unlink was atomic enough to serve as the finalize claim, so exactly one of two concurrent final chunks would win. That is false in practice. Measured on Node v24.15.0 / darwin, 200 runs of three concurrent claims:

Mechanism Result
fsPromises.unlink, concurrent ok, ok, ok — all three win, every time
fsPromises.rename, concurrent ok, ENOENT, ENOENT — exactly one winner, every time
unlinkSync, sequential (control) ok, ENOENT, ENOENT — confirms the harness

With unlink as the claim, two concurrent final chunks would both finalize — one upload becoming two assets, or one asset plus an orphaned file. The claim is now rename, the measurement is recorded in the spec so nobody swaps it back, and the sweeper reclaims the .finalizing files a crashed finaliser leaves behind.

It was caught because a test was failing and the failure was investigated rather than retried.

Other corrections made during implementation

  • The create DTO could not have worked. AssetMediaCreateSchema is built for multipart strings — isFavorite is z.stringbool (rejects a JSON false) and metadata is JsonParsed (rejects a real array). A JSON body would have failed validation on every request while tsc stayed green. The session endpoint gets its own JSON-native schema; §4.3 of the spec spells out the field-by-field mapping.
  • operationId collision. The controller's create generated createSession, colliding with SessionController's POST /sessions, and the Dart generator rejects duplicates outright — mise open-api failed. Renamed; the generated Dart is verified with dart analyze --fatal-infos because the OpenAPI check does not compile Dart.
  • The generated client cannot type the chunk PATCH. oazapfts emits it as Promise<unknown> and drops status and headers, so no client can tell 204 from 200/201 from 409 through it. Every client hand-rolls the PATCH; create/HEAD/DELETE still use the SDK.
  • A blanket as AssetMediaCreateDto cast in finalize was disabling type checking; removing it immediately surfaced a genuine missing field.

Test coverage

49 numbered edge cases in the spec, each mapped to a named test in a coverage table. Highlights:

  • The three storage invariants are mutation-proven: making writeChunkAt land 64 KiB past the intended offset fails all three, including the sparse-file check — which is the only direct test that the exact-offset rule is intact.
  • The route-collision test builds a Nest module with both AssetController and UploadSessionController in production order and asserts HEAD resolves correctly, since Express resolves HEAD against GET handlers.
  • The auth-kind check asserts all four combinations, not just the two failing ones — testing only the failures would pass against an implementation that rejects everything.
  • e2e uploads identical bytes through both protocols as two different users and asserts the checksums match, which is what catches a dropped, doubled or misplaced chunk.

Rebase cost

This fork rebases onto upstream regularly, so the coupling surface was measured rather than guessed. The contracts this depends on are near-frozen: uploadAsset()'s signature took 1 upstream commit in 12 months, the UploadFile type 0, the canUploadFile/getUploadFilename/getUploadFolder trio 2 (both small bugfixes).

Verified in this branch: server/src/middleware/file-upload.interceptor.ts is still byte-identical to upstream/main, and neither asset-media.service.ts nor app.common.ts was modified at all. The server side is new files plus about a dozen union lines.

The real recurring cost is mobile — background_upload.service.dart and foreground_upload.service.dart take ~19 and ~21 upstream commits a year, and the latter had zero fork edits before this.

Known gaps, stated plainly

  • Resumption is within-session only. Closing the tab or losing the Dart isolate abandons the upload; the server session is swept after 24h. Mobile background gets restart survival incidentally because the chain state rides in a field background_downloader already persists.
  • The database-backed medium test is not implemented — the medium harness mocks StorageRepository, so it would not add real-filesystem coverage, and the behaviour is already covered three ways. Reasoned in the spec rather than left as a silent hole.
  • The e2e specs have not been run locally. The shared e2e stack on :2285 has been up for two days on an image without these routes, and rebuilding it would disrupt other sessions. CI runs them in a clean environment.
  • Mobile has not been exercised on a real device — unit tests only (3,591 passing, dart analyze --fatal-infos clean). The native iOS Range-enqueue failure path, background_downloader's real persistence semantics, and an actual app-kill-and-restart were not exercised.
  • Background progress aggregation is not wired to any UI. Nothing in the repo currently consumes BackgroundUploadService.taskProgressStream, so the aggregation is implemented and unit-tested in isolation rather than observed end to end. The foreground path, which does feed the UI, is unaffected by chunking.

Docs

The reverse-proxy guide now explains that a generous body limit is no longer required, with a warning not to tighten it until every client is updated — an older mobile app still uploads single-shot and would start failing with 413. Three genuinely stale CLAUDE.md items were fixed along the way: the documented single-test command silently ran the whole suite, make open-api has been removed in favour of mise open-api, and the stated base version was six weeks out of date.

https://claude.ai/code/session_01Rkzj87UgmFKFXKX5QNmSxo

@github-actions github-actions Bot added documentation Improvements or additions to documentation 🗄️server cli 🖥️web 📱mobile labels Sep 8, 2026
@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Sep 8, 2026
Squashed from 44 commits when rebasing onto the v3.3.3 main (originals on backup/pr-1085-2026-10-15):

- docs: design for chunked / resumable upload
- docs: add the refactor step to the chunked-upload TDD loop
- feat(server): add the chunked-upload session create DTO
- feat(server): add the chunked-upload session store
- docs: claim chunked-upload finalization with rename, not unlink
- fix(server): claim finalize via rename, not unlink — unlink is not exclusive under concurrency
- docs: let the e2e chunked variant pick its own chunk size
- docs: correct stale commands and version in CLAUDE.md
- feat(server): create a chunked-upload session
- feat(server): upload-session offset, abort, and the auth-kind ownership check
- docs: document chunked upload
- docs: warn before tightening the proxy body limit
- test(server): make the row-10 ownership test's coverage explicit
- fix(server): satisfy unicorn/no-await-expression-member in the session store
- feat(server): register the upload-session cleanup job
- feat(server): advertise the resumable upload chunk size
- feat(server): append chunks to an upload session
- feat(server): finalize a chunked upload into an asset
- feat(server): sweep abandoned upload sessions
- feat(server): expose the chunked-upload session endpoints
- fix(server): give the upload-session endpoints unique operation ids
- chore: regenerate api clients for chunked upload
- test(server): cover the chunked-upload storage invariants
- docs: move the storage invariants out of the medium-test section
- fix(server): stop casting away the finalize DTO's type check
- docs: declare the database-backed medium test as a gap
- test(e2e): cover the chunked upload protocol
- feat(cli): upload large files in chunks
- docs: record that the generated client cannot type the chunk PATCH
- feat(mobile): chunk foreground uploads
- feat(web): add a chunked upload client
- feat(web): upload large files in chunks
- feat(mobile): chunk background backup uploads
- fix(server): keep upload-session paths inside their folder
- fix(server): reject unsafe upload-session filenames outright
- test(e2e): expect uploadChunkSize in the server config
- fix(server): build the sidecar path through the same guard
- fix(mobile): make the upload status handler awaitable
- fix(mobile): interpolate the error directly in the upload logger calls
- fix(mobile): satisfy the newly-enabled Dart analyzer rules
- test(server): admit the upload-session routes to the shared-link allowlist
- fix(rebase): carry chunked/resumable upload onto the v3.2.2 server
- fix(server): pass a storage repository to DiskStorageBackend in the upload-session spec
- fix(cli): satisfy main's stricter unicorn lint rules in the chunked-upload code
Fork controllers and services register in src/gallery/index.ts on main, not in
the upstream arrays.
@Deeds67
Deeds67 force-pushed the feat/chunked-resumable-upload branch from dc4823b to 9b5944d Compare October 28, 2026 13:38

This branch has not been deployed

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

Labels

changelog:feat Feature change for changelog cli documentation Improvements or additions to documentation 📱mobile 🗄️server 🖥️web

2 participants