From fc6466a50a65cf225997e0f0b143f0c6fcdd5ba5 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 10 Oct 2026 15:19:43 +0200 Subject: [PATCH] v0.307.0: after a restart the Databases table lists each database once, not every pre-restore undo copy Seen on demo-hp 2026-10-10 (adventurelog x4, bookstack x4). The synthesised run skips IsUndo files; only undo copies -> no synthesised run. Two tests, both red-proved. v0.306.0 (R-570) was built but never delivered; 0.307.0 carries it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 15 +++++- controller/internal/backup/backup.go | 17 ++++-- .../internal/backup/synth_skips_undo_test.go | 54 +++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 controller/internal/backup/synth_skips_undo_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a76dafe..3c47bdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ +## v0.307.0 — after a restart the Databases table lists each database once, not every undo copy (2026-10-10) + +**MinAgent: 0.131.0** (unchanged — no agent call changed). Carries v0.306.0 (R-570). Released 2026-10-10 alone (no hub +change). + +- **Backup → Apps „Databases": one row per database after a controller restart.** SEEN 2026-10-10 on demo-hp's household + guest (0.305.0, while retaking the website pictures, R-910): adventurelog four rows, bookstack four rows, every one + stamped with the night's time. Cause: after a restart the last run's result is not in memory, so `GetFullStatus` + SYNTHESISES it from the dump files on disk — and listed every `pre-restore-` undo copy (R-379) as one more row of the + run. Now the synthesis skips undo copies (they stay in the file list below, as R-379 decided), and with only undo + copies on disk there is no synthesised run at all. Tests `TestSynthesisedRunListsNoUndoCopy` (the measured shape; + RED before: 5 rows for 2 databases) and `TestSynthesisedRunAbsentWhenOnlyUndoCopies` (RED with the guard removed). + ## v0.306.0 — the off-site note decides on its kind only; the pre-0.251.0 text fallback is gone (R-570) (2026-10-10) -**MinAgent: 0.131.0** (unchanged — no agent call changed). Released 2026-10-10 alone (no hub change). +**MinAgent: 0.131.0** (unchanged — no agent call changed). **Built and pushed to the registry, NEVER delivered** — superseded the same afternoon by v0.307.0, which carries this change too. - **R-570 — the legacy text fallback in `offboxWarningDisplay` is deleted** (`internal/web/handlers.go`), with its constant `offboxStaleWarningMarker`. It existed only for a box whose last off-site run predated v0.251.0 and so diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index cb88292..0a17d79 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -1311,6 +1311,12 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus { var results []DumpResult var latestTime time.Time for _, f := range status.DumpFiles { + // 2026-10-10: a `pre-restore-` undo copy (R-379) is not part of any run — it stays in the file + // list, never in the run's table (demo-hp listed adventurelog four times after a restart). + // Pinned by TestSynthesisedRunListsNoUndoCopy. + if f.IsUndo { + continue + } results = append(results, DumpResult{ DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName}, FilePath: f.FileName, @@ -1323,10 +1329,13 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus { } // R-519: after a restart the run's result is not in memory, so it is SYNTHESISED from the files. A run // the stop cut off left a fresh .sql beside last night's tars — "Success" must not be read off that. - status.LastDBDump = &DBDumpStatus{ - LastRun: latestTime, - Results: results, - Success: m.runInterrupted.IsZero(), + // Only undo copies on disk → no run to show (nil, as with no files at all). + if len(results) > 0 { + status.LastDBDump = &DBDumpStatus{ + LastRun: latestTime, + Results: results, + Success: m.runInterrupted.IsZero(), + } } } diff --git a/controller/internal/backup/synth_skips_undo_test.go b/controller/internal/backup/synth_skips_undo_test.go new file mode 100644 index 0000000..67faf50 --- /dev/null +++ b/controller/internal/backup/synth_skips_undo_test.go @@ -0,0 +1,54 @@ +package backup + +import ( + "testing" + "time" +) + +// After a controller restart the Backup → Apps „Databases" table is SYNTHESISED from the dump files on disk (the last +// run's result is not in memory). Seen 2026-10-10 on demo-hp after the 0.305.0 delivery: every `pre-restore-` undo copy +// (R-379) was listed as one more row of the night's run — adventurelog four times, bookstack four times, each stamped +// with the run's time. An undo copy is not part of any run: it stays in the file list (its visibility is a recorded +// decision), but the run table shows one row per database, the newest real dump. +func TestSynthesisedRunListsNoUndoCopy(t *testing.T) { + m, _ := newTestManager(t, "/srv/sys") + night := time.Date(2026, 10, 10, 2, 15, 0, 0, time.UTC) + m.cachedStatus = &FullBackupStatus{DumpFiles: []DumpFileInfo{ + {StackName: "adventurelog", DBType: DBTypePostgres, FileName: "adventurelog-postgres.sql", ModTime: night}, + {StackName: "adventurelog", DBType: DBTypePostgres, FileName: "pre-restore-20260913T193505Z-adventurelog-postgres.sql", ModTime: night.AddDate(0, 0, -27), IsUndo: true}, + {StackName: "adventurelog", DBType: DBTypePostgres, FileName: "pre-restore-20260928T021839Z-adventurelog-postgres.sql", ModTime: night.AddDate(0, 0, -12), IsUndo: true}, + {StackName: "bookstack", DBType: DBTypeMariaDB, FileName: "bookstack-mariadb.sql", ModTime: night}, + {StackName: "bookstack", DBType: DBTypeMariaDB, FileName: "pre-restore-20260930T113419Z-bookstack-mariadb.sql", ModTime: night.AddDate(0, 0, -10), IsUndo: true}, + }} + st := m.GetFullStatus(time.Time{}) + if st.LastDBDump == nil { + t.Fatal("no synthesised run") + } + per := map[string]int{} + for _, r := range st.LastDBDump.Results { + per[r.DB.StackName]++ + if r.FilePath != "adventurelog-postgres.sql" && r.FilePath != "bookstack-mariadb.sql" { + t.Errorf("an undo copy is listed as a row of the run: %s", r.FilePath) + } + } + if per["adventurelog"] != 1 || per["bookstack"] != 1 || len(st.LastDBDump.Results) != 2 { + t.Errorf("want one row per database (2), got %d: %v", len(st.LastDBDump.Results), per) + } + if !st.LastDBDump.LastRun.Equal(night) { + t.Errorf("the run time must come from the real dumps: %v", st.LastDBDump.LastRun) + } + if len(st.DumpFiles) != 5 { + t.Errorf("the file list must keep every file, undo copies included (R-379): %d", len(st.DumpFiles)) + } +} + +// Only undo copies on disk: there is no run to show, so no synthesised run (a zero-time „last backup" would be a lie). +func TestSynthesisedRunAbsentWhenOnlyUndoCopies(t *testing.T) { + m, _ := newTestManager(t, "/srv/sys") + m.cachedStatus = &FullBackupStatus{DumpFiles: []DumpFileInfo{ + {StackName: "bookstack", DBType: DBTypeMariaDB, FileName: "pre-restore-20260930T113419Z-bookstack-mariadb.sql", ModTime: time.Now(), IsUndo: true}, + }} + if st := m.GetFullStatus(time.Time{}); st.LastDBDump != nil { + t.Errorf("a run was synthesised from undo copies alone: %+v", st.LastDBDump) + } +}