feat(jobs): run the thumbnail migration at startup, by default

A migration nobody triggers never finishes. Scheduled ticks deliberately
never pass `repair`, so a deployment whose operator never opens the
admin panel re-imported the same sidecars forever and never drained the
directory — and relying on operators to edit `.env` has the same failure
mode one level up.

`OXICLOUD_STARTUP_JOBS` dispatches named jobs once, in the background,
after the scheduler is ready. Entries use the syntax operators already
type at the trigger URL (`name?repair=true`), so the value is literally
the request they would otherwise make by hand. It defaults to both
migration jobs in repair mode, so an untouched deployment migrates and
drains itself.

That is a destructive default and a real exception to
no-silent-auto-repair, so the guard it rests on had to get stronger:
`verify_and_unlink` now compares CONTENT, not length. A blob of the
right size and the wrong bytes used to pass — a key-mapping bug handing
back another file's preview at the same length would have deleted the
original and kept the impostor, and thumbnails cluster tightly enough in
size for that to be a real coincidence. The readback streams from the
backend with no cache in front, so it proves durability rather than that
a write was acknowledged.

Deletion of `.thumbnails/` is attempted first and only falls back to
renaming it `.thumbnails.migrated` when `remove_dir` refuses because a
non-sidecar file is inside (Finder's `.DS_Store`). Either way the
directory stops existing, which lets the read-path probe go back to a
single `stat` on the root instead of walking the size directories.

Validation is fail-fast: an unknown job name or flag panics at boot. A
silently dropped `?repare=true` would leave the job in discovery-only
mode while the operator believed the tier was draining, surfacing months
later as "the migration never finished" with nothing pointing at the
config line.

Interrupted runs resume. Boot recovery flips abandoned rows to Paused
with their cursor, so `run_or_resume` continues rather than rescanning —
a long migration completes across however many restarts it takes. That
is a scoped exception to "we do not auto-resume": here somebody did ask,
in configuration, and not having to ask again is the point.

`StartupJob` holds a `JobRunArgs` rather than re-listing its four
fields, so a fifth flag cannot be added to the scheduler and silently
ignored in configuration.

Jobs named here are ordinary registered jobs — visible in the panel,
triggerable by hand, same runs and findings. Their rows now carry a
`startup` object so an operator can see that a job deletes on every boot
rather than only when someone clicks Run.

Adds docs/config/thumbnail-migration.md: what runs on first boot, how to
snapshot database and storage together beforehand, and how to verify
afterwards with satellites_consistency plus backend_consistency
?deep=true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edouard Vanbelle
2026-08-29 10:23:02 +02:00
parent 03246305f6
commit 577ecb7cef
15 changed files with 829 additions and 32 deletions
+1
View File
@@ -18,6 +18,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator
| `OXICLOUD_CHUNK_DIR` | `{STORAGE_PATH}/.uploads` | Root directory for chunked-upload sessions (REST + NextCloud). Direct (non-chunked) uploads stream straight into the blob store and need no spool directory. Placement guidance: see [Storage Fine Tuning](./storage-fine-tuning.md). |
| `OXICLOUD_REUSE_PORT` | `false` | Enable `SO_REUSEPORT` so multiple processes can share the same port. **Disabled by default** — a second accidental instance will fail with "address already in use". Enable only for deliberate multi-worker setups (process supervisor, rolling restart). Not supported on Windows. |
| `OXICLOUD_METRICS_LISTEN` | (unset) | Prometheus `/metrics` listener address (e.g. `127.0.0.1:9090`, IPv6 allowed as `[::1]:9090`). **Unset = disabled**: no `/metrics` endpoint is bound and no metrics recorder is installed (zero runtime cost). When set, a separate HTTP listener on this address serves the text-format scrape. **Deliberately NOT merged into the main API** — no auth, CSRF, or DPoP layer in front. Bind to loopback or a private interface unless you intend to expose metrics publicly. Starter counters: `oxicloud_dpop_verify_failed_total{reason}`, `oxicloud_dpop_proof_missing_total`, `oxicloud_dpop_header_missing_on_bound_session_total`, `oxicloud_dpop_replay_detected_total`, `oxicloud_dpop_nonce_challenges_issued_total`. |
| `OXICLOUD_STARTUP_JOBS` | `thumb_derived_import?repair=true,thumb_attached_import?repair=true` | Background jobs dispatched once at boot, comma-separated, each `name` or `name?flag=true` using the same syntax as `POST /api/admin/jobs/{name}/trigger`. Flags: `force`, `deep`, `repair`, `storage`. **The default migrates thumbnails out of the legacy `.thumbnails/` directory and deletes the originals**, so the migration completes without anyone triggering it from the admin panel; each sidecar is read back through the normal stack before it is unlinked, and every deletion is audited. An explicit value **replaces** the default; set it empty (`OXICLOUD_STARTUP_JOBS=`) to disable startup jobs, or to `thumb_derived_import,thumb_attached_import` to import without deleting. **Non-blocking** — readiness never waits on a job; entries run sequentially in the background. **Fail-fast** — an unknown job name or flag panics at boot, because a silently-dropped entry means a migration that never runs. A run interrupted by a restart resumes from its cursor on the next boot, so a long migration finishes across restarts. Safe to leave at the default: the jobs are idempotent, and once drained a run does nothing. See [Thumbnail Migration](./thumbnail-migration.md) for the upgrade runbook. |
## Database
+1
View File
@@ -7,6 +7,7 @@ OxiCloud is configured entirely via **environment variables** (no config files n
- [Deployment & Docker](/config/deployment) — Docker Compose, Kubernetes Helm chart, image details
- [Environment Variables](/config/env) — complete reference of all `OXICLOUD_*` variables
- [Storage Fine Tuning](/config/storage-fine-tuning) — sizing the upload caps + spool directories; tmpfs vs real disk; NVMe split layouts
- [Thumbnail Migration](/config/thumbnail-migration) — upgrading past `.thumbnails/`: what runs on first boot, taking a snapshot first, verifying afterwards
- [Authentication](/config/authentication) — JWT auth, login, refresh, password changes, and auth status
- [OIDC / SSO](/config/oidc) — single sign-on with Keycloak, Authentik, Authelia, Google, Azure AD
- [WOPI (Office Editing)](/config/wopi) — Collabora Online / OnlyOffice integration
+153
View File
@@ -0,0 +1,153 @@
# Thumbnail migration runbook
Thumbnails used to live as files under `{STORAGE_PATH}/.thumbnails/`.
They now live in the content-addressed blob store, alongside file
content. This page is for operators upgrading across that change.
**You do not have to do anything.** The migration runs itself, in the
background, on the first boot after the upgrade. The rest of this page
is for operators who want to verify it, take a safety net first, or
understand what it did.
## What runs, and when
Two background jobs, dispatched once at startup and daily thereafter:
| Job | Migrates | Regenerable if lost? |
|---|---|---|
| `thumb_derived_import` | Thumbnails the server rendered from file content | Yes — the next request re-renders |
| `thumb_attached_import` | Previews a client uploaded (`ext-{file_id}.jpg`) | **No** — there is no render path for these |
Both import each sidecar into blob storage, read it back to confirm the
copy is byte-identical, and only then delete the original. When the
directory is empty it is removed, and `.thumbnails/` stops existing.
Startup dispatch is non-blocking — the server is ready immediately and
the migration proceeds behind it. A run interrupted by a restart resumes
from where it stopped, so a large installation finishes over several
restarts rather than starting again each time.
This is controlled by `OXICLOUD_STARTUP_JOBS`, which defaults to:
```
OXICLOUD_STARTUP_JOBS=thumb_derived_import?repair=true,thumb_attached_import?repair=true
```
To **import without deleting** — migrate now, inspect, delete later:
```
OXICLOUD_STARTUP_JOBS=thumb_derived_import,thumb_attached_import
```
The sidecars then stay on disk. Trigger the deletion when you are ready
from **Admin → Jobs**, using each job's Repair action.
To disable startup jobs entirely, set the variable to an empty value.
## Taking a safety net first
Recommended for any installation where the uploaded previews matter, and
cheap enough to be worth it regardless. Both parts must be captured
together — a database that references blobs a storage snapshot predates
is worse than neither.
**1. Stop the server.** A snapshot taken while writes are in flight can
catch a blob that exists on disk without its database row, or the
reverse.
```bash
systemctl stop oxicloud # or: docker compose stop oxicloud
```
**2. Snapshot the database.**
```bash
pg_dump --format=custom --file=oxicloud-preflight.dump "$DATABASE_URL"
```
Use `--format=custom`; restoring it needs `pg_restore --disable-triggers`,
because the folder table carries a self-referencing foreign key that a
plain SQL restore cannot order correctly.
**3. Snapshot the storage directory.** At minimum `.thumbnails/`, which
is what the migration touches:
```bash
tar -czf oxicloud-thumbnails-preflight.tar.gz -C "$STORAGE_PATH" .thumbnails
```
A whole-directory snapshot is better if you have the space — filesystem
or volume snapshots (ZFS, LVM, EBS) are ideal, since they are atomic and
near-instant:
```bash
zfs snapshot tank/oxicloud@preflight
```
**4. Start the server.** The migration begins in the background.
Keep both snapshots until you have run the verification below and are
satisfied.
## Verifying the migration
Two checks, both from **Admin → Jobs** or the API. Run them after the
migration reports no remaining work.
**1. Every mapping points at a blob that exists.** Run
`satellites_consistency`. It walks both thumbnail tables and reports any
row whose blob or source is gone. A clean run means nothing was lost in
the bookkeeping.
```
POST /api/admin/jobs/satellites_consistency/trigger
```
**2. Every blob still hashes to what it claims.** Run
`backend_consistency` with `?deep=true`. It reads every blob back from
storage and re-hashes it, which covers the migrated thumbnails along
with everything else. This is a full read of your storage and can take
hours on a large installation — schedule it accordingly.
```
POST /api/admin/jobs/backend_consistency/trigger?deep=true
```
A clean pass on both means the thumbnails are readable, correctly
referenced, and byte-intact in their new home. At that point the
snapshots can be discarded.
## Checking it finished
`.thumbnails/` is gone. That is the whole test:
```bash
ls -d "$STORAGE_PATH/.thumbnails" # No such file or directory
```
If you instead find `.thumbnails.migrated/`, the migration completed but
could not remove the directory, because something that is not a
thumbnail was inside it — a `.DS_Store` from macOS Finder is the usual
culprit. The tree was moved aside instead of deleted. Its contents are
no longer used and it is safe to remove by hand once you have looked at
what is in there.
While either directory is absent, the server skips the legacy read path
entirely, at no cost. While `.thumbnails/` is present, reads fall back
to it on a miss, which is what makes the migration invisible to users
while it runs.
## If something looks wrong
Every deletion is written to the audit log, naming the job, the file
removed and the blob that replaced it. To review what a migration
removed:
```bash
journalctl -u oxicloud | grep sidecar_deleted
```
A sidecar is only ever deleted after its replacement has been read back
and compared byte-for-byte, so a file that failed that check is still on
disk. Those show up as findings on the job's run in **Admin → Jobs**,
with the reason recorded per file.
+92
View File
@@ -737,6 +737,98 @@ trigger) resumes any `Paused` row per the normal flow.
Consistency-check.md's existing consistency-scoped sweep collapses
into this general one.
### Startup jobs — `OXICLOUD_STARTUP_JOBS`
A comma-separated list of jobs to dispatch once, in the background,
after the scheduler is ready. Each entry is a registered job name,
optionally with the same query syntax the admin trigger URL uses.
**The default is both migration jobs, in repair mode:**
```
OXICLOUD_STARTUP_JOBS=thumb_derived_import?repair=true,thumb_attached_import?repair=true
```
An explicit value replaces that list; an empty value disables startup
jobs entirely.
**Why it exists.** Scheduled ticks deliberately never pass `repair` — a
job that deletes on its default setting is what no-silent-auto-repair
forbids. But that left the migration jobs unable to finish on their
own: a deployment whose operator never opens the admin panel re-imports
sidecars it already imported, forever, and never drains the directory.
**Why the default deletes anyway.** Relying on operators to edit `.env`
has the same failure mode one level up — the ones who never edit it are
exactly the ones whose migration never completes. So this is a
deliberate exception to no-silent-auto-repair, and it rests on three
properties that must keep holding:
- **Nothing is deleted before its replacement has been read back.**
`verify_and_unlink` imports, reads the blob back through the normal
stack, and only then unlinks; a store that reported success but landed
unreadable keeps its sidecar. This matters most for
`thumb_attached_import`, whose bytes are user-uploaded previews with
no render path — a wrong deletion there is permanent, where a wrong
deletion of a server-rendered thumbnail costs a re-render.
- **Sidecars whose source is gone are deleted without a readback**,
because there is nothing to read back and nothing can reference them
again. Unrecoverable and unreachable are different things; these are
both.
- **Every deletion is audited**, so what a boot removed, and from which
source, is reconstructable afterwards.
The consequence to hold in mind: an upgrade deletes on first boot, in
every deployment at once, with no operator action. A regression in the
readback path would be simultaneous and unrecoverable, so that code is
load-bearing. Operators who want to inspect before committing set
`OXICLOUD_STARTUP_JOBS=thumb_derived_import,thumb_attached_import` —
same jobs, import only.
It is not a "run everything in repair mode" switch. Each job is named
individually and carries its own flags.
**Validation is fail-fast.** An unknown job name panics at boot — the
registry is fully populated by then, so a name that doesn't resolve is a
typo or a stale rename, and ignoring it would leave a migration that
silently never runs. Unknown flags panic too: a dropped `?repare=true`
would leave the job in discovery-only mode while the operator believed
the tier was draining, and the symptom ("it never finished") surfaces
months later with nothing pointing back at the config.
**Dispatch is non-blocking.** `tokio::spawn`, so readiness never waits
on a job that may walk a filesystem for hours. Jobs in the list run
sequentially within that task, not concurrently: they contend for the
same directories and pool, and the exclusivity gate would turn overlap
into a *skipped* run rather than a queued one.
**Interrupted runs resume.** The boot recovery sweep above runs first
and flips every abandoned `Running` row to `Paused` with its cursor
intact; `run_or_resume` then picks Resume over a fresh start. So a
migration killed by a restart continues where it stopped, and completes
across however many restarts it takes.
That is a deliberate exception to "do NOT auto-resume" — scoped to the
named jobs only. The rule protects against a restart silently resuming
work nobody asked for; here somebody did ask, in configuration, and not
having to ask again is the entire point. Every other paused run still
waits for an operator.
A resumed run keeps the flags it started with (`repair` / `deep` are
persisted to `params` on the fresh open and read back on resume), so
editing the config mid-migration does not retroactively change a run
already in flight.
**Safe to leave set.** Each job is idempotent and resumable; once the
tier has drained, a run is a `read_dir` over three directories that
returns nothing — and after the directory is removed, not even that.
**Visible in the admin panel.** These are ordinary registered jobs:
they appear in `GET /api/admin/jobs`, are triggerable by hand, and
record the same runs and findings. Rows named here additionally carry a
`startup` object with the configured flags, so an operator can see that
a job deletes files on every boot rather than only when someone clicks.
### Admin surface (recoverable runs)
Same URL taxonomy as Part 1 — resource-first, action second, all