Z12 zfs-snapshots-remove: the run removes surplus scheduled ZFS snapshots only by their own record

Builds: design 42 zfs-owns-zfs §22, §26, A-30, A-31
This commit is contained in:
2026-11-13 00:44:07 +01:00
parent a357a85faa
commit 23256413de
4 changed files with 290 additions and 1 deletions
+2 -1
View File
@@ -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 {
+11
View File
@@ -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")
}
@@ -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 `<a dataset of the pool>@mesh-<kind>-<UTC stamp>`, that the snapshot carries `mesh:schedule=<kind>`, that
// it is in the ledger, that it has no hold and no clone; anything else is refused, said and left. Then `zfs
// destroy -- <dataset>@<snapshot>`, 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 + "-<UTC stamp>", 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)
}
@@ -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)
}
}
}
}