Skip to content

feat: pre-warm preview cache for indexed photos#3494

Open
karlitschek wants to merge 1 commit into
frank/feat/photos-metadata-editorfrom
frank/feat/photos-preview-warmup
Open

feat: pre-warm preview cache for indexed photos#3494
karlitschek wants to merge 1 commit into
frank/feat/photos-metadata-editorfrom
frank/feat/photos-preview-warmup

Conversation

@karlitschek
Copy link
Copy Markdown
Member

The first scroll over a fresh library is the slowest user-facing moment in photos: every visible tile fires /api/v1/preview/{fileId} cold, NC re-encodes the thumbnail on demand at 64×64 and 1024×1024, and a few dozen parallel ~200-500ms preview generations stutter the load. After that the NC preview cache kicks in and everything is fast.

Drains that cost into a background job so the user never sees it.

Schema

  • New oc_photos_preview_warmup table — file_id PK, state (pending → warming → warmed / failed), attempts, updated_at. PK is per-file (not per-user) because NC's preview cache is global; warming once covers every user that mounts the file.

Service

  • PhotoPreviewWarmupMapper mirrors PhotoTranscodeMapper but hands back batches of fileIds rather than one at a time — warming is sub-second per file so per-file claim round trips would dominate the wall budget.
  • PhotoPreviewWarmupService::warmFile calls IPreview::getPreview at exactly the two sizes FileComponent asks for (64 and 1024). Idempotent: re-warming an already-warm file is a stat-and-return.
  • PREVIEW_SIZES is the contract — if photos changes the layer sizes in FileComponent, this constant has to follow or the warmup misses.
  • Disabled via enable_preview_warmup AppValue (default true — the perf win is the whole point). Admins on tight quotas (warming a 100k library adds ~3-5 GB to NC's appdata preview cache) leave it off.

Worker

  • PhotoPreviewWarmupJob (TimedJob, 5-min interval, 5-min wall budget). Per tick: reap stale warming rows from a dead worker → backfill new pending rows from oc_photos_index (one-time bootstrap; the listener takes over for new uploads) → drain the queue in batches of 50.

Listener

  • PhotoIndexNodeListener.onWritten now also marks the file pending warmup (idempotent — markPending is a no-op for already-pending / warmed files).
  • onDeleted drops the warmup row alongside the index + transcode cleanup.

Bumped app version to 7.0.0-dev.2 so occ upgrade re-runs the migration loader and picks the new table + job up.

The first scroll over a fresh library is the slowest user-facing
moment in photos: every visible tile fires `/api/v1/preview/{fileId}`
cold, NC re-encodes the thumbnail on demand at 64×64 and 1024×1024,
and a few dozen parallel ~200-500ms preview generations stutter the
load. After that the NC preview cache kicks in and everything is
fast.

Drains that cost into a background job so the user never sees it.

Schema
- New `oc_photos_preview_warmup` table — file_id PK, state
  (pending → warming → warmed / failed), attempts, updated_at.
  PK is per-file (not per-user) because NC's preview cache is
  global; warming once covers every user that mounts the file.

Service
- `PhotoPreviewWarmupMapper` mirrors `PhotoTranscodeMapper` but
  hands back batches of fileIds rather than one at a time —
  warming is sub-second per file so per-file claim round trips
  would dominate the wall budget.
- `PhotoPreviewWarmupService::warmFile` calls
  `IPreview::getPreview` at exactly the two sizes FileComponent
  asks for (64 and 1024). Idempotent: re-warming an already-warm
  file is a stat-and-return.
- `PREVIEW_SIZES` is the contract — if photos changes the layer
  sizes in FileComponent, this constant has to follow or the
  warmup misses.
- Disabled via `enable_preview_warmup` AppValue (default `true` —
  the perf win is the whole point). Admins on tight quotas
  (warming a 100k library adds ~3-5 GB to NC's appdata preview
  cache) leave it off.

Worker
- `PhotoPreviewWarmupJob` (TimedJob, 5-min interval, 5-min wall
  budget). Per tick: reap stale `warming` rows from a dead worker
  → backfill new pending rows from `oc_photos_index` (one-time
  bootstrap; the listener takes over for new uploads) → drain
  the queue in batches of 50.

Listener
- `PhotoIndexNodeListener.onWritten` now also marks the file
  pending warmup (idempotent — `markPending` is a no-op for
  already-pending / warmed files).
- `onDeleted` drops the warmup row alongside the index + transcode
  cleanup.

Bumped app version to 7.0.0-dev.2 so `occ upgrade` re-runs the
migration loader and picks the new table + job up.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@karlitschek
Copy link
Copy Markdown
Member Author

@codecov
Copy link
Copy Markdown

codecov Bot commented May 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant