Skip to content

feat(server): apply the storage template to assets stored on S3 - #1239

Open
willtrking wants to merge 8 commits into
open-noodle:mainfrom
willtrking:willtrking/storage-template-on-s3
Open

willtrking wants to merge 8 commits into
open-noodle:mainfrom
willtrking:willtrking/storage-template-on-s3

Conversation

@willtrking

Copy link
Copy Markdown

Summary

With IMMICH_STORAGE_BACKEND=s3, the storage template did nothing. S3 uploads stayed at upload/<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 (RenameObject exists only for S3 Express One Zone directory buckets), so a move is:

  1. a server-side CopyObject (UploadPartCopy above 5 GiB), so no bytes pass through Gallery;
  2. a size check;
  3. the database pointed at the new key;
  4. a delete of the old key.

move_history gives 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.md records the design, the decisions and their reasons, and the known limits. It also marks the parts of specs/2026-03-02, 2026-04-19 and 2026-04-22 that it supersedes.

Behaviour

  • Uploads move to their template path (library/<storage label or user id>/…) after metadata extraction, as on disk.
  • Upgrade exception: an install that upgrades with photos or videos already in S3 and the template enabled waits.
    • Its S3 originals and sidecars keep their upload keys until an admin runs Storage Template Migration once. That run moves everything, and from then on uploads move automatically.
    • Uploads keep working normally in the meantime, and a warning is logged on every start while waiting.
    • The decision is made once, on the first start, under a database lock, and stored in system_metadata as storage-template-s3-state.
    • Fresh installs, installs whose template was off at upgrade, and disk files are not affected.
  • A manual Storage Template Migration on an S3 install also queues upstream's Migration job (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.
  • Verification matches upstream's disk move: a size check after each copy, and SHA-1 (hashVerificationEnabled) only when finishing an interrupted move.
  • Safety:
    • A destination counts as taken when either backend holds it.
    • A sidecar waits while its original's move is unfinished, and never overwrites an existing sidecar.
    • The template root ends in /, so EXIF-driven templates cannot escape into a sibling library folder.
    • On a size mismatch, only an original's copy is deleted.
    • The mover is S3-only: without S3 configured it warns and leaves the file.
  • Backends:
    • Copies keep the source's standard S3 storage class.
    • Gateways without UploadPartCopy fall back to CopyObject.
    • Disk copies keep atime/mtime.
    • Disk writes go to a temp file that is renamed into place.
  • Accounting and cleanup:
    • Storage usage counts the S3 library/<label>/ prefix.
    • User deletion removes that prefix, but skips it when another user's files are stored there.
    • The storage migration compares sizes before reusing an existing target.
  • Android motion videos: embedded motion videos in S3 (encoded-video/…-MP.mp4) are not templated, as on disk. This also fixes includeEmbeddedVideos for S3 downloads.

Layering

Fork logic lives in server/src/gallery/:

  • utils/s3-file-move.ts
  • utils/s3-key.ts, with toS3Key and isInStorageFolder
  • utils/storage-backends.ts, the backend registry: it imports no service, so the new code needs no lazy imports
  • utils/storage-template-s3.ts
  • services/storage-template-s3.service.ts

Upstream files, diffed against upstream v3.3.5 (upstream.version in branding/config.json), on main and on this branch:

Upstream file main This PR Why
server/src/services/storage-template.service.ts +10 −1 +17 −4 S3 skip removed; calls into the fork module for the library root, the destination check and the upgrade gate
server/src/cores/storage.core.ts +52 −5 +64 −6 the early return for keys becomes one call into the S3 mover; isAndroidMotionPath is one isInStorageFolder call
server/src/services/storage.service.ts +49 −2 +29 −2 shrinks: the backend instances moved to a leaf registry; four one-line delegates remain
server/src/services/user.service.ts +58 −9 +51 −9 shrinks: the fork-owned S3 cleanup block reads its prefixes from storage-usage.ts
server/src/enum.ts +210 +211 one SystemMetadataKey line
server/src/types.ts +210 −6 +211 −6 the state's type, one line
docs/docs/administration/storage-template.mdx none +8 an S3 note: the template applies in the bucket, the upgrade exception, and links to the S3 storage page

The 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) and storage.service.spec.ts (+1). Four more stay the same size, because they only swap StorageService's backend statics for the registry: the asset-media, auth and metadata service specs, and the medium asset-media spec.

storage-template-s3-state follows the fork's existing system_metadata state keys, and scripts/revert-to-immich.sql deletes it. move_history keeps Immich's meaning, and its rows are transient. Every changed file is covered by docs/fork/ownership.yml (the preflight coverage check passes).

No schema, migration, endpoint, OpenAPI or i18n changes. An earlier revision added a JobName value, which upstream's check-openapi rejects as a breaking change; the derivative pass reuses upstream's Migration job instead.

Testing

  • On this branch, rebased onto 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 needs NODE_OPTIONS=--max-old-space-size=6144: at the default ~2 GB heap, shared-space.service.spec.ts runs out of memory on main too. commit-autolink-check and the ownership coverage check pass.
  • CI on the same commits before the rebase (fork PR willtrking/gallery 1, Immich v3.3.3 base): every check passed except the changelog-label check. That covered:
    • server unit tests and lint;
    • medium tests, among them the new hasS3Originals() spec;
    • SQL schema checks;
    • the web and Server & CLI E2E suites;
    • the revert-to-Immich validation.
  • Not run yet: the Storage Migration Tests workflow, which runs only on schedule or dispatch, never on pull requests. It covers:
    • the MinIO integration spec for the new backend copy/stat (keys that need encoding, overwriting, missing objects, part-by-part copy, and the fallback for gateways without UploadPartCopy);
    • the template-s3-* E2E phases, including template-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.

  • A clearing manual run whose S3 moves all fail still ends the wait. Each failure is logged.
  • hasS3Originals() scans the asset table once, at first start, on a large install without S3 originals.
  • Crash windows can leave an orphaned object. Integrity checks still skip S3 (Extend integrity checks to S3-backed storage #685).
  • RC images built from this branch's first commits already moved S3 uploads automatically. They decide "waiting" on their first start after updating, so testers should run the job once.

Generated by Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation 🗄️server labels Oct 23, 2026
@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Oct 24, 2026
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
@Deeds67
Deeds67 force-pushed the willtrking/storage-template-on-s3 branch from 0efdd0b to 0f1078b Compare October 28, 2026 13:35

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 documentation Improvements or additions to documentation 🗄️server

3 participants