R-1252 (P2): a rejected or absent backup block never deletes second-drive copies — reconcile only on a valid classification; a rejected block is said on the app's status and once a day to the operator (backup_run_failures); kept copies stay kept (H2); Remove drops the kept records it deleted (H9). Commit only.
gates / gates (push) Successful in 1m18s
gates / gates (push) Successful in 1m18s
This commit is contained in:
@@ -185,6 +185,8 @@ type Manager struct {
|
||||
|
||||
// hollow (Part D, v0.279.0) — the running apps whose newest copy holds no data. See hollow_watch.go.
|
||||
hollow hollowWatch
|
||||
// tier2Unreadable (R-1252) dedupes the operator signal for a rejected backup block: once per app per day.
|
||||
tier2Unreadable tier2UnreadableWatch
|
||||
|
||||
// F17 restore seams — overridable in tests so the .sql re-import orchestration can be unit-tested
|
||||
// without Docker. Default to the real DiscoverDatabases / ImportDump (lazy-init in reimportDBDumps).
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package backup
|
||||
|
||||
// R-1252 (review round 2, HUNT-2 H1, H2, H9): a rejected backup block never deletes second-drive copies.
|
||||
//
|
||||
// The first test is the review's 9202 reproduction (audits/review-round-2-2026-10-24/proofs/H2-bad-backup-block/) as a
|
||||
// unit test: an app with its household files in a userdata leg; two good runs keep the copy (the control); one bogus
|
||||
// path in the block rejects it → the legacy legs → before the fix „Tier 2 reconcile: removed stale dest dir
|
||||
// userdata/media". RED-PROOF (2026-10-24, on the parent of this commit): TestR1252_ARejectedBlockKeepsEveryCopy failed
|
||||
// on „the household's file copy was deleted"; TestR1252_AKeptCopyStaysWhenItsLegLeaves on „the kept copy was
|
||||
// reconciled away"; TestR1252_RemoveDropsTheKeptRecordsItDeleted on „the record outlived its copy".
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rejectingProvider is the v2 fixture provider that can also say a block was rejected (the stack adapter's
|
||||
// BackupBlockRejected in production).
|
||||
type rejectingProvider struct {
|
||||
*t2v2Provider
|
||||
rejected map[string]string
|
||||
}
|
||||
|
||||
func (p *rejectingProvider) BackupBlockRejected(n string) string { return p.rejected[n] }
|
||||
|
||||
func mUD(rel string) ClassifiedBind {
|
||||
b := oUD(rel)
|
||||
b.ReadOnly = false
|
||||
b.Class = ClassMandatory
|
||||
return b
|
||||
}
|
||||
|
||||
func TestR1252_ARejectedBlockKeepsEveryCopy(t *testing.T) {
|
||||
m, src, target, prov := newTier2V2(t, "audiobookshelf")
|
||||
rp := &rejectingProvider{t2v2Provider: prov, rejected: map[string]string{}}
|
||||
m.stackProvider = rp
|
||||
var sent []string
|
||||
m.SetTier2SettingsUnreadableNotify(func(app, why string) { sent = append(sent, app+": "+why) })
|
||||
prov.has["audiobookshelf"] = true
|
||||
prov.binds["audiobookshelf"] = []ClassifiedBind{mHDD("appdata/audiobookshelf/config"), mUD("media/audiobooks")}
|
||||
prov.mounts["audiobookshelf"] = []string{filepath.Join(src, "appdata", "audiobookshelf", "config")}
|
||||
mustWrite(t, filepath.Join(src, "appdata", "audiobookshelf", "config", "db.sqlite"), "DB")
|
||||
mustWrite(t, filepath.Join(src, "userdata", "media", "audiobooks", "r1252-marker.mp3"), "the household's book")
|
||||
destBase := filepath.Join(target, "backups", "secondary", "audiobookshelf")
|
||||
copyOfBook := filepath.Join(destBase, "userdata", "media", "audiobooks", "r1252-marker.mp3")
|
||||
|
||||
for run := 1; run <= 2; run++ { // the positive control: an unchanged block keeps the copy
|
||||
if err := m.RunTier2("audiobookshelf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(copyOfBook) {
|
||||
t.Fatalf("control run %d: the household's file was not copied", run)
|
||||
}
|
||||
}
|
||||
// one bogus path: LoadMetadata rejects the WHOLE block → no classification, the legacy legs
|
||||
prov.has["audiobookshelf"] = false
|
||||
rp.rejected["audiobookshelf"] = `userdata path "media/nope" matches no compose bind`
|
||||
if err := m.RunTier2("audiobookshelf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(copyOfBook) {
|
||||
t.Fatal("the household's file copy was deleted by the night copy after a rejected block (R-1252)")
|
||||
}
|
||||
if cd := m.settings.GetCrossDriveConfig("audiobookshelf"); cd == nil || !strings.Contains(cd.LastWarning, "nem olvashat") {
|
||||
t.Fatalf("the app's status must say the settings are unreadable and the copies kept: %+v", cd)
|
||||
}
|
||||
if len(sent) != 1 || !strings.Contains(sent[0], "media/nope") {
|
||||
t.Fatalf("the operator must be told once, with the reason: %v", sent)
|
||||
}
|
||||
if err := m.RunTier2("audiobookshelf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("a second run the same day must not tell the operator again: %v", sent)
|
||||
}
|
||||
m.tier2Unreadable.now = func() time.Time { return time.Now().Add(25 * time.Hour) }
|
||||
if err := m.RunTier2("audiobookshelf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sent) != 2 {
|
||||
t.Fatalf("the next day tells the operator again: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// An app with no block at all (absent) — the legacy legs — reconciles nothing either, and says nothing.
|
||||
func TestR1252_AnAbsentBlockReconcilesNothing(t *testing.T) {
|
||||
m, src, target, prov := newTier2V2(t, "legacyapp")
|
||||
prov.has["legacyapp"] = false
|
||||
prov.mounts["legacyapp"] = []string{filepath.Join(src, "appdata", "legacyapp", "x")}
|
||||
mustWrite(t, filepath.Join(src, "appdata", "legacyapp", "a.txt"), "A")
|
||||
destBase := filepath.Join(target, "backups", "secondary", "legacyapp")
|
||||
stray := filepath.Join(destBase, "userdata", "photos", "p.jpg")
|
||||
mustWrite(t, stray, "a copy from an earlier, classified night")
|
||||
if err := m.RunTier2("legacyapp"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(stray) {
|
||||
t.Fatal("an app without a valid classification must not reconcile away a copy")
|
||||
}
|
||||
if cd := m.settings.GetCrossDriveConfig("legacyapp"); cd != nil && strings.Contains(cd.LastWarning, "nem olvashat") {
|
||||
t.Fatalf("an absent block is not unreadable settings: %q", cd.LastWarning)
|
||||
}
|
||||
}
|
||||
|
||||
// The control the other way: a VALID classification still reconciles a leg that was re-classed excluded.
|
||||
func TestR1252_AValidClassificationStillReconciles(t *testing.T) {
|
||||
m, src, target, prov := newTier2V2(t, "app")
|
||||
prov.has["app"] = true
|
||||
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/keep"), mHDD("appdata/app/drop")}
|
||||
mustWrite(t, filepath.Join(src, "appdata", "app", "keep", "k"), "K")
|
||||
mustWrite(t, filepath.Join(src, "appdata", "app", "drop", "d"), "D")
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dropped := filepath.Join(target, "backups", "secondary", "app", "hdd", "appdata", "app", "drop")
|
||||
if !exists(dropped) {
|
||||
t.Fatal("precondition: run 1 copied both legs")
|
||||
}
|
||||
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/keep"), xHDD("appdata/app/drop")}
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists(dropped) {
|
||||
t.Fatal("a leg re-classed excluded under a VALID block must still be reconciled away (§8.2)")
|
||||
}
|
||||
}
|
||||
|
||||
// HUNT-2 H2: a copy D25 keeps (its folder went missing) stays kept when its leg later leaves the declared set.
|
||||
func TestR1252_AKeptCopyStaysWhenItsLegLeaves(t *testing.T) {
|
||||
m, src, target, prov := newTier2V2(t, "app")
|
||||
prov.has["app"] = true
|
||||
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/photos"), mHDD("appdata/app/docs")}
|
||||
mustWrite(t, filepath.Join(src, "appdata", "app", "photos", "family.jpg"), "PHOTO")
|
||||
mustWrite(t, filepath.Join(src, "appdata", "app", "docs", "contract.pdf"), "PDF")
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
photo := filepath.Join(target, "backups", "secondary", "app", "hdd", "appdata", "app", "photos", "family.jpg")
|
||||
if err := os.RemoveAll(filepath.Join(src, "appdata", "app", "photos")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RunTier2("app"); err != nil { // the folder is missing → kept + recorded (D25)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.KeptCopies()) != 1 {
|
||||
t.Fatalf("precondition: the photos copy is kept, got %+v", m.KeptCopies())
|
||||
}
|
||||
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/docs")} // the photos leg leaves the declared set
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(photo) {
|
||||
t.Fatal("the kept copy was reconciled away while its record stays (HUNT-2 H2)")
|
||||
}
|
||||
if len(m.KeptCopies()) != 1 {
|
||||
t.Fatalf("the kept record must stay: %+v", m.KeptCopies())
|
||||
}
|
||||
}
|
||||
|
||||
// HUNT-2 H9: Remove with „remove backups" deletes the mirror — and the kept-copy records inside it go with it.
|
||||
func TestR1252_RemoveDropsTheKeptRecordsItDeleted(t *testing.T) {
|
||||
m, src, target, prov := newTier2V2(t, "app")
|
||||
prov.has["app"] = true
|
||||
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/photos")}
|
||||
mustWrite(t, filepath.Join(src, "appdata", "app", "photos", "family.jpg"), "PHOTO")
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(src, "appdata", "app", "photos")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.KeptCopies()) != 1 {
|
||||
t.Fatalf("precondition: one kept copy, got %+v", m.KeptCopies())
|
||||
}
|
||||
mirror := filepath.Join(target, "backups", "secondary", "app")
|
||||
removed := m.RemoveTier2Mirrors("app", []string{mirror})
|
||||
if len(removed) != 1 || exists(mirror) {
|
||||
t.Fatalf("precondition: the mirror is deleted, got %v", removed)
|
||||
}
|
||||
if len(m.KeptCopies()) != 0 {
|
||||
t.Fatalf("the record outlived its copy: %+v", m.KeptCopies())
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ func (m *Manager) RemoveTier2Mirrors(stackName string, dirs []string) []string {
|
||||
continue
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] remove %s: Tier-2 mirror deleted: %s (%s)", stackName, d, size)
|
||||
m.dropKeptCopiesUnder(d) // R-1252 H9: the kept copies inside it are gone too
|
||||
removed = append(removed, fmt.Sprintf("%s (%s)", d, size))
|
||||
}
|
||||
return removed
|
||||
|
||||
@@ -367,7 +367,7 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
}
|
||||
|
||||
// Class-driven legs (classified) / resolver legs (legacy) + loud gap warnings (§7-H).
|
||||
legs, missingLegs, warns := m.tier2CaptureSetFull(stackName, sourceNsRoot)
|
||||
legs, missingLegs, warns, classified := m.tier2CaptureSetFull(stackName, sourceNsRoot)
|
||||
|
||||
// Two sizes: full = unit + all legs; state-only = unit + mandatory legs (the SSD ceiling).
|
||||
unitSize := dirSizeBytes(unitDir)
|
||||
@@ -488,8 +488,22 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
filepath.Join(destBase, filepath.FromSlash(lg.DestRel)))
|
||||
}
|
||||
|
||||
// HUNT-2 H2 (R-1252): a copy D25 keeps stays kept even when its leg leaves the declared set — its record is the
|
||||
// household's only handle on it, and only their „Eltávolítás" removes it.
|
||||
legRels = append(legRels, m.keptCopyRels(destBase)...)
|
||||
// Reconcile stale dest dirs (a bind removed / re-classed excluded), then write the marker LAST.
|
||||
m.tier2Reconcile(destBase, legRels)
|
||||
// R-1252: ONLY on a valid classification. A rejected or absent block yields the legacy legs, and reconciling
|
||||
// against them deleted every copy of the household's files (9202, 2026-10-24). Nothing is reconciled that night;
|
||||
// a REJECTED block is said on the app's status and to the operator.
|
||||
if classified {
|
||||
m.tier2Reconcile(destBase, legRels)
|
||||
} else if why := m.backupBlockRejected(stackName); why != "" {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 %s: the backup block is REJECTED (%s) — nothing reconciled, every existing copy on the second drive is KEPT (R-1252)", stackName, why)
|
||||
warns = append(warns, m.note("note.tier2.settings_unreadable_kept"))
|
||||
m.notifyTier2SettingsUnreadable(stackName, why)
|
||||
} else if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [backup] Tier 2 %s: no backup classification — nothing reconciled (R-1252)", stackName)
|
||||
}
|
||||
if err := os.WriteFile(markerPath, []byte(tier2LayoutVersion), 0644); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 %s: layout marker write failed (restore will refuse until next run): %v", stackName, err)
|
||||
}
|
||||
|
||||
@@ -40,14 +40,18 @@ func tier2DestRel(root appbackup.BindRoot, rel string) string {
|
||||
// footprint promise), mapped into the same v2 layout so restore has ONE reader. Missing/refused
|
||||
// mandatory paths are stat-filtered + surfaced loudly (§7-H).
|
||||
func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns []string) {
|
||||
legs, _, warns = m.tier2CaptureSetFull(stack, nsRoot)
|
||||
legs, _, warns, _ = m.tier2CaptureSetFull(stack, nsRoot)
|
||||
return legs, warns
|
||||
}
|
||||
|
||||
// tier2CaptureSetFull is tier2CaptureSet plus the DECLARED legs that are missing on disk (mandatory and optional).
|
||||
// D25 / R-1181 (2026-10-21): a missing declared folder keeps its second-drive copy — RunTier2 adds these to the
|
||||
// reconcile's keep set and lists them as kept, instead of pruning the copy that may be the only one left.
|
||||
func (m *Manager) tier2CaptureSetFull(stack, nsRoot string) (legs, missing []tier2Leg, warns []string) {
|
||||
//
|
||||
// classified (R-1252) reports whether the legs come from a VALID backup classification. Only then may the run
|
||||
// reconcile (delete) dest dirs the legs no longer name: the legacy set is a fallback, and a block that was rejected
|
||||
// or could not be read falls back to it too — reconciled, that deleted the household's file copies (measured on 9202).
|
||||
func (m *Manager) tier2CaptureSetFull(stack, nsRoot string) (legs, missing []tier2Leg, warns []string, classified bool) {
|
||||
// Classified path.
|
||||
if m.stackProvider != nil {
|
||||
if binds, has := m.stackProvider.GetStackClassifiedBinds(stack); has {
|
||||
@@ -75,7 +79,7 @@ func (m *Manager) tier2CaptureSetFull(stack, nsRoot string) (legs, missing []tie
|
||||
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a(z) %s alkalmazás egyes adatmappái nem kerültek a másodlagos mentésbe: %s.",
|
||||
stack, strings.Join(gaps, ", ")))
|
||||
}
|
||||
return legs, missing, warns
|
||||
return legs, missing, warns, true
|
||||
}
|
||||
}
|
||||
// Legacy path: the resolver appdata dir(s) → hdd/appdata/<name> legs (mandatory-equivalent for SSD
|
||||
@@ -98,5 +102,5 @@ func (m *Manager) tier2CaptureSetFull(stack, nsRoot string) (legs, missing []tie
|
||||
Class: appbackup.ClassMandatory,
|
||||
})
|
||||
}
|
||||
return legs, missing, warns
|
||||
return legs, missing, warns, false
|
||||
}
|
||||
|
||||
@@ -57,6 +57,46 @@ func (m *Manager) releaseKeptCopy(path string) {
|
||||
}
|
||||
}
|
||||
|
||||
// keptCopyRels (R-1252, HUNT-2 H2) is the slash-form path, relative to destBase, of every kept copy recorded inside
|
||||
// it — added to the reconcile's keep set so a kept copy is never reconciled away while its record stays.
|
||||
func (m *Manager) keptCopyRels(destBase string) []string {
|
||||
if m.settings == nil {
|
||||
return nil
|
||||
}
|
||||
base := filepath.Clean(destBase)
|
||||
var out []string
|
||||
for _, k := range m.settings.GetKeptCopies() {
|
||||
if filepath.Clean(k.DestBase) != base {
|
||||
continue
|
||||
}
|
||||
rel, err := filepath.Rel(base, filepath.Clean(k.Path))
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
out = append(out, filepath.ToSlash(rel))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dropKeptCopiesUnder (R-1252, HUNT-2 H9) drops the records of kept copies inside dir — called after dir itself was
|
||||
// deleted (Remove with „remove backups"), so the backups page never lists a copy that is gone.
|
||||
func (m *Manager) dropKeptCopiesUnder(dir string) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
d := filepath.Clean(dir)
|
||||
for _, k := range m.settings.GetKeptCopies() {
|
||||
p := filepath.Clean(k.Path)
|
||||
if p == d || strings.HasPrefix(p, d+string(filepath.Separator)) {
|
||||
if err := m.settings.DropKeptCopy(k.ID); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] the record of the deleted kept copy %s could not be dropped: %v", p, err)
|
||||
continue
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] kept copy %s was deleted with its app's backups — record dropped", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KeptCopies lists the kept copies for the backups page.
|
||||
func (m *Manager) KeptCopies() []settings.KeptCopy {
|
||||
if m.settings == nil {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-1252 (review round 2, HUNT-2 H1): a rejected backup block never deletes second-drive copies. RunTier2 reconciles
|
||||
// only on a valid classification (tier2CaptureSetFull's `classified`); for a REJECTED block it says so on the app's
|
||||
// status (note.tier2.settings_unreadable_kept) and tells the operator once a day through the existing operator-only
|
||||
// digest (backup_run_failures, leg tier2-settings-unreadable — no new event type, so no hub change). Pinned by
|
||||
// r1252_rejected_block_keeps_copies_test.go.
|
||||
|
||||
type tier2UnreadableWatch struct {
|
||||
mu sync.Mutex
|
||||
notify func(app, why string)
|
||||
sent map[string]string // app → the UTC day it was last sent
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// SetTier2SettingsUnreadableNotify wires the operator signal (main.go → notifier). INIT-ONLY.
|
||||
func (m *Manager) SetTier2SettingsUnreadableNotify(fn func(app, why string)) {
|
||||
m.tier2Unreadable.mu.Lock()
|
||||
m.tier2Unreadable.notify = fn
|
||||
m.tier2Unreadable.mu.Unlock()
|
||||
}
|
||||
|
||||
// backupBlockRejected asks the stack provider why the app's backup block was rejected ("" = valid or absent).
|
||||
func (m *Manager) backupBlockRejected(stack string) string {
|
||||
if r, ok := m.stackProvider.(interface{ BackupBlockRejected(string) string }); ok {
|
||||
return r.BackupBlockRejected(stack)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// notifyTier2SettingsUnreadable sends the operator signal at most once per app per UTC day.
|
||||
func (m *Manager) notifyTier2SettingsUnreadable(app, why string) {
|
||||
w := &m.tier2Unreadable
|
||||
w.mu.Lock()
|
||||
now := time.Now
|
||||
if w.now != nil {
|
||||
now = w.now
|
||||
}
|
||||
day := now().UTC().Format("2006-01-02")
|
||||
if w.notify == nil || w.sent[app] == day {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if w.sent == nil {
|
||||
w.sent = map[string]string{}
|
||||
}
|
||||
w.sent[app] = day
|
||||
fn := w.notify
|
||||
w.mu.Unlock()
|
||||
fn(app, why)
|
||||
}
|
||||
Reference in New Issue
Block a user