From 23256413de6cd0006f8371b732fbbfa699c842ad Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 13 Nov 2026 00:44:07 +0100 Subject: [PATCH] Z12 zfs-snapshots-remove: the run removes surplus scheduled ZFS snapshots only by their own record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 42 zfs-owns-zfs §22, §26, A-30, A-31 --- modules/zfs/cmd/zfs-tools/manifest_test.go | 3 +- modules/zfs/cmd/zfs-tools/run.go | 11 ++ modules/zfs/cmd/zfs-tools/snapshots_prune.go | 156 ++++++++++++++++++ .../zfs/cmd/zfs-tools/snapshots_prune_test.go | 121 ++++++++++++++ 4 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 modules/zfs/cmd/zfs-tools/snapshots_prune.go create mode 100644 modules/zfs/cmd/zfs-tools/snapshots_prune_test.go diff --git a/modules/zfs/cmd/zfs-tools/manifest_test.go b/modules/zfs/cmd/zfs-tools/manifest_test.go index 45c15b9fb..35e20bf7e 100644 --- a/modules/zfs/cmd/zfs-tools/manifest_test.go +++ b/modules/zfs/cmd/zfs-tools/manifest_test.go @@ -228,7 +228,7 @@ func TestTheBundleRunsOnlyReadingVerbsSnapshotAndScrub(t *testing.T) { "DatasetSetArgv": {"zfs", "set"}, "DatasetInheritArgv": {"zfs", "inherit"}, "DeviceStatusArgv": {"zpool", "status"}, "AllRealStatusArgv": {"zpool", "status"}, "RemoveArgv": {"zpool", "remove"}, "AddArgv": {"zpool", "add"}, "WipefsArgv": {"wipefs", "--no-act"}, "ZdbLabelArgv": {"zdb", "-l"}, - "SnapshotSetArgv": {"zfs", "set"}, "ZdbConfigArgv": {"zdb", "-C"}, "ZgenhostidArgv": {"zgenhostid", "-f"}, + "SnapshotSetArgv": {"zfs", "set"}, "HoldsArgv": {"zfs", "holds"}, "SnapshotPropertyArgv": {"zfs", "get"}, "DestroyArgv": {"zfs", "destroy"}, "ZdbConfigArgv": {"zdb", "-C"}, "ZgenhostidArgv": {"zgenhostid", "-f"}, } verbs := map[string][]string{"DatasetsArgv": DatasetsArgv(), "SnapshotsArgv": SnapshotsArgv(), "PoolsArgv": PoolsArgv(), "StatusArgv": StatusArgv(), "IOArgv": IOArgv(3), "EventsArgv": EventsArgv(), "HistoryArgv": HistoryArgv("tank"), @@ -240,6 +240,7 @@ func TestTheBundleRunsOnlyReadingVerbsSnapshotAndScrub(t *testing.T) { "DeviceStatusArgv": DeviceStatusArgv("tank", true), "AllRealStatusArgv": AllRealStatusArgv(), "RemoveArgv": RemoveArgv("tank", "x"), "AddArgv": AddArgv("tank", "cache", false, "/dev/x"), "WipefsArgv": WipefsArgv("/dev/x"), "ZdbLabelArgv": ZdbLabelArgv("/dev/x"), + "HoldsArgv": HoldsArgv("tank@x"), "SnapshotPropertyArgv": SnapshotPropertyArgv("tank@x", "clones"), "DestroyArgv": DestroyArgv("tank", "x"), "SnapshotSetArgv": SnapshotSetArgv("tank@mesh-hourly-x", "hourly"), "ZdbConfigArgv": ZdbConfigArgv("tank"), "ZgenhostidArgv": ZgenhostidArgv("00bab10c")} for fn, argv := range verbs { for _, a := range argv { diff --git a/modules/zfs/cmd/zfs-tools/run.go b/modules/zfs/cmd/zfs-tools/run.go index a99894446..a6bd87aed 100644 --- a/modules/zfs/cmd/zfs-tools/run.go +++ b/modules/zfs/cmd/zfs-tools/run.go @@ -112,6 +112,12 @@ func (m *Machine) RunScheduled(ctx context.Context, servedPath string) (string, if err != nil { failed = append(failed, err) } + case act == ActPrune && guard == "": + ts, err := m.PruneScheduled(ctx, c, true, by) + said = append(said, ts...) + if err != nil { + failed = append(failed, err) + } case guard != "": said = append(said, m.skip(guard)) default: @@ -163,6 +169,11 @@ func (m *Machine) RunScheduled(ctx context.Context, servedPath string) (string, if err != nil { failed = append(failed, err) } + ps, err := m.PruneScheduled(ctx, c, false, "") + said = append(said, ps...) + if err != nil { + failed = append(failed, err) + } if len(said) == 0 { said = append(said, "nothing due") } diff --git a/modules/zfs/cmd/zfs-tools/snapshots_prune.go b/modules/zfs/cmd/zfs-tools/snapshots_prune.go new file mode 100644 index 000000000..ab2f9dc47 --- /dev/null +++ b/modules/zfs/cmd/zfs-tools/snapshots_prune.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" +) + +// The run's ZFS snapshots: removing them, only by their own record (design 42 zfs-owns-zfs §22). +// +// After taking, the run reads the ledger again in the same run (no read, no removal) and, per dataset and kind, +// removes the oldest past its keep count, all of them in that run. Before any destroy it checks that the name +// is `@mesh--`, that the snapshot carries `mesh:schedule=`, that +// it is in the ledger, that it has no hold and no clone; anything else is refused, said and left. Then `zfs +// destroy -- @`, the ledger's `removed`, and `snapshot.removed.schedule`. When `snapshots` +// goes back to `none` nothing is removed. A ZFS snapshot taken by hand is never removed. + +// HoldsArgv lists a snapshot's holds. +func HoldsArgv(snapshot string) []string { return []string{"holds", "-H", "--", snapshot} } + +// SnapshotPropertyArgv reads one property of a snapshot (mesh:schedule, clones). +func SnapshotPropertyArgv(snapshot, property string) []string { + return []string{"get", "-Hp", "-o", "value", property, "--", snapshot} +} + +// DestroyArgv destroys one snapshot, never a dataset: the name always holds its @. +func DestroyArgv(dataset, snapshot string) []string { + return []string{"destroy", "--", dataset + "@" + snapshot} +} + +// surplus are the entries of a dataset and kind past its keep count, oldest first. +func surplus(entries []LedgerEntry, keep int) []LedgerEntry { + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].At != entries[j].At { + return entries[i].At < entries[j].At + } + return entries[i].Name < entries[j].Name + }) + if len(entries) <= keep { + return nil + } + return entries[:len(entries)-keep] +} + +// refuseRemoval is the reason an entry may not be destroyed, "" when every check holds. +func (m *Machine) refuseRemoval(ctx context.Context, pool string, e LedgerEntry) (string, error) { + full := e.Dataset + "@" + e.Name + nm := meshSnapshot.FindStringSubmatch(e.Name) + switch { + case e.Dataset != pool && !strings.HasPrefix(e.Dataset, pool+"/"): + return "its dataset is not a dataset of the pool", nil + case nm == nil || nm[1] != e.Kind: + return "its name is not mesh-" + e.Kind + "-", nil + } + sched, err := m.ask(ctx, "zfs", SnapshotPropertyArgv(full, "mesh:schedule")...) + if err != nil { + return "", err + } + if strings.TrimSpace(sched) != e.Kind { + return fmt.Sprintf("it carries mesh:schedule=%q, not %q", strings.TrimSpace(sched), e.Kind), nil + } + holds, err := m.ask(ctx, "zfs", HoldsArgv(full)...) + if err != nil { + return "", err + } + if strings.TrimSpace(holds) != "" { + return "it has a hold", nil + } + clones, err := m.ask(ctx, "zfs", SnapshotPropertyArgv(full, "clones")...) + if err != nil { + return "", err + } + if c := strings.TrimSpace(clones); c != "" && c != "-" { + return "it has a clone", nil + } + return "", nil +} + +// PruneScheduled removes the scheduled ZFS snapshots past their keep counts (a person's act when person, by +// them). It answers what it did, and a failed command as the error. +func (m *Machine) PruneScheduled(ctx context.Context, c *Config, person bool, by string) ([]string, error) { + if c.Snapshots == nil || c.Snapshots.None || c.Pool == "" { + return nil, nil + } + removed, skipped := EventRemovedSched, EventSkippedSched + if person { + removed, skipped = EventRemovedPerson, EventSkippedPerson + } + if m.Paused() { + return []string{"snapshots paused: nothing removed"}, nil + } + entries, err := m.ledgerEntries() + if err != nil { + return []string{"nothing removed: the ledger cannot be read"}, nil + } + snaps, err := m.Snapshots(ctx) + if err != nil { + return nil, err + } + have := map[string]Snapshot{} + for _, s := range snaps { + have[s.Name] = s + } + groups := map[string][]LedgerEntry{} + for _, e := range entries { + k := e.Dataset + " " + e.Kind + groups[k] = append(groups[k], e) + } + var said []string + var failed []error + capacity := 0 + if ps, err := m.Pools(ctx); err == nil { + for _, p := range ps { + if p.Name == c.Pool { + capacity = p.Capacity + } + } + } + for _, k := range sortedKeys(groups) { + g := groups[k] + for _, e := range surplus(g, c.Snapshots.Of(g[0].Kind)) { + full := e.Dataset + "@" + e.Name + why, err := m.refuseRemoval(ctx, c.Pool, e) + if err != nil { + failed = append(failed, fmt.Errorf("%s: %v", full, err)) + continue + } + if why != "" { + var f2 []error + if err := m.say(skipped, map[string]any{"pool": c.Pool, "capacity": capacity, "reason": "refused to remove " + full + ": " + why}); err != nil { + m.failure(&f2, skipped, err) + } + said = append(said, "left "+full+": "+why) + continue + } + freed := have[full].Used + if _, there := have[full]; there { + if _, err := m.AsRoot(ctx, "zfs", DestroyArgv(e.Dataset, e.Name)...); err != nil { + failed = append(failed, fmt.Errorf("%s: %v", full, err)) + continue + } + } + if err := m.recordLedger("removed", e); err != nil { + failed = append(failed, fmt.Errorf("%s was removed, and the ledger did not take it: %v", full, err)) + continue + } + if err := m.say(removed, map[string]any{"snapshot": full, "kind": e.Kind, "space freed in bytes": freed, "by": by}); err != nil { + m.failure(&failed, removed, err) + } + said = append(said, fmt.Sprintf("removed %s (%s freed)", full, strconv.FormatUint(freed, 10))) + } + } + return said, joinErrors(failed) +} diff --git a/modules/zfs/cmd/zfs-tools/snapshots_prune_test.go b/modules/zfs/cmd/zfs-tools/snapshots_prune_test.go new file mode 100644 index 000000000..d1ef491a7 --- /dev/null +++ b/modules/zfs/cmd/zfs-tools/snapshots_prune_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" +) + +// §22, §31: pruning removes every surplus of a dataset and kind in one run, oldest first, only by the +// ledger's record, and never a snapshot whose name, property or ledger entry does not fit, nor one with a hold +// or a clone; `none` and a pause remove nothing; a hand-made snapshot is never touched. +func TestPruningRemovesEverySurplusByItsRecordAndNothingThatDoesNotFit(t *testing.T) { + name := func(h int) string { return fmt.Sprintf("mesh-hourly-20261009T%02d00Z", h) } + at := func(h int) string { return fmt.Sprintf("2026-10-09T%02d:00:00Z", h) } + entry := func(ds string, h int) LedgerEntry { return LedgerEntry{ds, name(h), "hourly", at(h)} } + four := []LedgerEntry{entry("tank", 1), entry("tank", 2), entry("tank", 3), entry("tank", 4)} + cases := []struct { + name string + keeps string + l *ledgerCalls + prop map[string]string // by snapshot: its mesh:schedule + hold string // the snapshot with a hold + clone string // the snapshot with a clone + gone string // the snapshot ZFS no longer has + setup func(st memStates) + removed []string + left int + wantErr bool + }{ + {name: "every surplus in one run, oldest first", keeps: `{"hourly":2}`, l: &ledgerCalls{entries: four}, + removed: []string{"tank@" + name(1), "tank@" + name(2)}}, + {name: "within the keep count", keeps: `{"hourly":4}`, l: &ledgerCalls{entries: four}}, + {name: "a hold is left", keeps: `{"hourly":2}`, l: &ledgerCalls{entries: four}, hold: "tank@" + name(1), + removed: []string{"tank@" + name(2)}, left: 1}, + {name: "a clone is left", keeps: `{"hourly":2}`, l: &ledgerCalls{entries: four}, clone: "tank@" + name(2), + removed: []string{"tank@" + name(1)}, left: 1}, + {name: "a property that does not fit is left", keeps: `{"hourly":2}`, l: &ledgerCalls{entries: four}, + prop: map[string]string{"tank@" + name(1): "daily"}, removed: []string{"tank@" + name(2)}, left: 1}, + {name: "a dataset of another pool is left", keeps: `{"hourly":1}`, l: &ledgerCalls{entries: []LedgerEntry{entry("other", 1), entry("other", 2)}}, left: 1}, + {name: "a name that does not fit is left", keeps: `{"hourly":1}`, l: &ledgerCalls{entries: []LedgerEntry{ + {"tank", "before-move", "hourly", at(1)}, entry("tank", 2)}}, left: 1}, + {name: "none removes nothing", keeps: `{"none":true}`, l: &ledgerCalls{entries: four}}, + {name: "paused removes nothing", keeps: `{"hourly":1}`, l: &ledgerCalls{entries: four}, + setup: func(st memStates) { st.Put(StateSchedule, KeyPaused, map[string]string{}) }}, + {name: "no read, no removal", keeps: `{"hourly":1}`, l: &ledgerCalls{read: errors.New("no bus")}}, + {name: "a snapshot ZFS no longer has is only recorded removed", keeps: `{"hourly":3}`, l: &ledgerCalls{entries: four}, + gone: "tank@" + name(1)}, + {name: "a failed destroy is the run's failure", keeps: `{"hourly":3}`, l: &ledgerCalls{entries: four}, wantErr: true}, + } + for _, c := range cases { + f, m, st := snapFake(t, c.keeps, c.l) + if c.setup != nil { + c.setup(st) + } + var list strings.Builder + for _, e := range c.l.entries { + full := e.Dataset + "@" + e.Name + if full != c.gone { + fmt.Fprintf(&list, "%s\t1791400000\t1000\t2000\n", full) + } + p := c.prop[full] + if p == "" { + p = e.Kind + } + f.on("zfs get -Hp -o value mesh:schedule -- "+full, p+"\n") + h := "" + if full == c.hold { + h = full + "\tkeep\tFri Oct 9 12:00 2026\n" + } + f.on("zfs holds -H -- "+full, h) + cl := "-" + if full == c.clone { + cl = "tank/clone" + } + f.on("zfs get -Hp -o value clones -- "+full, cl+"\n") + if !c.wantErr { + f.on("zfs destroy -- "+full, "") + } + } + f.on(line("zfs", SnapshotsArgv()), list.String()+"tank@by-hand\t1791400000\t1\t2\n") + said, err := m.PruneScheduled(context.Background(), keepsOfJSON(t, c.keeps), false, "") + if (err != nil) != c.wantErr { + t.Errorf("%s: error %v", c.name, err) + } + var destroyed []string + for _, d := range ran(f, "zfs destroy") { + destroyed = append(destroyed, strings.TrimPrefix(d, "zfs destroy -- ")) + } + if c.gone != "" { + if len(destroyed) != 0 || len(c.l.records) != 1 || c.l.records[0]["operation"] != "removed" { + t.Errorf("%s: destroyed %v, records %v", c.name, destroyed, c.l.records) + } + continue + } + if strings.Join(destroyed, ",") != strings.Join(c.removed, ",") && !c.wantErr { + t.Errorf("%s: destroyed %v, want %v (%v)", c.name, destroyed, c.removed, said) + } + if !c.wantErr && len(c.l.records) != len(c.removed) { + t.Errorf("%s: %d records for %d removals", c.name, len(c.l.records), len(c.removed)) + } + skips := 0 + for _, e := range f.emitted { + if e["_type"] == EventSkippedSched { + skips++ + } + if e["_type"] == EventRemovedSched && e["space freed in bytes"] != uint64(1000) { + t.Errorf("%s: freed %v", c.name, e) + } + } + if skips != c.left || len(f.emitted)-skips != len(c.removed) { + t.Errorf("%s: events %v, left %d", c.name, f.emitted, c.left) + } + for _, d := range destroyed { + if !strings.Contains(d, "@mesh-") || strings.Contains(d, "by-hand") { + t.Errorf("%s: destroyed %s", c.name, d) + } + } + } +}