Skip to content

fix(server): make cached-file storage accounting opt-in, defaulting to upstream semantics - #979

Merged
Deeds67 merged 40 commits into
mainfrom
fix/storage-usage-opt-in-cached
Aug 12, 2026
Merged

fix(server): make cached-file storage accounting opt-in, defaulting to upstream semantics#979
Deeds67 merged 40 commits into
mainfrom
fix/storage-usage-opt-in-cached

Conversation

@Deeds67

@Deeds67 Deeds67 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Reverts user storage usage to upstream Immich semantics by default, and puts the fork's derivative-inclusive accounting behind one admin opt-in.

Since #492 the fork counted thumbnails and transcoded videos in a user's storage usage. That measures real disk consumption, but it makes the number shown to a user larger than the media they actually uploaded, which several people found misleading. This keeps both behaviours available and makes upstream's the default.

Behaviour

Admin → System Settings → Storage usage → "Include cached files in storage usage", default off. Also settable from a config file as storageUsage.includeDerivatives.

  • Off (default): usage is originals only, computed by upstream's SQL sum. Identical to upstream Immich.
  • On: usage is originals + thumbnails + transcoded videos, measured on disk and in S3.

The setting governs both what users are shown and what their quota is enforced against, so the displayed figure can never contradict the one that rejects an upload.

How it works

quotaUsageInBytes keeps holding whichever number the setting implies — the same approach #492 took, so there is no new column and no database migration.

  • Off: upstream's syncUsage SQL runs, one statement, originals only, external-library assets excluded. The nightly job costs exactly what upstream's does.
  • On: the nightly walk measures real bytes across library/, upload/, profile/, thumbs/ and encoded-video/ and writes the total into the same column.

Nothing downstream needs to know about the setting — enforcement, the sidebar meter and the admin user page all just read the column, exactly as upstream does. asset-media.service.ts, StorageSpace.svelte, database.ts, the user and server DTOs and the generated clients are all untouched by this PR.

Toggling the setting either direction queues an immediate resync, so the figure is correct without waiting for the nightly job. Config-file installs never emit a config-update event, so they get the same resync at boot.

External libraries

Derivatives belonging to external-library assets are no longer counted. Upstream excludes external assets from quota entirely, but thumbnail and transcode files are laid out by owner id with no library dimension, so the previous folder walk swept them in — inflating usage for anyone with a large external library. The asset id is recovered from the derivative filename to filter them out.

This is separate from the sidebar showing whole-disk figures for users without a quota, which is stock upstream behaviour and is unchanged here.

Upgrade

No migration. On upgrade the column still holds whatever #492 last wrote, so an existing install keeps showing today's derivative-inclusive figure until the next nightly sync recomputes it as originals-only. The improvement arrives within a day rather than at restart; nothing regresses in the meantime.

Validation on a real library

Deployed as an RC to a live instance (69,146 assets, S3-backed) and checked both toggle states against measurements taken independently of this code — a separate S3 ListObjectsV2 census and a separate stat-based disk walk.

Toggle Displayed Independent measurement Match
off 277,815,008,661 SQL originals sum: 277,815,008,661 exact
on 361,745,267,186 S3 361,260,941,364 + disk 484,325,822 exact
  • Both transitions triggered the resync: off→on recomputed in ~90s over ~271k S3 objects; on→off completed in under 15s.
  • Deploying alone changed nothing, as intended with no migration — the figure only moved once a toggle did.
  • Measured overhead on that library: 83.6 GB of derivatives on 277.7 GB of originals, or 30.1%.

The external-library exclusion could not be exercised there (that instance has no external assets); it is covered by the medium tests instead.

Notes

  • Between nightly runs the incremental deltas track original file sizes only, so with the setting on the figure drifts until the next scan reconciles it. That is how fix(server): count physical storage in quota sync #492 already behaves.
  • Freshness depends on nightlyTasks.syncQuotaUsage; this is called out in the admin panel and the config-file docs.
  • HLS segments, Android motion-photo sidecars and person thumbnails are keyed on ids that are not asset ids, so they are always counted even when they belong to an external-library asset.

Testing

Server 5765 · web 5629 · tsc both packages · svelte-check 607 files 0 errors · eslint both · prettier (i18n + docs) · branding check — all green locally, and CI is green including Medium Tests (Server), SQL Schema Checks, Test Branding and the full E2E matrix.

make fork-ownership-coverage-check fails on main itself: the manifest's last_verified_fork_head is stale and unrelated files (web/vite.config.ts, web/src/test-data/mocks/*) have moved since. This PR touches none of them and adds no uncovered paths — worth a separate fix on main.

