nfs-server: learn the backup holder's data paths from disk-load's readings, not the controller's data (issue 1955, ADR 0571 clause 3)

This commit is contained in:
2026-10-29 23:46:56 +01:00
parent 44a3f24456
commit 4ddf702145
8 changed files with 71 additions and 49 deletions
+15 -25
View File
@@ -14,13 +14,12 @@ package main
// been read, what is exported stays exported and the status says the list is not known: the bus being
// slow is no reason to take the library away, and the look below still guards against a repository.
//
// The parsing of mesh-controller.data's answer is a small copy of the mounts module's (DataFrom). Shared
// code belongs in mesh-sdk: moving both there is mesh-sdk issue 13.
import (
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"os"
"path"
"sort"
@@ -48,10 +47,10 @@ type BackupFromBus struct {
}
// BackupFrom reads the holder of node-backup on one machine from the placement seat's state of who holds which seat
// (novox/hq ADR 0571 clause 4), and the paths that module keeps data in on that machine from mesh-controller.data's
// answer. A machine whose backup seat nobody holds, and an answer with no data list, are errors: never an empty list
// read as "nothing to fear".
func BackupFrom(machine string, keys map[string][]byte, data json.RawMessage) (string, []string, error) {
// (novox/hq ADR 0571 clause 4), and the paths that module keeps data in on that machine from disk-load's readings
// (clause 3). A machine whose backup seat nobody holds, and no readings at all, are errors: never an empty list read as
// "nothing to fear".
func BackupFrom(machine string, keys map[string][]byte, readings []health.Reading) (string, []string, error) {
holders, err := delivery.HoldersOf(keys, BackupSeat, machine)
if err != nil {
return "", nil, err
@@ -65,36 +64,27 @@ func BackupFrom(machine string, keys map[string][]byte, data json.RawMessage) (s
if holder == "" {
return "", nil, fmt.Errorf("no module holds %s on %s", BackupSeat, machine)
}
var d struct {
Answer *struct {
Data *[]struct {
Machine string `json:"machine"`
Module string `json:"module"`
Path string `json:"path"`
} `json:"data"`
} `json:"answer"`
}
if err := json.Unmarshal(data, &d); err != nil || d.Answer == nil || d.Answer.Data == nil {
return "", nil, fmt.Errorf("the controller's data answer holds no data list (%v)", err)
if len(readings) == 0 {
return "", nil, fmt.Errorf("disk-load keeps no reading of this machine's data yet")
}
var paths []string
for _, item := range *d.Answer.Data {
if item.Module == holder && (item.Machine == "" || item.Machine == machine) && path.IsAbs(item.Path) {
paths = append(paths, path.Clean(item.Path))
for _, r := range readings {
if r.Module == holder && (r.Node == "" || r.Node == machine) && path.IsAbs(r.Path) {
paths = append(paths, path.Clean(r.Path))
}
}
sort.Strings(paths)
return holder, paths, nil
}
// TakeBackup asks the controller over the bus, as the module, through the runtime.
// TakeBackup reads the bus, as the module, through the runtime.
func (m *Machine) TakeBackup(machine string) {
m.TakeBackupWith(machine, askBus, seatHolderKeys)
m.TakeBackupWith(machine, readingsOf, seatHolderKeys)
}
// TakeBackupWith is TakeBackup with the ask and the reading of the seat-holders state given, for tests. A failed ask keeps the last holder and
// paths and adds why.
func (m *Machine) TakeBackupWith(machine string, ask func(key string, body any) (json.RawMessage, error),
func (m *Machine) TakeBackupWith(machine string, readings func(node string) ([]health.Reading, error),
holderKeys func() (map[string][]byte, error)) {
var last BackupFromBus
if text, err := m.Read(BackupFile); err == nil {
@@ -104,9 +94,9 @@ func (m *Machine) TakeBackupWith(machine string, ask func(key string, body any)
next.Looked = m.Now().UTC().Format(time.RFC3339)
next.Error = ""
seats, err := holderKeys()
var data json.RawMessage
var data []health.Reading
if err == nil {
data, err = ask("seat:mesh-controller.data", map[string]any{"machine": machine})
data, err = readings(machine)
}
if err == nil {
var holder string
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"errors"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
"strings"
"testing"
"time"
@@ -82,7 +84,7 @@ func TestWithoutTheBackupListTheExportsStayAndItIsSaid(t *testing.T) {
func TestBackupFromTheSeatsAndTheData(t *testing.T) {
seats := backupHolders("ace", "restic")
data := json.RawMessage(`{"answer":{"data":[{"machine":"ace","module":"restic","item":"repository","path":"/storage/restic"},{"machine":"ace","module":"restic","item":"state","path":"/var/lib/restic"},{"machine":"ace","module":"plex","item":"data","path":"/mnt/plex/data"}]},"ok":true}`)
data := readingsOfPaths("ace", map[string]string{"restic/repository": "/storage/restic", "restic/state": "/var/lib/restic", "plex/data": "/mnt/plex/data"})
holder, paths, err := BackupFrom("ace", seats, data)
if err != nil || holder != "restic" || strings.Join(paths, " ") != "/storage/restic /var/lib/restic" {
t.Errorf("got %q %v %v", holder, paths, err)
@@ -90,8 +92,8 @@ func TestBackupFromTheSeatsAndTheData(t *testing.T) {
if _, _, err := BackupFrom("g14", seats, data); err == nil {
t.Errorf("a machine whose backup seat nobody holds is not said")
}
if _, _, err := BackupFrom("ace", seats, json.RawMessage(`{"ok":true}`)); err == nil {
t.Errorf("an answer with no data list reads as an empty list")
if _, _, err := BackupFrom("ace", seats, nil); err == nil {
t.Errorf("no readings read as an empty list")
}
}
@@ -99,11 +101,11 @@ func TestBackupFromTheSeatsAndTheData(t *testing.T) {
func TestAFailedAskKeepsTheLastBackupPaths(t *testing.T) {
f := newFake(t)
m := f.machine()
m.TakeBackupWith("ace", func(string, any) (json.RawMessage, error) {
m.TakeBackupWith("ace", func(string) ([]health.Reading, error) {
return nil, errors.New("no responders")
}, noHolders)
f.backup("restic", "/storage/restic")
m.TakeBackupWith("ace", func(string, any) (json.RawMessage, error) {
m.TakeBackupWith("ace", func(string) ([]health.Reading, error) {
return nil, errors.New("timeout")
}, func() (map[string][]byte, error) { return backupHolders("ace", "restic"), nil })
var b BackupFromBus
@@ -152,12 +154,12 @@ func TestAStaleOrUnknownBackupListIsSaidWithItsAge(t *testing.T) {
t.Errorf("an unknown backup list is not a warning in the health: %+v", h)
}
ok := func(key string, body any) (json.RawMessage, error) {
return json.RawMessage(`{"answer":{"data":[{"machine":"hs","module":"restic","path":"/srv/restic"}]}}`), nil
ok := func(string) ([]health.Reading, error) {
return readingsOfPaths("hs", map[string]string{"restic/repository": "/srv/restic"}), nil
}
m.TakeBackupWith("hs", ok, func() (map[string][]byte, error) { return backupHolders("hs", "restic"), nil })
now = now.Add(2 * time.Hour)
m.TakeBackupWith("hs", func(string, any) (json.RawMessage, error) { return nil, errors.New("the bus did not answer") },
m.TakeBackupWith("hs", func(string) ([]health.Reading, error) { return nil, errors.New("the bus did not answer") },
func() (map[string][]byte, error) { return backupHolders("hs", "restic"), nil })
s := m.Pass(context.Background(), nil)
said := strings.Join(append(append([]string{}, s.Said...), s.Warnings...), "\n")
@@ -177,3 +179,13 @@ func backupHolders(node, module string) map[string][]byte {
}
func noHolders() (map[string][]byte, error) { return nil, errors.New("the state is not read yet") }
// readingsOfPaths is disk-load's readings of one node's items, by "<module>/<item>", each at its path.
func readingsOfPaths(node string, items map[string]string) []health.Reading {
var out []health.Reading
for k, p := range items {
module, item, _ := strings.Cut(k, "/")
out = append(out, health.Reading{V: 1, Node: node, PlacedDataItem: catalogue.PlacedDataItem{Module: module, ID: item, Path: p}})
}
return out
}
@@ -4,7 +4,7 @@ package main
import (
"context"
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/health"
"strings"
"testing"
)
@@ -63,12 +63,10 @@ func TestAFailedReloadIsTriedAgainWhenNothingElseAsks(t *testing.T) {
}
}
func TestBackupFromRefusesAnAnswerWithoutADataList(t *testing.T) {
func TestBackupFromRefusesNoReadingsAtAll(t *testing.T) {
seats := backupHolders("ace", "restic")
for _, data := range []string{`{"answer":{}}`, `{"answer":{"data":null}}`} {
if _, paths, err := BackupFrom("ace", seats, json.RawMessage(data)); err == nil {
t.Errorf("%s was read as the empty list %v: never \"nothing to fear\"", data, paths)
}
if _, paths, err := BackupFrom("ace", seats, []health.Reading{}); err == nil {
t.Errorf("no readings were read as the empty list %v: never \"nothing to fear\"", paths)
}
}
+26 -3
View File
@@ -16,11 +16,13 @@ import (
"os"
"os/signal"
"sort"
"strings"
"syscall"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
)
// ExportsState is the state the exports are kept in on the bus; MountsState the `mounts` modules' state,
@@ -89,9 +91,30 @@ func ShareExports(m *Machine) {
}
}
// askBus calls a seat's verb over the bus as this module, through the runtime.
func askBus(key string, body any) (json.RawMessage, error) {
return stdio.Ask(key, body)
// readingsOf reads disk-load's readings of one machine's data items, every key `<node>.<module>.<item>`.
func readingsOf(node string) ([]health.Reading, error) {
state := stdio.State(health.ReadingsRead)
names, err := state.Keys()
if err != nil {
return nil, err
}
var out []health.Reading
for _, k := range names {
if !strings.HasPrefix(k, node+".") {
continue
}
e, err := state.Get(k)
if err != nil {
return nil, err
}
if e == nil {
continue
}
if r, err := health.DecodeReading(e.Value); err == nil {
out = append(out, r)
}
}
return out, nil
}
// seatHolderKeys reads the placement seat's state of who holds which seat, every key.
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"os"
"regexp"
"sort"
@@ -138,7 +139,7 @@ func TestTheManifestCarriesGrantsTheRosterTheWantsAndTheRemovalOrder(t *testing.
if err := json.Unmarshal([]byte(raw), &full); err != nil {
t.Fatal(err)
}
if len(full.Reads) != 2 || full.Reads[0] != delivery.SeatHoldersRead || full.Reads[1] != MountsState {
if len(full.Reads) != 3 || full.Reads[0] != health.ReadingsRead || full.Reads[1] != delivery.SeatHoldersRead || full.Reads[2] != MountsState {
t.Errorf("reads: %v", full.Reads)
}
roster := full.Facts["machines"]
+1 -1
View File
@@ -2,4 +2,4 @@ module nfsserver
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220947-f9a496d3e121
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029221656-196c5b6af277
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220947-f9a496d3e121 h1:OsA1v6n736a7RqEV8Nkip8pf5ILMbzj0PWuKqcBOhK0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220947-f9a496d3e121/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029221656-196c5b6af277 h1:ajjsH4MWcx45MGYASjUSRQKhLzEringuk3TA0oxZRCU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029221656-196c5b6af277/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+1 -3
View File
@@ -48,12 +48,10 @@
}
],
"reads": [
"disk-load.readings",
"mesh-delivery-assigner.seat-holders",
"mounts.shares"
],
"invokes": [
"seat:mesh-controller.data"
],
"config": {
"shares": {
"type": "string",