97249099d6
VERDICT. H1 confirmed: the reconstitution never ran. /backup/offbox/reconstitute
was never hit (reconstituted/safety-dump/replay line counts all 0); the operator
clicked the old missing-only button, which merged 34 files and left the DB
untouched. H2 rejected (the full path never executed; staging used the correct
snapshot 49e7cb46). H3 rejected (no replay happened; DB was genuinely asset:0).
R-44 is exonerated and working: 49e7cb46 carries offsite_run_id 20260719T102851Z
and its dump probes to asset:11 / user:1 — a properly coherent pair, against
round 1's asset:0 / user:0.
H4 (NEW, the more important result). Phase-3 recovery ran the real sequence and
the v0.148.0 path executed correctly — safety dump, stop, start, replay — then
the replay ABORTED: relation "clip_index" already exists. Proven to the second:
10:58:25 controller begins replay
10:58:33 immich-server "Reindexing clip_index" -> "Reindexed clip_index"
10:58:35 dump's CREATE INDEX clip_index fails, exit 3
The replay races the app's own schema repair, because reconstitution starts the
stack before replaying (ImportDump needs a live container). The photos survived
only because pg_dump emits COPY data before CREATE INDEX — luck, not design. The
same start-then-replay shape is on the LOCAL path, so it is a class defect.
Recovery: PARTIAL and STOPPED as instructed. All 11 assets are back and visible
by every DB criterion (status=active, deletedAt null, all files resolve), but the
operation reported failure and immich reports schema drift. Not clean acceptance
evidence; no second attempt made; safety dump read only.
Findings -> R-47 (the replay race), R-48 (restore controls separable only by
layout — the proven cause of this incident), R-49 (1.1 GB of a 1.2 GB immich
backup is ML model cache + a postgres data-dir tar duplicating the .sql dump +
immich's own nightly dump; recorded, not changed).
RULINGS LANDED.
- 00-capability-map.md:61 -> PARTIAL, scope-corrected: 6D's destruction hit the
FILE TREE only; the DB survived in its named volume, so "end-to-end from
offsite alone" overclaimed scope.
- The 704.6 MiB "discrepancy" -> CLOSED, not a defect: it was immich's own
Tarhely widget, never a controller page. Same for round 2's 650MiB->1.4GiB.
- customer-restore row: partial evidence recorded, NOT flipped.
Diagnosis only — no code, label or layout changes; no forget/prune.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Nn14TWGzKoqAJAiVwC2s
232 lines
13 KiB
Markdown
232 lines
13 KiB
Markdown
# DIAGNOSE — v0.148.0 full-restore acceptance: files back, timeline empty (round 2, 2026-07-19)
|
|
|
|
> **Class:** diagnosis. No product code changed. **Access mode: SSH** (the box was reachable from
|
|
> the workstation; every step below is direct evidence, none inferred).
|
|
> **Companion:** `DIAG-immich-restore-2026-07-19.md` (round 1, which produced R-43 + R-44).
|
|
|
|
---
|
|
|
|
## TL;DR
|
|
|
|
**H1 confirmed: the reconstitution never ran.** The operator clicked the old missing-only button;
|
|
`/backup/offbox/reconstitute` was **never hit** (`reconstituted lines: 0`, `safety dump lines: 0`,
|
|
`replay lines: 0`). Files came back (34 merged — hence the disk growth), the database did not.
|
|
|
|
**Then Phase-3 recovery found a NEW defect (H4), which is the more important result.** Running the
|
|
real sequence deliberately, the v0.148.0 path executed correctly — safety dump → stop → start →
|
|
replay — and the **replay aborted**: `ERROR: relation "clip_index" already exists`.
|
|
|
|
Root cause, proven to the second: **the replay races the application's own schema repair.** The
|
|
reconstitution starts the stack *before* replaying (ImportDump needs a live container), which gives
|
|
immich-server a window to recreate schema objects the dump is about to create.
|
|
|
|
```
|
|
10:58:25 controller: replaying DB dump into immich-postgres
|
|
10:58:33 immich-server: "Reindexing clip_index" → "Reindexed clip_index" ← app recreates it
|
|
10:58:35 controller: ERROR relation "clip_index" already exists — exit status 3
|
|
```
|
|
|
|
**The photos ARE back** (11 assets, `status=active`, `deletedAt` null, all 11 files resolve, owned
|
|
by the live user) — because `pg_dump` emits COPY data *before* CREATE INDEX, so the abort landed
|
|
after the data. **That success is accidental.** A collision earlier in the script would abort before
|
|
the data and leave a genuinely half-restored database, reported identically.
|
|
|
|
**This is NOT clean §9 acceptance evidence.** The run reported failure, and immich now reports
|
|
schema drift (indexes the aborted script never created). Recovery is PARTIAL.
|
|
|
|
---
|
|
|
|
## Phase 1 — facts
|
|
|
|
### 1. Endpoint timeline (controller log, guest UTC)
|
|
|
|
| UTC | Event |
|
|
|---|---|
|
|
| 10:23:13 | controller 0.148.0 starts |
|
|
| 10:28:51 | manual offsite run starts — **v0.148 dump pre-phase runs** |
|
|
| 10:29:56 | `pre-push dump leg completed in 1m5.04s — snapshot pair is coherent` |
|
|
| 10:32:52 | `backed up immich (1 mandatory path(s))` |
|
|
| 10:33:41 | `backup OK: 3 app(s), 6 snapshot(s), 4m45s` |
|
|
| 10:38:32 | `restored immich (49e7cb46, full=true) → …/backups/offsite-restore/immich` (staging) |
|
|
| **10:39:55** | **`placed immich from offsite scratch: 34 file(s) merged (missing-only)`** |
|
|
|
|
Counted over the same window: `reconstituted: 0 · safety dump: 0 · replaying DB dump: 0 ·
|
|
missing-only: 1`. The reconstitute endpoint appears nowhere in the log. **H1.**
|
|
|
|
### 2. Which snapshot / which dump
|
|
|
|
Only two snapshots exist for immich:
|
|
|
|
- `6df12205` — 2026-07-18T17:13:46Z
|
|
- `49e7cb46` — 2026-07-19T10:30:01Z ← staged, correct
|
|
|
|
**No snapshot was pushed after the deletion**, so there is no post-delete empty-state snapshot to
|
|
restore by accident. `49e7cb46`'s unit carries `offsite_run_id: 20260719T102851Z` /
|
|
`dumps_at: 2026-07-19T10:28:51Z`, and its dump (`immich-postgres.sql`, mtime 10:28:52, 52 393 708 B)
|
|
probes to **`asset: 11` / `user: 1`**.
|
|
|
|
> **R-44 is working exactly as designed.** Round 1's dump was `asset: 0 / user: 0`. This one holds
|
|
> the customer's data and is stamped as coherent with the files beside it. The pair mechanism is
|
|
> not implicated in this failure.
|
|
|
|
The `pre-restore-` safety dump exists: `pre-restore-20260719T105811Z-immich-postgres.sql`,
|
|
51 964 807 B, 10:58:11 — written before anything was stopped or overwritten, as designed.
|
|
|
|
### 3. DB truth
|
|
|
|
Before Phase 3: `total 0 / trashed 0 / live 0`, 1 user — the trash was genuinely emptied, this was
|
|
real data loss (unlike round 1, where the rows survived as trashed).
|
|
|
|
After Phase 3: **`asset: 11, all status=active, deletedAt null, isOffline false, all 11 owned by
|
|
the live user`**; all 11 `originalPath` values resolve to files on disk. Every timeline-visibility
|
|
condition is satisfied.
|
|
|
|
### 4. File truth — what the 1.2 GB actually is
|
|
|
|
The operator's suspicion (immich's own DB-backup feature accumulating) is a **minor** contributor:
|
|
one 18 MB file. The dominant item is something else entirely.
|
|
|
|
| Item | Bytes | Share |
|
|
|---|---:|---:|
|
|
| `volume-dumps/immich_immich_ml_cache.tar` | 823 660 032 | **~60%** |
|
|
| `volume-dumps/immich_immich_postgres_data.tar` | 308 251 136 | ~23% |
|
|
| `db-dumps/immich-postgres.sql` | 52 393 708 | ~4% |
|
|
| `volume-dumps/immich_immich_redis_data.tar` | 6 358 016 | <1% |
|
|
| `appdata/immich` tree (below) | ~127 MB | ~10% |
|
|
| — of which `upload/` (originals, 2 user trees) | 72 MB | |
|
|
| — of which `encoded-video/` | 30 MB | |
|
|
| — of which `backups/` (immich's OWN nightly dump) | 18 MB | |
|
|
| — of which `thumbs/` | 8 MB | |
|
|
|
|
**The single biggest thing in the customer's offsite backup is immich's machine-learning model
|
|
cache — 786 MiB of re-downloadable model weights.** Second is a raw tar of the postgres data
|
|
directory, which duplicates the logical `.sql` dump captured beside it (both are in every
|
|
snapshot). Actual irreplaceable customer content — the originals — is 72 MB, and even that includes
|
|
the ~36 MB stranded pre-v3 user tree (`dccc13fe…`) from round 1.
|
|
|
|
So roughly **1.1 GB of a 1.2 GB "photo backup" is cache and duplication**, at the customer's offsite
|
|
quota and transfer cost.
|
|
|
|
### 5. Phase-3 run — the new defect
|
|
|
|
`pg_dump` is invoked `--clean --if-exists`, and `psql` with `ON_ERROR_STOP=1` (so an error aborts
|
|
rather than half-applying — correct). Plain-format pg_dump order is DROP → CREATE TABLE → COPY data
|
|
→ CREATE INDEX/constraints. The abort landed in the final stage:
|
|
|
|
- 10:58:25 replay begins; the DROP + CREATE TABLE + COPY phases succeed (11 assets land)
|
|
- 10:58:33 **immich-server** logs `Reindexing clip_index` → `Reindexed clip_index`
|
|
- 10:58:35 the dump's own `CREATE INDEX clip_index` fails: *already exists* → exit 3
|
|
|
|
immich-server then logs `Detected schema drift` and lists indexes that are missing and need to be
|
|
created (`user_updatedAt_id_idx`, `library_ownerId_idx`, `stack_primaryAssetId_idx`,
|
|
`asset_id_timeline_notDeleted_idx`, …) — the ones the aborted script never reached.
|
|
|
|
---
|
|
|
|
## Phase 2 — verdict
|
|
|
|
| Hypothesis | Verdict | Decisive evidence |
|
|
|---|---|---|
|
|
| **H1** — mangled row caused a mis-click; missing-only ran, replay never did | **CONFIRMED** | `placed immich … 34 file(s) merged (missing-only)` at 10:39:55; `reconstituted / safety dump / replaying DB dump` counts all **0**; the reconstitute endpoint appears nowhere in the log |
|
|
| **H2** — full restore ran but replayed a wrong/stale unit | **REJECTED** | The full path never executed at all. Independently, the staging used `49e7cb46` — the correct pre-delete snapshot, stamped coherent, dump holding 11 assets |
|
|
| **H3** — replay ran correctly, immich-side hides the assets | **REJECTED** | No replay occurred; the DB was genuinely `asset: 0`. After the Phase-3 replay the assets are visible-by-every-criterion, so nothing immich-side hides them |
|
|
| **H4 (new)** — the replay races the app's own schema repair | **CONFIRMED** | `clip_index` recreated by immich-server at 10:58:33, dump's CREATE INDEX fails at 10:58:35; resulting schema drift reported by immich itself |
|
|
|
|
---
|
|
|
|
## Phase 3 — recovery: PARTIAL, stopped as instructed
|
|
|
|
One deliberate run, via the exact endpoints the buttons post to (the endpoint route was used rather
|
|
than a click **because the mangled 4-button row makes the correct target genuinely ambiguous** —
|
|
finding 1 below):
|
|
|
|
1. `POST /backup/offbox/restore` `app=immich mode=full confirm=1` → staged `49e7cb46`, 1.3 G scratch.
|
|
2. `POST /backup/offbox/reconstitute` `app=immich confirm=1` → safety dump → stop → start → replay →
|
|
**abort** (H4).
|
|
|
|
Result: **photos recovered and visible-by-every-DB-criterion; the operation reported failure; the
|
|
schema is incomplete.** Per the runbook — state is partially mutated, so **no second attempt was
|
|
made**. The safety dump from 10:58:11 is intact and untouched.
|
|
|
|
> **Screenshot still needed from Viktor** (cannot be captured from SSH): the immich timeline showing
|
|
> the 11 photos. Requested; not yet held.
|
|
|
|
---
|
|
|
|
## Phase 4 — findings
|
|
|
|
### 1. [HIGH] Restore UX — adjacent buttons whose difference is "data returns vs data cannot return"
|
|
|
|
Independent of the H-verdict, and the direct cause of H1:
|
|
|
|
- **The second step is hidden.** „Teljes visszaállítás indítása" only appears after
|
|
„…előkészítése" has been pressed, with zero signposting that a second step exists or that the
|
|
first one did nothing to the live data.
|
|
- **The row overflows.** Four buttons plus hint text overlap into an unreadable row, so the honest
|
|
labels — the ones that distinguish a file-only merge from a real restore — are exactly what gets
|
|
lost.
|
|
- **The proven consequence class:** *two adjacent controls whose difference is "your data comes
|
|
back" versus "your data cannot come back" must not be separable only by layout.* This is now
|
|
demonstrated, not theorised: a competent operator who had read the code pressed the wrong one.
|
|
|
|
Direction (already ruled in principle, spec rides v0.149): a single „Visszaállítás…" guided dialog —
|
|
one intent, visible phases, the wizard precedent.
|
|
|
|
### 2. [HIGH, new] The DB replay races the application's own schema repair
|
|
|
|
The reconstitution starts the stack before replaying, because `ImportDump` needs a live container.
|
|
That hands the application a window in which it recreates schema objects the dump is about to
|
|
create. Here it cost only the index phase; the data had already landed because pg_dump orders COPY
|
|
before CREATE INDEX. **That ordering is luck, not design** — a collision earlier in the script
|
|
aborts before the data and produces a half-restored database reported identically.
|
|
|
|
Note the same start-then-replay shape exists on the LOCAL restore path
|
|
(`RestoreFromRecoveryUnit` → `RecreateStackFromUnit` → `reimportDBDumpsCtx`), so this is a class
|
|
defect, not an offsite-only one. Direction for v0.149 (not decided here): bring up the DB container
|
|
alone for the replay and start the app only afterwards, or quiesce the app's schema management for
|
|
the duration.
|
|
|
|
### 3. [MED, new] A partially-successful restore reports as a flat failure
|
|
|
|
The customer was told „az adatbázis visszaállítása sikertelen" while their photos were, in fact,
|
|
restored. This is the mirror image of round 1's defect (success reported over a no-op) and the same
|
|
class: **the message describes the mechanism's exit status, not the outcome.** Worse here, because
|
|
the true state — data restored, schema incomplete — is neither "success" nor "failure" and the UI
|
|
has no way to say it.
|
|
|
|
### 4. [MED] Template classification — 1.1 GB of a 1.2 GB backup is cache and duplication
|
|
|
|
Candidates to exclude from the offsite capture set, **recorded not changed**:
|
|
|
|
- `immich_ml_cache` (786 MiB) — re-downloadable model weights, pure cache. Strong candidate.
|
|
- `immich_postgres_data` tar (294 MiB) — duplicates the logical `.sql` dump captured beside it.
|
|
Keeping both doubles the DB's footprint in every snapshot; the dump is already the authoritative
|
|
copy on the restore path.
|
|
- `upload/backups/` (18 MiB, immich's own nightly dump) — a backup inside a backup; grows daily.
|
|
- The stranded pre-v3 `dccc13fe…` tree (~36 MiB) from round 1, still unreferenced by any DB.
|
|
|
|
### 5. Standing rulings, carried into the repo
|
|
|
|
- **`00-capability-map.md:61`** — ruled: CAMPAIGN-6D's destruction hit the **file tree only**; the
|
|
database survived in its named volume (`immich_postgres_data` is a named volume in both eras), so
|
|
"immich end-to-end from offsite alone" **overclaimed scope**. Row → PARTIAL, scope-corrected.
|
|
- **The 704.6 MiB "discrepancy"** — ruled **resolved, not a defect**: the figure was immich's own
|
|
Tárhely widget (immich-internal accounting), never a controller page. Closed in the round-1 DIAG.
|
|
The same explanation covers today's „650 MiB → 1.4 GiB" observation, which likewise does not match
|
|
the controller's own measurement of the tree (127 MB).
|
|
|
|
### 6. Capability map — customer-restore row
|
|
|
|
**NOT flipped.** The Phase-3 run recovered the photos but reported failure and left schema drift, so
|
|
it is not the clean acceptance evidence §9 asks for. Recorded as
|
|
*"partial evidence captured 2026-07-19 — blocked on H4"* rather than flipped.
|
|
|
|
---
|
|
|
|
## What was NOT done
|
|
|
|
- No code, label or layout fixes — all of the above rides v0.149.
|
|
- No `restic forget`/`prune`, no snapshot deletion.
|
|
- The safety dumps were read, never modified or removed.
|
|
- **No second restore attempt** after the partially-mutating first run.
|