The node agent waits for a backup's end on the holder's event, not by polling; the manifest consumes it (R6-A A5)

This commit is contained in:
2026-10-24 17:03:30 +02:00
parent 20d0998cdd
commit e5a06cc73d
6 changed files with 158 additions and 53 deletions
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
func backupEvent(node, module string, ok bool, at time.Time, why string) []byte {
raw, _ := json.Marshal(map[string]any{"node": node, "module": module, "ok": ok, "at": at, "error": why})
return raw
}
// A backup is done when the holder says so for this node and module, held on its words when it says it failed, and held
// when it says nothing within the bound; another node's or module's event, or one from before the ask, is not the answer
// (R6-A A5, novox/hq ADR 0429 §14).
func TestABackupEndsWhenItsEventSaysSo(t *testing.T) {
since := time.Now()
run := func(within time.Duration, events ...[]byte) (done bool, words string) {
ch := make(chan []byte, len(events))
for _, e := range events {
ch <- e
}
got := awaitBackup(context.Background(), ch, "n1", "db", since, within)
return got.Done, got.Words
}
if done, _ := run(time.Second,
backupEvent("n2", "db", true, since, ""), backupEvent("n1", "web", true, since, ""),
backupEvent("n1", "db", false, since.Add(-time.Hour), "an old night"),
backupEvent("n1", "db", true, since.Add(time.Second), "")); !done {
t.Error("the event of this node and module was not the answer")
}
if done, words := run(time.Second, backupEvent("n1", "db", false, since, "the repository is locked")); done ||
!strings.Contains(words, "repository is locked") {
t.Errorf("a failed backup: done=%v words=%q", done, words)
}
if done, words := run(50 * time.Millisecond); done || !strings.Contains(words, "did not say it was done") {
t.Errorf("silence within the bound: done=%v words=%q", done, words)
}
}
+45 -36
View File
@@ -89,52 +89,61 @@ func shorten(s string) string {
return s[:most-1] + "…"
}
// backupWithin is how long a backup is given from its ask to say it is done (ADR 0429 §14), and backupPollEvery how
// soon the holder is asked again how it went while it works.
var (
backupWithin = 10 * time.Minute
backupPollEvery = 5 * time.Second
)
// backupWithin is how long a backup is given from its ask to say it is done (ADR 0429 §14).
var backupWithin = 10 * time.Minute
// backupNode asks the holder of node-backup on this node to back a module up now, and waits for it to say how it went:
// the holder answers when it has started, and its `backed-up` says the module's last night.
// backedUpEvent is where the holder of node-backup says a backup taken by hand ended, also one that could not start.
const backedUpEvent = "mesh.mod.restic.event.backed-up"
// backupNode asks the holder of node-backup on this node to back a module up now, and waits for its event to say how it
// went: the holder answers `now` when it has started, and says `backed-up` when the backup ends.
func backupNode(session *link.Session) func(ctx context.Context, module string) agent.Backup {
return func(ctx context.Context, module string) agent.Backup {
// Heard before the ask, so an event that comes at once is not lost.
events, stop, err := session.Hear(backedUpEvent)
if err != nil {
return agent.Backup{Words: "the end of the backup cannot be heard: " + err.Error()}
}
defer stop()
asked := time.Now()
if _, err := session.AskSeat(ctx, "node-backup", "now", map[string]any{"module": module}, 30*time.Second); err != nil {
return agent.Backup{Words: err.Error()}
}
deadline := asked.Add(backupWithin)
for {
select {
case <-ctx.Done():
return agent.Backup{Words: "the node agent was stopped while the backup ran"}
case <-time.After(backupPollEvery):
return awaitBackup(ctx, events, session.Membership.Node, module, asked, backupWithin)
}
}
// awaitBackup waits for the event that says the backup of a module on this node ended, within the bound: done when it
// says ok, held on its words when it does not, and held when it says nothing within the bound. An event of another node
// or module, or from before the ask, is not the answer.
func awaitBackup(ctx context.Context, events <-chan []byte, node, module string, since time.Time, within time.Duration) agent.Backup {
bound := time.NewTimer(within)
defer bound.Stop()
for {
select {
case <-ctx.Done():
return agent.Backup{Words: "the node agent was stopped while the backup ran"}
case <-bound.C:
return agent.Backup{Words: fmt.Sprintf("the backup did not say it was done within %s", within)}
case raw := <-events:
var e struct {
Node string `json:"node"`
Module string `json:"module"`
OK bool `json:"ok"`
At time.Time `json:"at"`
Error string `json:"error"`
}
raw, err := session.AskSeat(ctx, "node-backup", "backed-up", map[string]any{"module": module}, 10*time.Second)
if err == nil {
var read []struct {
Module string `json:"module"`
LastNight *struct {
OK bool `json:"ok"`
At time.Time `json:"at"`
Error string `json:"error"`
} `json:"lastNight"`
}
if json.Unmarshal(raw, &read) == nil {
for _, m := range read {
if m.Module == module && m.LastNight != nil && m.LastNight.At.After(asked) {
if m.LastNight.OK {
return agent.Backup{Done: true}
}
return agent.Backup{Words: "the backup failed: " + m.LastNight.Error}
}
}
}
if json.Unmarshal(raw, &e) != nil || e.Node != node || e.Module != module || e.At.Before(since.Add(-time.Second)) {
continue
}
if time.Now().After(deadline) {
return agent.Backup{Words: fmt.Sprintf("the backup did not say it was done within %s", backupWithin)}
if e.OK {
return agent.Backup{Done: true}
}
words := "the backup failed"
if e.Error != "" {
words += ": " + e.Error
}
return agent.Backup{Words: words}
}
}
}
+4 -17
View File
@@ -230,32 +230,19 @@ func TestNatsAWantAndItsCompositionAreAppliedWithinASecond(t *testing.T) {
// module (nothing applied, the have says held with the backup's words); when its want then says skip, it is applied.
func TestNatsADataModuleAsksItsBackupFirstAndIsHeldWhenItFails(t *testing.T) {
b := newItBed(t)
backupPollEvery = 20 * time.Millisecond
var mu sync.Mutex
var asked []string
var onDiskWhenAsked string
var nightOK bool
var at time.Time
if _, err := b.conn.Subscribe("mesh.seat.node-backup.tool.now."+itNode, func(m *nats.Msg) {
mu.Lock()
asked = append(asked, "now")
onDiskWhenAsked = b.file("db")
at = time.Now()
mu.Unlock()
_ = m.Respond([]byte(`{"result":{"started":"db"}}`))
}); err != nil {
t.Fatal(err)
}
if _, err := b.conn.Subscribe("mesh.seat.node-backup.tool.backed-up."+itNode, func(m *nats.Msg) {
mu.Lock()
defer mu.Unlock()
if at.IsZero() {
_ = m.Respond([]byte(`{"result":[]}`))
return
}
night, _ := json.Marshal(map[string]any{"module": "db", "lastNight": map[string]any{"ok": nightOK,
"at": time.Now().UTC(), "error": "restic: the repository is locked"}})
_ = m.Respond([]byte(`{"result":[` + string(night) + `]}`))
// The holder says the backup ended on its event.
ev, _ := json.Marshal(map[string]any{"node": itNode, "module": "db", "ok": false, "at": time.Now().UTC(),
"error": "restic: the repository is locked"})
_ = b.conn.Publish(backedUpEvent, ev)
}); err != nil {
t.Fatal(err)
}