Design notes: docs/superpowers/plans/2026-08-12-storage-usage-opt-in-cached.md

@github-actions github-actions Bot added documentation Improvements or additions to documentation 🗄️server 🖥️web labels Aug 12, 2026
@Deeds67 Deeds67 added the changelog:fix Bug fix for changelog label Aug 12, 2026
Deeds67 added 26 commits August 12, 2026 19:51
…ream semantics

Adds user.physicalUsageInBytes (originals + thumbnails + transcodes) and a
migration that preserves the current quotaUsageInBytes value into it before
resetting quotaUsageInBytes to upstream semantics (asset_exif.fileSizeInByte
sum, external libraries excluded). Also updates the test factory for the new
column and keeps revert-to-immich.sql / its coverage test in sync with the
new fork migration and column.
Adds physicalUsageInBytes to UserAdminResponseDto and
storageUsageIncludesDerivatives to ServerConfigDto so clients can pick
which storage-usage number to show, without exposing the separate
quota-enforcement toggle.
Polish used three different storage nouns (magazynu / no noun / przestrzeni
dyskowej) across the panel's four switch strings; normalized to magazyn
throughout, keeping kontyngent for the quota concept itself. Italian's
display-switch description said spazio di archiviazione while its own
title said utilizzo visualizzato; changed the description to echo utilizzo
di archiviazione, matching the title and the panel's other keys.
The @GenerateSql decorator on AssetRepository.getExternalAssetIds emits a
block that was never written to src/queries/asset.repository.sql, so the
sql-schema-up-to-date job would fail on the diff.
Picks up ServerConfigDto.storageUsageIncludesDerivatives,
SystemConfigDto.storageUsage, UserAdminResponseDto.physicalUsageInBytes and
the new SystemConfigStorageUsageDto.
GET /server/config now returns the required storageUsageIncludesDerivatives,
so the toEqual literal has to carry it. With both storage usage toggles off
the exposed value is false.
…forcement

Two independent toggles meant a supported configuration
(includeDerivativesInDisplay=false, includeDerivativesInQuota=true) showed a
user "10 GB of 20 GB" while rejecting their upload as over quota — the exact
misleading number this work set out to remove.

- server: expose storageUsageIncludesDerivatives as the OR of the two
  toggles, so opting quota in implies showing the enforced figure.
- web: make the admin user page's storage meter follow the same server
  config flag instead of always reading quotaUsageInBytes, so an admin
  investigating a rejected upload sees the number enforcement used.
SystemConfigService.updateSystemConfig rejects outright while
IMMICH_CONFIG_FILE is set, so those installs never emit ConfigUpdate and the
only resync trigger never fired. An admin who set includeDerivativesInDisplay
in a config file saw 0 B on a fresh install, or a drifted migration-time
value on an existing one, until the nightly job ran.

Resync on ConfigInit instead, gated on the install actually using a config
file and on at least one toggle being on. Pinned to the microservices worker
like onConfigUpdate, since UserSyncUsage carries no jobId.

Also documents how the figure populates and how it stays fresh, including
that nightlyTasks.syncQuotaUsage is what keeps it current.
The figure is only recalculated when a toggle is first turned on and then by
the nightly Sync quota usage task, so an admin who disabled that task would
see a permanently frozen number with nothing on screen saying why.

Adds the hint in en plus the nine maintained locales.
… comments

- Mark every storage-usage hunk sitting in an otherwise upstream file with a
  Gallery-fork comment, starting with the greatest(0, ...) line inside
  updateUsage — the one a rebase resolver would most easily drop.
- Restore the circular-dependency note on the lazy StorageService import in
  base.service, matching the other three lazy-import sites in that file.
- Name the actual syncUsage call sites instead of "below this method".
- Drop the claim that physicalUsageInBytes is not in the userAdmin
  projection; it has been since database.ts added it.
The two toggles are no longer independent — enabling quota enforcement now
switches the displayed figure too — so the admin panel copy for the quota
switch and the two code comments that described the flag as display-only were
incomplete. Updated in en plus the nine maintained locales.
…p collision

#834 landed 1785000000000-AddFaceRepairLock, which shares a timestamp prefix
with this branch's 1785000000000-AddPhysicalUsageInBytes. Two migrations on one
timestamp is ambiguous and breaks the repo's unique-round-timestamp convention,
so move ours to 1791000000000 — above main's highest (1790000000000).

The migration has never merged, so no database has recorded the old name and no
pre-rename alias entry is needed in the revert script.
The two independent opt-ins (display and quota) could disagree, which meant
holding two numbers at once: a physicalUsageInBytes column, a migration, a
field on AuthUser/UserAdmin and both user DTOs, a ServerConfigDto flag telling
clients which number to render, and a matching read-site change everywhere
storage is shown.

