Z11 Z12 Z15 the ledger is one instance per dataset and kind, its state a map taken keyed by the ZFS snapshot's name

Builds: design 42 zfs-owns-zfs §21, §22, §26, §28, A-36 (corrects A-30)
This commit is contained in:
2026-11-13 01:03:17 +01:00
parent 9116067e11
commit c6844fc951
8 changed files with 85 additions and 46 deletions
+33 -21
View File
@@ -136,30 +136,42 @@ func (busLedger) Apply(instance, operation string, body any, call string) error
return err
}
func (busLedger) Entries() ([]LedgerEntry, error) {
raw, err := stdio.BusState(buscalls.LedgerQuery, buscalls.QueryRequest{Ledger: LedgerName, Index: "by-dataset-kind"})
if err != nil {
return nil, err
}
var q buscalls.QueryAnswer
if err := json.Unmarshal(raw, &q); err != nil {
return nil, err
// absentInstance says whether a ledger get said the instance is not there, which an instance never written is.
func absentInstance(err error) bool {
s := strings.ToLower(err.Error())
for _, w := range []string{"not found", "no instance", "no such", "does not exist", "unknown instance", "not kept"} {
if strings.Contains(s, w) {
return true
}
}
return false
}
func (busLedger) Entries(datasets []string) ([]LedgerEntry, error) {
var out []LedgerEntry
for _, ie := range q.Entries {
raw, err := stdio.BusState(buscalls.LedgerGet, buscalls.InstanceRequest{Ledger: LedgerName, Instance: ie.Instance})
if err != nil {
return nil, err
for _, ds := range datasets {
for _, kind := range keepKinds {
raw, err := stdio.BusState(buscalls.LedgerGet, buscalls.InstanceRequest{Ledger: LedgerName, Instance: LedgerInstance(kind, ds)})
if err != nil {
if absentInstance(err) {
continue
}
return nil, err
}
var st buscalls.InstanceState
if err := json.Unmarshal(raw, &st); err != nil {
return nil, err
}
var body struct {
Taken map[string]LedgerEntry `json:"taken"`
}
if err := json.Unmarshal(st.State, &body); err != nil {
return nil, err
}
for _, name := range sortedKeys(body.Taken) {
out = append(out, body.Taken[name])
}
}
var st buscalls.InstanceState
if err := json.Unmarshal(raw, &st); err != nil {
return nil, err
}
var e LedgerEntry
if err := json.Unmarshal(st.State, &e); err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}
+1 -1
View File
@@ -211,7 +211,7 @@ func (m *Machine) Health(ctx context.Context) Health {
}
sched, serr := m.ScheduleNow(ctx)
return m.Errors.Judge(Look{Pools: pools, Status: status, Now: m.Now(), Schedule: sched, ScheduleErr: serr,
Served: m.readServed(ServedFile), Module: m.moduleLook()})
Served: m.readServed(ServedFile), Module: m.moduleLook(ctx)})
}
func sentence(s string) string {
+3 -2
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
@@ -34,7 +35,7 @@ var snapshotLate = map[string]time.Duration{"hourly": 2 * time.Hour, "daily": 36
// moduleLook reads what the module itself keeps. It answers nil when the config file is not readable or not valid:
// the module is then judged on its pools alone, and the refusal is already in the state `applied`.
func (m *Machine) moduleLook() *ModuleLook {
func (m *Machine) moduleLook(ctx context.Context) *ModuleLook {
text, err := m.ReadFile(ConfigFile)
if err != nil {
return nil
@@ -58,7 +59,7 @@ func (m *Machine) moduleLook() *ModuleLook {
}
l.Paused = m.Paused()
if m.Ledger != nil {
if es, err := m.Ledger.Entries(); err == nil {
if es, err := m.ledgerAll(ctx); err == nil {
l.Ledger, l.LedgerKnown = es, true
}
}
+10 -8
View File
@@ -47,10 +47,10 @@ type LedgerEntry struct {
At string `json:"at"`
}
// LedgerInstance is the instance path of a scheduled snapshot: `snapshot/<kind>-<UTC stamp>-<dataset>`, the
// dataset's `/` written `~`.
func LedgerInstance(kind, stamp, dataset string) string {
return "snapshot/" + kind + "-" + stamp + "-" + strings.ReplaceAll(dataset, "/", "~")
// LedgerInstance is the instance path of a dataset's scheduled snapshots of one kind: `snapshot/<kind>-<dataset>`, the
// dataset's `/` written `~` (A-36). Its state is the map `taken`, keyed by the ZFS snapshot's name.
func LedgerInstance(kind, dataset string) string {
return "snapshot/" + kind + "-" + strings.ReplaceAll(dataset, "/", "~")
}
// InstanceOf is the instance of a record's snapshot, refusing a name that is not a scheduled snapshot's.
@@ -62,7 +62,7 @@ func InstanceOf(dataset, name string) (string, error) {
if dataset == "" || strings.ContainsAny(dataset, "@~ ") {
return "", fmt.Errorf("%q is not a dataset's name", dataset)
}
return LedgerInstance(m[1], m[2], dataset), nil
return LedgerInstance(m[1], dataset), nil
}
// ledgerEntries reads the ledger through the module's tool zfs_snapshots_schedule.
@@ -252,8 +252,8 @@ const LedgerName = "snapshots"
type LedgerStore interface {
// Apply applies an operation to an instance; call is the id of the tool call it serves.
Apply(instance, operation string, body any, call string) error
// Entries are every instance's state.
Entries() ([]LedgerEntry, error)
// Entries are the entries of the `taken` map of every kind's instance of each dataset; a dataset with none has none.
Entries(datasets []string) ([]LedgerEntry, error)
}
// Record applies the run's `taken` or `removed` to the ledger, refusing a snapshot not named mesh- (A-20).
@@ -269,7 +269,9 @@ func (m *Machine) Record(op string, e LedgerEntry, call string) error {
return fmt.Errorf("this process reaches no ledger")
}
if op == "removed" {
return m.Ledger.Apply(instance, op, struct{}{}, call)
return m.Ledger.Apply(instance, op, struct {
Name string `json:"name"`
}{e.Name}, call)
}
return m.Ledger.Apply(instance, op, e, call)
}
+6 -4
View File
@@ -131,23 +131,25 @@ func TestADueKindTakesOneSnapshotPerFilesystemAndRecordsEach(t *testing.T) {
}
// A-20: the tool zfs_snapshots_record refuses a snapshot not named mesh- and one of another operation.
type fakeLedger struct{ applied []string }
type fakeLedger struct{ applied, bodies []string }
func (l *fakeLedger) Apply(instance, op string, body any, call string) error {
l.applied = append(l.applied, op+" "+instance)
b, _ := json.Marshal(body)
l.bodies = append(l.bodies, string(b))
return nil
}
func (l *fakeLedger) Entries() ([]LedgerEntry, error) { return nil, nil }
func (l *fakeLedger) Entries([]string) ([]LedgerEntry, error) { return nil, nil }
func TestTheLedgerTakesOnlyTheRunsSnapshots(t *testing.T) {
l := &fakeLedger{}
m := newFake(t).machine()
m.Ledger = l
e := LedgerEntry{Dataset: "tank/media", Name: "mesh-hourly-20261009T1000Z", Kind: "hourly", At: "2026-10-09T10:00:00Z"}
if err := m.Record("taken", e, "id"); err != nil || len(l.applied) != 1 || l.applied[0] != "taken snapshot/hourly-20261009T1000Z-tank~media" {
if err := m.Record("taken", e, "id"); err != nil || len(l.applied) != 1 || l.applied[0] != "taken snapshot/hourly-tank~media" {
t.Fatalf("%v %v", err, l.applied)
}
if err := m.Record("removed", e, "id"); err != nil || l.applied[1] != "removed snapshot/hourly-20261009T1000Z-tank~media" {
if err := m.Record("removed", e, "id"); err != nil || l.applied[1] != "removed snapshot/hourly-tank~media" || l.bodies[1] != `{"name":"mesh-hourly-20261009T1000Z"}` || !strings.Contains(l.bodies[0], `"name":"mesh-hourly-20261009T1000Z"`) {
t.Fatal(err, l.applied)
}
for _, bad := range []LedgerEntry{{Dataset: "tank", Name: "before-move"}, {Dataset: "tank", Name: "mesh-yearly-20261009T1000Z"},
+1 -1
View File
@@ -114,7 +114,7 @@ func Tools(m *Machine) []stdio.Tool {
readTool("zfs_snapshots_schedule", "The schedule of the scheduled ZFS snapshots: the keep counts, when each kind next falls due, whether it is paused, "+
"the entries past their keep count the next run would remove, and the ledger `{\"ledger\": [...]}` of every scheduled ZFS snapshot the run took "+
"and did not remove. The scheduled run reads the ledger from here. Changes nothing. (r)", nil,
func(map[string]any) (any, error) { return m.SnapshotSchedule() }),
func(map[string]any) (any, error) { return m.SnapshotSchedule(ctx) }),
readTool("zfs_module_events", "The module's own events, newest first, from its events stream (config.applied, config.refused, config.waiting, "+
"snapshot.taken.person and .schedule, snapshot.removed.person and .schedule, snapshot.skipped.person and .schedule, run.skipped, scrub.requested), "+
"the last N of one kind or of all, with how many of each kind. Not ZFS's own events: zfs_events reads those. Changes nothing. (r)",
+24 -3
View File
@@ -256,12 +256,12 @@ func wouldRemove(entries []LedgerEntry, k *Keeps) []LedgerEntry {
// SnapshotSchedule shows the schedule of the scheduled ZFS snapshots, its next run, what the next run would remove and
// the ledger. The ledger is shown even when the config says nothing of it: the scheduled run reads it from here.
func (m *Machine) SnapshotSchedule() (ScheduleAnswer, error) {
func (m *Machine) SnapshotSchedule(ctx context.Context) (ScheduleAnswer, error) {
a := ScheduleAnswer{Keeps: map[string]int{}, Next: map[string]string{}, WouldRemove: []LedgerEntry{}, Ledger: []LedgerEntry{}, Said: []string{}}
if m.Ledger == nil {
return a, fmt.Errorf("this process reaches no ledger")
}
entries, err := m.Ledger.Entries()
entries, err := m.ledgerAll(ctx)
if err != nil {
return a, fmt.Errorf("the ledger %s could not be read: %v", LedgerName, err)
}
@@ -379,7 +379,7 @@ func (m *Machine) CheckSnapshots(ctx context.Context) (CheckAnswer, error) {
if m.Ledger == nil {
return a, fmt.Errorf("this process reaches no ledger")
}
entries, err := m.Ledger.Entries()
entries, err := m.ledgerAll(ctx)
if err != nil {
return a, fmt.Errorf("the ledger %s could not be read: %v", LedgerName, err)
}
@@ -445,3 +445,24 @@ func (m *Machine) FindingsNow() (FindingsAnswer, error) {
}
return a, nil
}
// ledgerAll reads the ledger's entries for every filesystem of the config's pool (A-36: the ledger is one instance per
// dataset and kind, so the datasets name what to read). No pool set, no entries.
func (m *Machine) ledgerAll(ctx context.Context) ([]LedgerEntry, error) {
text, err := m.ReadFile(ConfigFile)
if err != nil {
return nil, fmt.Errorf("%s could not be read: %v", ConfigFile, err)
}
var c struct {
Pool string `json:"pool"`
}
_ = json.Unmarshal(text, &c)
if c.Pool == "" {
return nil, nil
}
fs, _, err := m.filesystems(ctx, c.Pool)
if err != nil {
return nil, err
}
return m.Ledger.Entries(fs)
}
@@ -16,7 +16,7 @@ type staticLedger struct {
}
func (l staticLedger) Apply(string, string, any, string) error { return nil }
func (l staticLedger) Entries() ([]LedgerEntry, error) { return l.entries, l.err }
func (l staticLedger) Entries([]string) ([]LedgerEntry, error) { return l.entries, l.err }
type staticLog struct {
evs []LoggedEvent
@@ -124,11 +124,11 @@ func TestSnapshotsScheduleAnswersTheLedgerTheRunReads(t *testing.T) {
{name: "paused", conf: `{"pool":"tank","snapshots":{"hourly":1}}`, paused: true, said: "paused"},
{name: "none keeps what exists", conf: `{"pool":"tank","snapshots":{"none":true}}`, l: staticLedger{entries: []LedgerEntry{e(1)}}, said: "are kept"},
{name: "not managed", conf: `{"pool":"tank"}`, said: "not managed"},
{name: "refused config still answers the ledger", conf: `{"snapshots":{"hourly":999}}`, l: staticLedger{entries: []LedgerEntry{e(1)}}, said: "refused"},
{name: "ledger unreadable", conf: `{}`, l: staticLedger{err: errors.New("no bus")}, wantErr: true},
{name: "refused config still answers the ledger", conf: `{"pool":"tank","snapshots":{"hourly":999}}`, l: staticLedger{entries: []LedgerEntry{e(1)}}, said: "refused"},
{name: "ledger unreadable", conf: `{"pool":"tank"}`, l: staticLedger{err: errors.New("no bus")}, wantErr: true},
}
for _, c := range cases {
f := newFake(t)
f := withZFS(t)
f.now = time.Date(2026, 10, 9, 12, 30, 0, 0, time.UTC) // a Friday
m := f.machine()
st := memStates{}
@@ -137,7 +137,7 @@ func TestSnapshotsScheduleAnswersTheLedgerTheRunReads(t *testing.T) {
if c.paused {
st.Put(StateSchedule, KeyPaused, map[string]string{"by": "x", "why": "y"})
}
a, err := m.SnapshotSchedule()
a, err := m.SnapshotSchedule(context.Background())
if (err != nil) != c.wantErr {
t.Errorf("%s: %v", c.name, err)
continue
@@ -195,9 +195,10 @@ func TestModuleEventsAreTheStreamsLastNNewestFirstByKind(t *testing.T) {
// §28: zfs_snapshots_check names each ledger entry whose ZFS snapshot is gone and each mesh- snapshot the ledger
// does not hold, and changes nothing.
func TestSnapshotsCheckNamesWhatTheLedgerAndZFSDoNotShare(t *testing.T) {
f := newFake(t)
f := withZFS(t)
f.on(line("zfs", SnapshotsArgv()), "tank@mesh-hourly-20261009T1000Z\t1791400000\t1\t2\ntank@mesh-hourly-20261009T1100Z\t1791400000\t1\t2\ntank@by-hand\t1791400000\t1\t2\n")
f.uid = 0
f.files[ConfigFile] = `{"pool":"tank"}`
m := f.machine()
m.Ledger = staticLedger{entries: []LedgerEntry{{"tank", "mesh-hourly-20261009T1000Z", "hourly", "2026-10-09T10:00:00Z"},
{"tank", "mesh-hourly-20261009T0900Z", "hourly", "2026-10-09T09:00:00Z"}}}