R-361: the safety dump destroyed the app's own database backup
gates / gates (push) Successful in 11s

writeSafetyDump called DumpOne into the app's OWN unit dir and renamed the result
to pre-restore-* afterwards. DumpOne writes <stack>-<dbtype>.sql - the app's
canonical dump - so every safety dump overwrote the app's real backup and then
moved it away, leaving the app with no database backup until the next nightly
run. A local restore-from-unit in that window tells the customer the app never
had a database.

The comment beside it asserted the rename meant it 'can never overwrite the app's
real dump'. False as written, and believed for four months. Measured live before
the fix: docmost and bookstack each held only pre-restore-* files and no
canonical dump.

DumpOneTo takes the final path and derives its own .tmp from it. DumpOne keeps
its signature and calls it with the canonical name. writeSafetyDump asks for its
own name directly; the rename is gone; the comment now states the invariant and
how it is enforced.

db_dumps no longer lists the undo copies. All three consumers of Manifest.DBDumps
were grepped and named - all inside recovery_unit.go, none reads it for recovery.
The files are neither deleted nor hidden.

Tests 1485 -> 1493. FIVE red-proofs, TWO PASSED first time and both are reported:
the behavioural tests inject the dump seam so a mutation inside DumpOneTo was
invisible, and 1.3 had no test at all. Guards added at the layer each defect
lives in; both mutations then convicted.
This commit is contained in:
2026-08-22 23:39:59 +02:00
parent 2024ed9982
commit 968c968559
11 changed files with 524 additions and 40 deletions
+35 -8
View File
@@ -226,14 +226,43 @@ func DumpAll(ctx context.Context, dbs []DiscoveredDB, dumpDir string, logger *lo
return results
}
// DumpOne dumps a single database.
// CanonicalDumpName is the app's own dump filename for one database: `<stack>-<dbtype>.sql`. It is
// the name the replay loop matches EXACTLY, which is why nothing else may ever be written to it.
func CanonicalDumpName(db DiscoveredDB) string {
return fmt.Sprintf("%s-%s.sql", db.StackName, db.DBType)
}
// DumpOne dumps a single database to the app's CANONICAL dump name inside dumpDir.
//
// Signature deliberately unchanged (R-361): it has callers outside this concern, and the defect was
// never in this function's contract — it was in a caller assuming a rename afterwards was equivalent
// to never writing the canonical name at all. Callers that need a different destination use
// DumpOneTo.
func DumpOne(ctx context.Context, db DiscoveredDB, dumpDir string, logger *log.Logger, debug bool) DumpResult {
return DumpOneTo(ctx, db, filepath.Join(dumpDir, CanonicalDumpName(db)), logger, debug)
}
// DumpOneTo dumps a single database to an EXPLICIT final path, deriving its scratch file from that
// path rather than from the canonical name.
//
// R-361, and the reason it exists: `writeSafetyDump` used to call DumpOne into the app's own unit
// directory and rename the result to `pre-restore-*` afterwards. DumpOne writes
// `<stack>-<dbtype>.sql` — THE APP'S REAL DUMP — so every safety dump destroyed the app's own backup
// and then moved it away, leaving the app with no database backup at all until the next nightly run.
// Measured live on `demo-hp` 2026-08-22: both `docmost` and `bookstack` had ONLY `pre-restore-*`
// files in their unit and no canonical dump. In that window a local restore-from-unit finds no `.sql`
// and tells the customer the app never had a database.
//
// The `.tmp` is derived from the FINAL path for the same reason: two dumps running into the same
// directory (a nightly dump beside a safety dump) must not share a scratch file.
func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *log.Logger, debug bool) DumpResult {
start := time.Now()
result := DumpResult{DB: db}
dumpDir := filepath.Dir(finalPath)
if debug {
logger.Printf("[DEBUG] DumpOne: starting dump for container=%s, stack=%s, dbType=%s, dumpDir=%s",
db.ContainerName, db.StackName, db.DBType, dumpDir)
logger.Printf("[DEBUG] DumpOne: starting dump for container=%s, stack=%s, dbType=%s, dest=%s",
db.ContainerName, db.StackName, db.DBType, finalPath)
}
// Ensure dump directory exists
@@ -243,9 +272,7 @@ func DumpOne(ctx context.Context, db DiscoveredDB, dumpDir string, logger *log.L
return result
}
filename := fmt.Sprintf("%s-%s.sql", db.StackName, db.DBType)
tmpPath := filepath.Join(dumpDir, filename+".tmp")
finalPath := filepath.Join(dumpDir, filename)
tmpPath := finalPath + ".tmp"
// 5-minute timeout per dump
dumpCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
@@ -374,11 +401,11 @@ func DumpOne(ctx context.Context, db DiscoveredDB, dumpDir string, logger *log.L
if debug {
logger.Printf("[DEBUG] DumpOne: completed %s → %s (size=%s, valid=%v, tables=%d, duration=%s)",
db.ContainerName, filename, humanizeBytes(stat.Size()),
db.ContainerName, filepath.Base(finalPath), humanizeBytes(stat.Size()),
result.Validation.Valid, result.Validation.TableCount, result.Duration.Round(time.Millisecond))
}
logger.Printf("[INFO] [backup] DB dump: %s → %s (%s, %s, %d tables)", db.ContainerName, filename,
logger.Printf("[INFO] [backup] DB dump: %s → %s (%s, %s, %d tables)", db.ContainerName, filepath.Base(finalPath),
humanizeBytes(stat.Size()), result.Duration.Round(time.Millisecond), result.Validation.TableCount)
return result
@@ -180,3 +180,73 @@ func TestImportDumpErrorDoesNotCarryEngineStderr(t *testing.T) {
t.Error("the engine stderr is no longer logged either — the diagnostic must be ADDED to the operator log, not removed from everywhere")
}
}
// R-361, AT THE LAYER THE DESTINATION IS HONOURED.
//
// WHY THIS EXISTS — recorded because a red-proof PASSED without it, for the second session running.
// The behavioural tests in internal/backup inject `m.safetyDumpFn`, so the REAL DumpOneTo never runs
// there: making DumpOneTo ignore its `finalPath` and write the canonical name anyway did not fail a
// single one of them. DumpOneTo shells out to docker, so its write path cannot be driven in a unit
// test; what CAN be pinned is the thing the mutation changes.
//
// TWO PROPERTIES, both mutation-detectable:
// 1. `finalPath` is never reassigned — the caller's destination is the destination.
// 2. the scratch file is derived from `finalPath`, not from the canonical name, so a nightly dump
// and a safety dump running into the same directory cannot share it.
func TestDumpOneToHonoursTheDestinationItWasGiven(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "dbdump.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var body *ast.BlockStmt
for _, d := range f.Decls {
if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "DumpOneTo" && fn.Body != nil {
body = fn.Body
}
}
if body == nil {
t.Fatal("DumpOneTo not found — this guard can no longer prove anything")
}
// 1. no assignment to finalPath
ast.Inspect(body, func(n ast.Node) bool {
as, ok := n.(*ast.AssignStmt)
if !ok {
return true
}
for _, lhs := range as.Lhs {
if id, ok := lhs.(*ast.Ident); ok && id.Name == "finalPath" {
t.Errorf("DumpOneTo REASSIGNS finalPath at line %d — the caller's destination must be "+
"the destination. This is R-361: the safety dump asked for its own name and got the "+
"app's canonical dump written instead, destroying the app's only database backup.",
fset.Position(as.Pos()).Line)
}
}
return true
})
// 2. tmpPath is built from finalPath
var tmpFromFinal bool
ast.Inspect(body, func(n ast.Node) bool {
as, ok := n.(*ast.AssignStmt)
if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 {
return true
}
id, ok := as.Lhs[0].(*ast.Ident)
if !ok || id.Name != "tmpPath" {
return true
}
bin, ok := as.Rhs[0].(*ast.BinaryExpr)
if ok {
if x, ok := bin.X.(*ast.Ident); ok && x.Name == "finalPath" {
tmpFromFinal = true
}
}
return true
})
if !tmpFromFinal {
t.Error("tmpPath is not derived from finalPath — two dumps into the same directory could share " +
"a scratch file, which is the same collision R-361 is about, one file over")
}
}