One key, storageUsage.includeDerivatives, removes all of it. quotaUsageInBytes
again holds whatever the admin chose: upstream's originals-only statement when
off, the disk/S3 walk when on. Nothing downstream has to know which produced
it, so the column, the migration, the DTO fields and the enforcement branch in
requireQuota all revert to upstream.

UserRepository keeps upstream syncUsage verbatim and updateUsage exactly as
upstream has it; setUsage returns as the fork setter writing the same column.
BaseService.syncUsage is now either/or - running upstream's statement alongside
the walk would overwrite the walked figure with the originals-only one.

The resync trigger fires on any change of the flag, not just off->on: switching
off leaves a derivative-inclusive figure that also needs recomputing.
Adapts the handleUserSyncUsage block to setUsage and one flag, keeping the S3
prefix walk and the external-library exclusion, and pins the either/or: with
the toggle on, upstream's originals-only statement must not also run.

The storage-usage service spec now covers on->off queuing alongside off->on.
Tests that only asserted removed behaviour - quota enforcement picking a
column, the ServerConfigDto flag, the physicalUsageInBytes column shape - are
deleted rather than left asserting nothing. The medium spec is renamed to
match what it still covers.
…itch

With quotaUsageInBytes always holding the figure the admin asked for, no
client needs to know the setting exists. StorageSpace, rail-storage and the
admin user page go back to reading that column directly, which retires the
shared derivation helper, the serverConfigManager.valueOrUndefined accessor
and the rail-storage tests covering the removed branch. rail-storage keeps its
original "Duplicated from StorageSpace.svelte" comment and parity block.

The settings panel keeps its refresh hint and drops to a single switch.
Replaces the separate display and quota switch strings with a single pair
describing what the one switch now does - it moves the shown figure and the
enforced figure together - and rewords the refresh hint, which said "when you
first turn one of these on" and now has to cover turning it off as well.

en plus the nine maintained locales.
Only SystemConfigStorageUsageDto survives, with one field. ServerConfigDto and
UserAdminResponseDto return to upstream: no client reads a second usage number
or a flag selecting between them.

Regenerated from a built server (sync-open-api, oazapfts, generate-dart-sdk),
not hand-edited.
config-file.md documents storageUsage.includeDerivatives and drops the
two-flag interaction that no longer exists.

The ownership manifest keeps the paths this change still touches and drops the
ones that reverted to upstream, along with the migration glob for the deleted
column migration.

The plan doc described a two-column design the code no longer implements, so
it is rewritten as a design record of what shipped, including why the
two-toggle draft was abandoned.
@Deeds67
Deeds67 force-pushed the fix/storage-usage-opt-in-cached branch from a9ad580 to 3502c1d Compare August 12, 2026 18:35
CI's Test & Lint Server job runs `prettier --check .` first, and a
misformatted storageUsage config-file test was failing it before any
test ran.
…he refresh-hint clause

The derivatives-setting description named upstream Immich by product
name, which the branding pipeline has no override for — substituting
"Noodle Gallery" there would turn "matches upstream Immich" into the
false claim "matches upstream Noodle Gallery". Reworded to reference
the upstream default without naming the product, in en and all nine
translated locales.

The refresh-hint string also overstated what stops when the nightly
sync task is disabled: UserService.onAssetCreate keeps applying live
original-size deltas regardless, so only the reconciliation of
derivative bytes actually stops. Reworded the same nine locales.
…sign

Both comments still described the abandoned two-toggle draft ("when
the toggles are enabled", "opt-ins for counting..."). There is only
one admin toggle now.
src/gallery/** now holds this PR's core storage-usage logic but was
missing from the coverage include list alongside cores/services/utils/
sql-tools, so it never showed up in coverage output.
getExternalAssetIds returns Postgres-lowercased uuids, but
getDerivativeAssetId preserved the filename's own casing, so an
uppercase-cased filename would fail the set-membership check and get
counted as non-external. Unreachable today since filenames are built
from asset.id (already lowercase), but the test was locking in the
mismatch by asserting the uppercase value came back. Normalise the
extraction instead and flip the test to match.
@Deeds67
Deeds67 merged commit db3b4c2 into main Aug 12, 2026
56 checks passed
@Deeds67
Deeds67 deleted the fix/storage-usage-opt-in-cached branch August 12, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog:fix Bug fix for changelog documentation Improvements or additions to documentation 🗄️server 🖥️web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant