Files
felhom.eu/documentation/audits/DIAG-immich-restore-2026-07-19.md
T
admin 238472b8ed docs: R-43 + R-44 shipped in controller v0.148.0 (live acceptance still pending)
ROADMAP R-43/R-44 → SHIPPED with what actually landed:
  - R-43: ReconstituteFromOffsite — safety dump → stop → files overwritten to
    the snapshot version → start → the snapshot's own dump replayed from the
    SCRATCH unit (the live unit is still never overwritten). Invariants:
    nothing is ever deleted, and the undo is verified on disk before the act.
  - R-44: every run, manual AND nightly, refreshes dumps + units BEFORE the
    capture, so each snapshot is a coherent {DB@T, files@T} pair stamped with
    offsite_run_id + dumps_at. Nightly ordering is now structural rather than a
    coincidence of two scheduler entries at 02:30 and 04:15.

DIAG doc gains a "fixed in v0.148.0" header pointing at both.

Capability map deliberately NOT flipped: the DB half now exists but is PARTIAL,
not PROVEN-LIVE — shipped and deployed with no live acceptance behind it. The
customer-restore row stays MISSING, now blocked only on the evidence run rather
than on missing capability, and R-3 stays DRAFT.

Still open: the 00-capability-map.md:61 ruling — did CAMPAIGN-6D's "immich
end-to-end from offsite alone" exercise the DB half, or only the file half?

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Nn14TWGzKoqAJAiVwC2s
2026-07-19 12:27:11 +02:00

