v0.153.0 — R-47: the DB replay no longer races the app, on BOTH restore paths

Closes R-47. No new agent coupling — MinAgent stays 0.90.0.

The replay needs a running DB container, so both restore paths started the
WHOLE stack first, giving the application a window to rebuild the very schema
objects the dump was about to create. Measured live on 2026-07-19 (H4,
DIAG-immich-restore-round2): immich-server rebuilt clip_index two seconds
before the dump's CREATE INDEX, the replay aborted "already exists" under
ON_ERROR_STOP=1, and immich reported schema drift. The data survived only
because pg_dump emits COPY before CREATE INDEX.

Both paths now open a DB-ONLY window: only the stack's database service(s)
come up, the dump is replayed with the app still down, and the full start
runs only after the replay exits 0. Fail-closed: a dump with no identifiable
DB service refuses BEFORE the first mutation. Every exit from the window
still does a best-effort full start, so a failed restore never leaves a box
with a database and no application.

New: appbackup.DBServiceNames (yaml.v3 services-map parse — never a line
scan; immich's top-level volume keys are the decoy) sharing dbTypeForImage
with DiscoverDatabases; stacks.Manager.StartStackServices (refuses an empty
list — argument-less `up -d` is a full start); RedeployFromEnv split into
PersistUnitRedeployConfig + its unchanged tail. StackDataProvider's
RecreateStackFromUnit becomes RecreateStackDefinitionFromUnit — the hidden
`up -d` inside the old name is what carried the defect on the local path.

19 new tests (ordering plus state-at-replay-time, zero-mutation fail-closed
effects, replay-failure bring-up, parser decoys, empty-list refusal); three
companion red-proofs run and reverted. 23/23 packages green.

Not yet live-validated: STOP-1 supervised reconstitute, golden 0.153.0.
This commit is contained in:
2026-07-20 17:01:52 +02:00
parent fd40b29119
commit 78ff991f1c
25 changed files with 1323 additions and 201 deletions
+80
View File
@@ -1,5 +1,85 @@
## Changelog
### v0.153.0 — the database replay no longer races the application, on BOTH restore paths (2026-07-20)
Closes **R-47**. **No new agent coupling — MinAgent stays 0.90.0.** Nothing in this release talks to
the host agent; the whole change is inside the controller's own compose orchestration.
**The defect, measured to the second.** On 2026-07-19 the offsite reconstitution was run deliberately
and correctly (`felhom.eu/documentation/audits/DIAG-immich-restore-round2-2026-07-19.md`, finding
**H4**). It executed its designed sequence — safety dump, stop, start, replay — and the replay
aborted:
```
10:58:25 controller: replaying DB dump into immich-postgres
10:58:33 immich-server: "Reindexing clip_index" -> "Reindexed clip_index" <- the app recreates it
10:58:35 controller: ERROR relation "clip_index" already exists - exit status 3
```
The replay needs a running database container, so the code started the WHOLE stack first. That gave
immich-server an eight-second window in which to rebuild the very schema objects the dump was about
to create, and under `ON_ERROR_STOP=1` the collision aborted the script. The photos came back anyway
**by accident**: `pg_dump` emits COPY data before CREATE INDEX, so the abort landed after the rows. A
collision earlier in the script would have left a genuinely half-restored database and reported it
identically. The operation reported failure and immich then reported schema drift.
**The fix: a DB-only window.** After the files are placed, only the stack's database service(s) come
up; the dump is replayed into them with the application still stopped; the rest of the stack starts
only once the replay has exited 0. Nothing about the replay itself changed — `--clean --if-exists`
and `ON_ERROR_STOP=1` were always correct. The bug was the window, not the flags.
**This was a class defect and both paths carried it.** The local `RestoreFromRecoveryUnit` had the
same start-then-replay shape, hidden inside `RecreateStackFromUnit` (which ended in a full
`compose up -d`). Fixing only the offsite path would have left the identical race one button away.
Both are re-sequenced here.
**What changed**
- `appbackup.DBServiceNames(composePath)` names the compose SERVICE(s) whose `image:` identifies a
database — `docker compose up -d` takes service names, not container names. It is a yaml.v3
`services:` map parse, deliberately not a line scan: immich's real template carries top-level
`immich_ml_cache:` and `immich_postgres_data:` volume keys that sit at exactly the indentation a
service name does.
- The image heuristic that `DiscoverDatabases` had inline is extracted to `dbTypeForImage` and shared
by both. That sharing is what makes the safety argument hold: a `.sql` dump can only exist because
discovery matched the running container's image, and the compose `image:` value IS that image
string — so "a dump exists" and "a service can be named" are answered by one predicate.
- `stacks.Manager.StartStackServices(name, services)` runs the scoped `up -d`. It **refuses an empty
service list**: an argument-less `up -d` is a full start, which is precisely the behaviour the
window exists to avoid, and a silent fall-through would have reintroduced the race at the one call
site that most needs it not to.
- `RedeployFromEnv` is split. Its persist half is now `PersistUnitRedeployConfig` (app.yaml, locked
fields, in-memory flags — starting nothing); `RedeployFromEnv` is that plus its unchanged
up-and-report tail, so its public behaviour is byte-identical. The split is what lets the restore
path put the DB-only window between persisting the definition and starting the app.
- `StackDataProvider.RecreateStackFromUnit` becomes `RecreateStackDefinitionFromUnit` (files +
persist, no start), and gains `StartStackServices`. The rename is deliberate: the old name promised
less than the method did, and the hidden `up -d` inside it is what carried the defect on the local
path.
**Fail-closed, both paths.** If a `.sql` dump exists but no database service can be identified in the
compose, the restore **refuses before the first mutation** — no stop, no file overwrite, no volume
restore. The alternative would be to start everything and replay into the race. Given the shared
predicate this should be structurally unreachable; it is the belt for template drift, not an expected
path.
**Every exit from the window still starts the app.** A failed replay, or a failed DB-only start, is
surfaced as before — but a best-effort full `StartStack` runs first. The DB-only state is a
deliberate half-started one, and leaving a customer with a running database and no application would
turn a failed restore into an outage.
**Tests.** 19 new (Groups AG): ordering plus **state-at-replay-time** on both paths (a recording
provider captures whether the full stack was up at the moment the import fired — asserting "no error"
would have passed on the pre-fix shape, which is how this shipped), the no-DB negatives, the
zero-mutation fail-closed effects, the replay-failure bring-up, the compose-parser decoys built from
the catalog's real immich template, and the empty-list refusal. Three companion red-proofs run and
reverted: the pre-fix full start on the offsite path, the pre-fix full start on the local path, and
deletion of both fail-closed gates — each failing on the intended assertion. 23/23 packages green.
**Not yet live-validated.** The supervised reconstitute on the demo box (STOP-1) and Viktor's C6
customer-restore UI run remain outstanding.
### v0.152.0 — Megosztás on a Mac: mDNS in the image, and the page stops giving Mac users a dead form (2026-07-20)
Closes **S-3** of `felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md`, and fixes a copy