R-379/R-380: put the customer's undo copy back when a database restore fails
gates / gates (push) Successful in 11s

R-379 and R-380 were one failure. Both ended with a half-restored database; the
only difference was whether it looked broken. Postgres emptied and crash-looped;
MariaDB applied part of the dump and reported health=healthy with a zero-row
schema-version table. Measured live on demo-hp 2026-08-22.

The undo copy was already taken and already good - proven by hand that day on
both engines. Nothing in the product could apply it. Now it does, with the same
ImportDump call, before any restart and inside the DB-only window.

The WHOLE undo set, matched on this run's stamp. writeSafetyDump returned one
path for an app with two databases; a rollback on that would restore one and
leave the other half-written.

When the rollback also fails the app is HELD STOPPED (operator ruling): a running
app on a half-written database lets the customer make the damage permanent. Every
start path refuses it - customer button, appstop Recover, boot sweep - via the
shared driveStartGate, checked ABOVE its driveless early return because these
apps have no drive. The marker is ended so nothing auto-restarts it. The row goes
red. Cleared with --clear-restore-hold, an operator CLI route.

--single-transaction is a belt on Postgres only; MariaDB DDL is not transactional
and that is why the rollback is the fix.

R-381: the engine's stderr stops reaching the customer (615 bytes on MariaDB, its
middle rows out of their own database) and starts reaching the operator log,
which never had it.
R-382: the summary log prints the volume count it already held.
Undo copies resolve to their own app, are marked IsUndo, and are capped at 3 per
app, pruned from the capture side. The reported render-as-an-app symptom did NOT
reproduce - the live page was read first and had zero occurrences.

Tests 1468 -> 1483. Eight red-proofs; ONE PASSED and is reported: the R-381
behavioural test injected below ImportDump. A guard at that layer now convicts.
This commit is contained in:
2026-08-22 18:03:18 +02:00
parent 0f3cf0dbb2
commit 2c724c9283
15 changed files with 1288 additions and 37 deletions
@@ -45,7 +45,11 @@ func TestR355_SafetyDumpIsTakenForTheCorrectlyAttributedApp(t *testing.T) {
return DumpResult{DB: db, FilePath: p, Size: 13}
}
safety, err := m.writeSafetyDump(context.Background(), "paperless-ngx", nsRoot)
// v0.220.0 (R-379): writeSafetyDump returns the SET it wrote, so a rollback can re-apply EVERY
// database's undo. `.First()` is the value this signature returned before; these assertions are
// unchanged in meaning.
set, err := m.writeSafetyDump(context.Background(), "paperless-ngx", nsRoot)
safety := set.First()
if err != nil {
t.Fatalf("writeSafetyDump: %v", err)
}
@@ -83,7 +87,11 @@ func TestR355_MisattributedAppGetsNoUndoCopy(t *testing.T) {
return DumpResult{DB: db}
}
safety, err := m.writeSafetyDump(context.Background(), "paperless-ngx", nsRoot)
// v0.220.0 (R-379): writeSafetyDump returns the SET it wrote, so a rollback can re-apply EVERY
// database's undo. `.First()` is the value this signature returned before; these assertions are
// unchanged in meaning.
set, err := m.writeSafetyDump(context.Background(), "paperless-ngx", nsRoot)
safety := set.First()
if err != nil {
t.Fatalf("writeSafetyDump: %v", err)
}
@@ -111,7 +119,11 @@ func TestR355_RestoreRefusesWhenTheUndoCannotBeTaken(t *testing.T) {
return DumpResult{DB: db, Error: os.ErrPermission}
}
safety, err := m.writeSafetyDump(context.Background(), "paperless-ngx", nsRoot)
// v0.220.0 (R-379): writeSafetyDump returns the SET it wrote, so a rollback can re-apply EVERY
// database's undo. `.First()` is the value this signature returned before; these assertions are
// unchanged in meaning.
set, err := m.writeSafetyDump(context.Background(), "paperless-ngx", nsRoot)
safety := set.First()
if err == nil {
t.Fatal("a database that cannot be dumped must be a hard error — the undo would not exist")
}