209 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DIAGNOSE — immich offsite restore "succeeds", photos do not reappear (2026-07-19)
> **FIXED IN controller v0.148.0 (2026-07-19)** — findings 1 and 2 below are closed in code as
> **R-43** (offsite reconstitution: safety dump → stop → overwrite files → start → replay the
> snapshot's dump) and **R-44** (every run dumps before it captures; manifests carry
> `offsite_run_id` + `dumps_at`). Findings 4 and 5 are described-and-accepted rather than changed;
> 3 stays with the v0.147 feedback arc and 6 is still unreconciled. **The live acceptance has not
> run** — the capability rows below are unchanged until it does. See `felhom-controller/REPORT.md`.
>
> **Class:** diagnosis. No product code was changed *by this document*. Findings + evidence only.
> **Box:** demo-felhom (felhom-pve), guest 9201, controller 0.146.0, immich v3.0.3.
> **Trigger:** Viktor deliberately deleted photos in the immich UI to **test the offsite restore
> feature**. The photos were never wanted back — the exercise was the point.
---
## TL;DR
The restore did not fail. **It was never invoked on missing data, and even if it had been, it could
not have brought the photos back.**
1. Deleting in the immich UI moves assets to **Lomtár (trash)**. No file leaves the disk. Both
„csak a hiányzó fájlok" runs therefore merged **0 files** — correctly, there was nothing missing.
The success flash was truthful about mechanism and empty as a test result.
2. A *valid* test (trash emptied, files genuinely gone) would also have failed to restore the
photos: **no offsite button loads a database.** The files would return; the library would still
be empty, because immich's truth lives in postgres and nothing replays the dump.
3. The dump that ships is additionally **stale by design** — it comes from the 02:30 local run and
is never refreshed before a manual push. Today's dump contains **0 users, 0 assets**.
**One-line answer to "can a customer trust same-day offsite?"** — For a DB-indexed app: **no.
Files come back; content does not.** The backup half is honest; the restore half cannot
reconstitute the app.
---
## Timeline (guest/DB clock; host is CEST, guest is UTC — CEST shown)
| CEST | Event | Evidence |
|---|---|---|
| 07-18 17:10 | old pre-v3 user tree `dccc13fe…` last written | dir mtime |
| 07-19 02:30 | local `db-dump` run writes `immich-postgres.sql` | mtime `00:30:00 UTC` |
| 07-19 07:56:25 | **admin user `4dcfb81e…` created** (fresh v3 deploy) | `user.createdAt` |
| 07-19 07:57:0609 | Viktor uploads 11 assets | `asset.createdAt` |
| 07-19 07:58:15 | **manual offsite backup** — captures the 11 files | `[offbox] backed up immich (…1 mandatory path(s))` |
| 07-19 08:00:08 / 08:00:15 | Viktor deletes assets in UI → **trash**, 2 batches | `asset.deletedAt` |
| 07-19 08:01:14 | full restore → **scratch folder**, not live | `[offbox] restored immich (360304f8, full=true) → …/backups/offsite-restore/immich` |
| 07-19 08:27:54 | place (missing-only) → **0 files merged** | `[offbox] placed immich from offsite scratch: 0 file(s) merged (missing-only)` |
| 07-19 08:29:09 | full restore → scratch again | same as above |
| 07-19 08:29:57 | place (missing-only) → **0 files merged** | same as above |
The backup at 07:58:15 ran **after** the upload and **before** the delete — so the snapshot's
*file* half is correct and current. Only the DB half is not.
---
## Evidence
### DB state (live, at 08:34 CEST)
```
total | trashed | live
11 | 11 | 0 -- all 11 assets trashed, none live
users: 1 -- 4dcfb81e-073a-49fb-938e-7fffb51dbac4 / admin@felhom.eu
```
**`updatedAt` == `deletedAt` exactly on all 11 rows** — nothing has modified these rows since the
08:00 deletion. Conclusive: **none of today's restore operations touched the database.**
All 11 `originalPath` values resolve to files that exist on disk (11 OK / 0 missing, after mapping
the container path `/usr/src/app/upload``/mnt/felhom-drives/hdd_1/appdata/immich`). The photos
are fully recoverable by an ordinary in-app un-trash; **left in trash at Viktor's instruction.**
### The decisive dump probe
The recovery unit that actually ships:
`/mnt/felhom-drives/hdd_1/backups/primary/immich/db-dumps/immich-postgres.sql`
— 51 954 452 B, mtime **02:30 CEST**.
```
asset: 0 rows
user: 0 rows
album: 0 rows
```
The 52 MB is entirely `geodata_places` / `naturalearth_countries` — immich's shipped reference
data. **A dump that looks substantial by size contains zero customer content.** It was taken at
02:30, four hours before the admin user existed (07:56:25) and five and a half hours before the
photos were uploaded (07:57).
The same is true of the unit's volume dumps: `immich_immich_postgres_data.tar` (323 088 384 B) is
also from 02:30 CEST.
> **Method note / correction.** The first probe in this session read
> `appdata/immich/backups/immich-db-backup-20260719T020000-…sql.gz` — that is *immich's own*
> self-backup, not the controller's. It is independently also empty (`asset: 0`, `user: 0`), but
> the figure that matters for restore is the controller unit dump above. Both were probed; both
> are empty; the conclusion is unchanged.
### Safety consequence
Had a full restore actually loaded that dump into postgres, it would have written an **empty
database over the live one — destroying the trashed rows that are the only remaining recovery
path.** It did not, because (a) full restore only stages to a scratch folder and never touches a
DB, and (b) the place path's `live recovery unit present — not overwriting` guard skipped the unit
entirely. The photos survived by the combination of two behaviours, neither of which is a
deliberate safety feature for this case.
---
## Mechanism (source, controller 0.146.0)
| Question | Answer | Cite |
|---|---|---|
| Does a manual offsite run refresh DB dumps before pushing? | **No.** `offboxRunHandler``RunOffboxBackup` directly; never calls `RunDBDumps` / `captureAllRecoveryUnits`. It ships whatever `.sql` already exists. | `web/offbox_handlers.go:203-227`, `backup/offbox.go:574-759` |
| Where do dumps come from, then? | The separate local `db-dump` daily at **02:30**; offsite runs at **04:15**. A *manual* offsite run at any hour ships dumps up to ~24 h old. | `cmd/controller/main.go:542,611`, `config/config.go:317` |
| What does the recovery unit contain? | compose/, app.yaml, manifest.json — and it **enumerates** existing dump filenames, never creates them (`listFileNames`). | `backup/recovery_unit.go:68-154` (105-106) |
| „csak a hiányzó fájlok" — what does it do? | `rsync -a --ignore-existing`. Files only. **Never touches a DB. Never stops/starts the stack.** | `backup/offbox_restore.go:346-421`, `tier2_restore.go:148-162` |
| „Teljes visszaállítás" — restores to where? | A **scratch/verify folder** (`backups/offsite-restore/<app>`), never live data, and **never loads a dump into postgres**. | `backup/offbox_restore.go:171-232`, `:130-164` |
| Does *any* offsite path replay a DB dump? | **No.** The only code that replays into a live DB is `RestoreFromRecoveryUnit`, reachable solely from the **local** restore panel. | `backup/restore_unit.go:74``web/handlers.go:1108` |
| Is there any dump-freshness / RPO check? | **None anywhere.** Zero `RPO` hits. `offboxUnitTime` is only a two-drive tiebreak. The `DBValidationCache` exists but no offsite/restore path reads it. | `backup/offbox.go:827-837`, `backup.go:364-370` |
### The place-success message
```
"A(z) " + app + " hiányzó fájljai helyreállítva az élő adatok közé." // offbox_handlers.go:354
```
Fixed string, built from the app name only. `PlaceOffsiteRestore` returns `error` alone, so the
merged-file count is **structurally unavailable** to the flash — it exists only in the English log
(`offbox_restore.go:419`). **With 0 files merged the customer still reads an unconditional claim
that files were restored.**
Tier-2 already solves this: `"Nincs hiányzó fájl — minden fájl megvan a helyén."`
(`web/handlers.go:1154`). The offsite path has no such branch — a straight parity gap.
---
## Findings
### 1. [HIGH → R-43] No offsite restore path can restore a database
All three offsite buttons are file-only. The DB dump is faithfully carried *into* the snapshot and
can never be replayed *out of* it by any offsite action. For every DB-indexed app — which is most
of the catalog — offsite restore cannot reconstitute the application. The customer is left with
correct bytes on disk and an app that cannot see them.
This outranks the staleness finding: even a perfectly fresh dump would not have restored the
photos today.
### 2. [HIGH → R-44] Manual offsite push ships an unrefreshed dump
"Backed up now" is false for the DB half. Today: files from 07:57, dump from 02:30 — containing
neither the user nor the assets. A same-day restore silently loses same-day state. No UI surface
discloses the DB's actual age.
### 3. [MED] Success semantics are mechanism-shaped, not outcome-shaped
Every flash today told the truth about what the code did while the customer watched nothing happen.
The zero-file case is the sharpest form: an unconditional "helyreállítva" when nothing was
restored. Feeds the v0.147.0 feedback arc — restore completions should state the **outcome**
(„N fájl visszaállítva; az adatbázis NEM — az alkalmazás tartalma nem változott").
### 4. [MED, new] The "live recovery unit present" guard silently skips the dump
`offbox_restore.go:400-405` skips the **entire** unit — compose, manifest **and db-dumps**
whenever a local `backups/primary/<app>` exists, which is true on any box whose local backup has
run. So on a normal box the offsite dump is never even staged to disk. Correct as
"don't clobber the local unit"; invisible as "your DB was not part of this restore".
### 5. [LOW, new] Orphaned pre-v3 user tree
`upload|thumbs|encoded-video/dccc13fe-d629-4980-98e5-f2ea88cdb790` — 10 files / 36 MB + 3.9 MB +
15 MB, mtime 07-18 17:10, stranded by the immich 2→3 redeploy. The live DB has never known about
it. Dead weight, not today's issue; worth a sweep policy for major redeploys.
### 6. [LOW, unresolved] Storage figure discrepancy
The brief cites **704.6 MiB** used on the library storage. Measured: **126 MB** total
(`upload` 72 M, `encoded-video` 30 M, `backups` 18 M, `thumbs` 8 M). Not reconciled in this
session. If 704.6 MiB came off a controller Storage page, that gap is its own defect and needs a
separate look.
### 7. [Method] A UI delete cannot test restore
Deleting in an app's own UI usually means *trash*, not deletion. Any "restore drill" scripted that
way will merge 0 files, flash success, and prove nothing. A real drill must empty the trash (or
otherwise remove the files) **and** verify the app's content afterwards, not just the file count.
This belongs in R-3's "first restore by the customer" step.
---
## Capability map
The **customer-restore row STAYS MISSING** and gains this diagnosis as a note. The C6 evidence
attempt found a product gap — that is the rehearsal system working, not failing. **R-3 stays
DRAFT.**
---
## What was NOT done
- No product fixes — no changes to restore/backup code, labels, or flashes.
- No `restic prune`/`forget`, no snapshot deletion, no escrow or check-folder changes.
- The 11 assets were **left in trash** (Viktor: recovery not wanted). immich's default 30-day
auto-purge applies; they are recoverable by un-trash until then.
- The genuine destructive test (empty trash → attempt real restore) was **offered and not run**
it needs explicit go-ahead, and the dump probe already settles the question without it.