Repository navigation
feat(server): apply the storage template to assets stored on S3 - #1239
Open
willtrking wants to merge 8 commits into
Open
willtrking wants to merge 8 commits into
willtrking wants to merge 8 commits into
Conversation
With IMMICH_STORAGE_BACKEND=s3 the storage template did nothing: S3 uploads stayed under upload/<userId>/ and the Migration job skipped S3 thumbnails and encoded videos. S3 has no rename, so a move is now a server-side copy (multipart above 5 GiB, storage class kept), a size check, the database update and a delete of the old key, with move_history recovery as upstream's disk move has. - Backends gain copy() and stat(); the S3 CopySource is encoded per path segment so spaces, Unicode and the +N suffix survive. - StorageCore.moveFile hands any request that is not disk-to-disk to the fork mover; a file always stays in the backend that holds it. - The template renders S3 originals under the key library/<label> and treats a destination as taken in either backend, so a later storage migration cannot map two assets onto one file. - Starting Storage Template Migration by hand also re-paths S3 thumbnails, previews, full-size images, encoded videos and person thumbnails (new StorageBackendFileMigrationQueueAll job on the Migration queue); the Migration job handles S3 files too. - S3 Android motion videos (encoded-video/ keys) are not templated. - Storage usage and user deletion cover the S3 library/ prefix. - The storage migration compares sizes before trusting an existing target and removes the partial target of a failed copy. - Docs, nightly E2E phases and the design doc updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skgCM7pz8gspD4QpaRoLt
- A sidecar waits while its original's move is unfinished, and never overwrites a sidecar already at its destination. - A move_history row the database has moved past is replaced and the move starts from the database's path, so stale content is not adopted. - On a size mismatch only an original's copy is deleted; a derivative or sidecar key may have just been rewritten by another job. - An S3 error while probing a disk destination is logged through the service logger and the key treated as free; disk templating no longer depends on S3 being reachable. - The template library root ends in '/', so a template cannot escape library/admin into library/admin2. - S3 copy forwards only standard storage classes (MinIO tier names are rejected on copy) and falls back to CopyObject on gateways without UploadPartCopy. - Disk copy keeps atime/mtime and removes its copy if that fails; disk writes go to a fixed-length temp file renamed into place. - The storage migration no longer deletes the target after a failed put. - The manual derivative pass is not queued while Migration is running; user deletion skips an S3 library prefix holding another user's files. - The nightly storage-migration workflow runs the MinIO integration spec. - Docs and design doc updated to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skgCM7pz8gspD4QpaRoLt
…3 files An install that upgrades with originals already in S3 and the storage template enabled had the template do nothing in the bucket; moving its new uploads right after the upgrade would surprise its admin. On the first start of this version such an install records a pending state (storage-template-s3-state in system_metadata, decided once before the job workers start). While it is pending, the per-upload template job leaves files in S3 at their upload keys (uploads, serving and thumbnails work as before) and a warning is logged on each start. The first manually started Storage Template Migration that succeeds clears it, and uploads move automatically from then on. Fresh installs, installs whose template was off at upgrade, and disk files keep the automatic move. revert-to-immich.sql deletes the key. Tests, an E2E phase and docs follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skgCM7pz8gspD4QpaRoLt
… once The first-start decision now runs under the storage template's database lock and re-reads the state inside it, so the first worker's decision stands: a worker that starts after the API accepted an S3 upload would otherwise count that upload and decide differently. Only one worker runs the S3-originals query. Specs cover the per-upload job while waiting (S3 originals and sidecars, both live-photo mixes, disk-only assets that never read the state), the cleared and absent states, the manual bulk run, the decision rules and the clearing condition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skgCM7pz8gspD4QpaRoLt
- Nightly E2E: template-s3-upgrade-prepare deletes the state row and enables the template while originals are in S3, the workflow restarts the server, and template-s3-upgrade-pending asserts the restart decided pending and logged the warning, that an upload waits at its upload key, and that a manual run moves it and clears the state. - Medium spec for hasBackendOriginals() (disk, key and trashed-key originals; a key sidecar does not count). - The first-start decision logs a line before taking the lock, so a boot that waits on it or fails on the query can be diagnosed. - S3 and storage template docs describe the upgrade exception: only photos, videos and their sidecars wait, uploads keep working, the Migration job still re-paths derivatives. - Design doc records the rule, its tests and its limits (a clearing run whose moves all fail, the one-time scan, RC images). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skgCM7pz8gspD4QpaRoLt
- Backend registry: the disk/S3 backend instances move from private statics on StorageService into a leaf module (gallery/utils/storage-backends.ts); StorageService keeps one-line delegates. The mover and the template helpers import it directly, so the lazy imports this PR added (and the cycle they hid) are gone. - S3 naming: s3-file-move.ts (moveS3File, S3MoveDependencies), storage-template-s3.ts, StorageTemplateS3Service, hasS3Originals, streamAssetsWithS3Derivatives, streamPeopleWithS3Thumbnails and JobName.S3FileMigrationQueueAll (OpenAPI spec and SDK regenerated). The mover is S3-only: without S3 it warns and leaves the file. - One path-to-key mapping, toS3Key in gallery/utils/s3-key.ts, shared by the mover, the template destination check and the storage migration's computeTargetPath; isAndroidMotionPath becomes one isInStorageFolder call that reads disk paths and keys alike. - The mover's move_history cases move into startOrResumeMove, each commented with the state that produces it; the copy step and the recovery check share hasExpectedSize; the disk-sidecar branch is explained. - The storage migration's size check keeps its behaviour with the corrected reasons in its comment and the user docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skgCM7pz8gspD4QpaRoLt
The fork job S3FileMigrationQueueAll added a JobName value to the enum the /queues endpoints return, which upstream's OpenAPI breaking-change check (oasdiff, check-openapi) rejects: a client with the old enum may fail to decode it. A manual Storage Template Migration on an S3 install now queues upstream's FileMigrationQueueAll instead; its per-asset and per-person jobs reach S3 through StorageCore.moveFile, and it re-paths derivatives still on disk as well, as the Migration job always does. The fork job, its two stream queries and its enum, types, OpenAPI and SDK lines are gone; the guards (manual start, S3 configured, template enabled, Migration queue idle) are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skgCM7pz8gspD4QpaRoLt
…overs S3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skgCM7pz8gspD4QpaRoLt
Deeds67
force-pushed
the
willtrking/storage-template-on-s3
branch
from
October 28, 2026 13:35
0efdd0b to
0f1078b
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.
Summary
With
IMMICH_STORAGE_BACKEND=s3, the storage template did nothing. S3 uploads stayed atupload/<userId>/…forever, the Migration job skipped S3 thumbnails and encoded videos, and the S3 docs said the opposite. This PR makes the template work in the bucket.S3 has no rename (
RenameObjectexists only for S3 Express One Zone directory buckets), so a move is:CopyObject(UploadPartCopyabove 5 GiB), so no bytes pass through Gallery;move_historygives the same crash recovery upstream's disk move has. A file always stays in the backend that holds it.Spec:
specs/2026-10-20-storage-template-s3-moves-design.mdrecords the design, the decisions and their reasons, and the known limits. It also marks the parts ofspecs/2026-03-02,2026-04-19and2026-04-22that it supersedes.Behaviour
library/<storage label or user id>/…) after metadata extraction, as on disk.system_metadataasstorage-template-s3-state.FileMigrationQueueAll), unless it is already running. That job re-paths thumbnails, previews, full-size images, encoded videos and person thumbnails, in S3 and on disk. The Migration job now handles S3 files too.hashVerificationEnabled) only when finishing an interrupted move./, so EXIF-driven templates cannot escape into a sibling library folder.UploadPartCopyfall back toCopyObject.library/<label>/prefix.encoded-video/…-MP.mp4) are not templated, as on disk. This also fixesincludeEmbeddedVideosfor S3 downloads.Layering
Fork logic lives in
server/src/gallery/:utils/s3-file-move.tsutils/s3-key.ts, withtoS3KeyandisInStorageFolderutils/storage-backends.ts, the backend registry: it imports no service, so the new code needs no lazy importsutils/storage-template-s3.tsservices/storage-template-s3.service.tsUpstream files, diffed against upstream
v3.3.5(upstream.versioninbranding/config.json), onmainand on this branch:mainserver/src/services/storage-template.service.tsserver/src/cores/storage.core.tsisAndroidMotionPathis oneisInStorageFoldercallserver/src/services/storage.service.tsserver/src/services/user.service.tsstorage-usage.tsserver/src/enum.tsSystemMetadataKeylineserver/src/types.tsdocs/docs/administration/storage-template.mdxThe upstream spec files grow with tests of the new behaviour:
storage-template.service.spec.ts(+1013 −1),storage.core.spec.ts(+132 −1),person.service.spec.ts(+91 −31),media.service.spec.ts(+65),user.service.spec.ts(+50) andstorage.service.spec.ts(+1). Four more stay the same size, because they only swapStorageService's backend statics for the registry: theasset-media,authandmetadataservice specs, and the mediumasset-mediaspec.storage-template-s3-statefollows the fork's existingsystem_metadatastate keys, andscripts/revert-to-immich.sqldeletes it.move_historykeeps Immich's meaning, and its rows are transient. Every changed file is covered bydocs/fork/ownership.yml(the preflight coverage check passes).No schema, migration, endpoint, OpenAPI or i18n changes. An earlier revision added a
JobNamevalue, which upstream'scheck-openapirejects as a breaking change; the derivative pass reuses upstream's Migration job instead.Testing
main(Immich v3.3.5): the server type-check passes, and the full server unit suite passes on Node 24 (7,002 tests); ESLint and Prettier are clean on the changed files. The suite needsNODE_OPTIONS=--max-old-space-size=6144: at the default ~2 GB heap,shared-space.service.spec.tsruns out of memory onmaintoo.commit-autolink-checkand the ownership coverage check pass.hasS3Originals()spec;copy/stat(keys that need encoding, overwriting, missing objects, part-by-part copy, and the fallback for gateways withoutUploadPartCopy);template-s3-*E2E phases, includingtemplate-s3-upgrade-prepare→ server restart →template-s3-upgrade-pending, which exercises the real upgrade decision.Known limits
All of these are recorded in the design doc.
hasS3Originals()scans the asset table once, at first start, on a large install without S3 originals.Generated by Claude Code