Repository navigation
Conversation
Deeds67
force-pushed
the
feat/chunked-resumable-upload
branch
from
September 11, 2026 19:19
a0d707f to
9ec36aa
Compare
Deeds67
force-pushed
the
feat/chunked-resumable-upload
branch
from
September 19, 2026 22:02
92d3591 to
86685c1
Compare
Deeds67
force-pushed
the
feat/chunked-resumable-upload
branch
from
October 8, 2026 20:57
86685c1 to
223269b
Compare
Deeds67
force-pushed
the
feat/chunked-resumable-upload
branch
from
October 9, 2026 17:06
e75f46b to
3c5db41
Compare
Deeds67
force-pushed
the
feat/chunked-resumable-upload
branch
from
October 15, 2026 06:58
3c5db41 to
dc4823b
Compare
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
force-pushed
the
feat/chunked-resumable-upload
branch
from
October 28, 2026 13:38
dc4823b to
9b5944d
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/mainstill carries no tus or chunked upload code as of today).Why
Two problems, one cause — every client uploads an asset as a single
POST /assetswhose body is the whole file:client_max_body_size 50000M, which is a workaround and is unavailable behind a managed proxy.Design
A tus-shaped subset, hand-rolled inside Nest —
POSTcreate-session,HEADoffset,PATCHwithapplication/offset+octet-stream,DELETEabort. Full design inspecs/2026-09-08-chunked-upload-design.md.The two decisions that shape everything else:
UploadFileto the existingAssetMediaService.uploadAsset(). Asset creation, dedupe, quota, live-photo linking, shared links and spaces all run the existing code unchanged.revert-to-immich.sqlblocks 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().sizerather 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
unlinkwas 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:fsPromises.unlink, concurrentok, ok, ok— all three win, every timefsPromises.rename, concurrentok, ENOENT, ENOENT— exactly one winner, every timeunlinkSync, sequential (control)ok, ENOENT, ENOENT— confirms the harnessWith
unlinkas the claim, two concurrent final chunks would both finalize — one upload becoming two assets, or one asset plus an orphaned file. The claim is nowrename, the measurement is recorded in the spec so nobody swaps it back, and the sweeper reclaims the.finalizingfiles 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
AssetMediaCreateSchemais built for multipart strings —isFavoriteisz.stringbool(rejects a JSONfalse) andmetadataisJsonParsed(rejects a real array). A JSON body would have failed validation on every request whiletscstayed green. The session endpoint gets its own JSON-native schema; §4.3 of the spec spells out the field-by-field mapping.operationIdcollision. The controller'screategeneratedcreateSession, colliding withSessionController'sPOST /sessions, and the Dart generator rejects duplicates outright —mise open-apifailed. Renamed; the generated Dart is verified withdart analyze --fatal-infosbecause the OpenAPI check does not compile Dart.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.as AssetMediaCreateDtocast 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:
writeChunkAtland 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.AssetControllerandUploadSessionControllerin production order and assertsHEADresolves correctly, since Express resolves HEAD against GET handlers.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, theUploadFiletype 0, thecanUploadFile/getUploadFilename/getUploadFoldertrio 2 (both small bugfixes).Verified in this branch:
server/src/middleware/file-upload.interceptor.tsis still byte-identical toupstream/main, and neitherasset-media.service.tsnorapp.common.tswas modified at all. The server side is new files plus about a dozen union lines.The real recurring cost is mobile —
background_upload.service.dartandforeground_upload.service.darttake ~19 and ~21 upstream commits a year, and the latter had zero fork edits before this.Known gaps, stated plainly
background_downloaderalready persists.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.dart analyze --fatal-infosclean). The native iOSRange-enqueue failure path,background_downloader's real persistence semantics, and an actual app-kill-and-restart were not exercised.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-apihas been removed in favour ofmise open-api, and the stated base version was six weeks out of date.https://claude.ai/code/session_01Rkzj87UgmFKFXKX5QNmSxo