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)
}
+47
View File
@@ -127,3 +127,50 @@ func (q *Session) WhenLinked(ctx context.Context) (context.Context, context.Canc
}
}
}
// EventBus is a link that can hear an event on a subject of core NATS: the node agent waits for a backup's end with it,
// and never polls.
type EventBus interface {
// Hear yields the payloads published on the subject until stop is called or the link ends.
Hear(subject string) (events <-chan []byte, stop func(), err error)
}
func (b OverNATS) Hear(subject string) (<-chan []byte, func(), error) {
out := make(chan []byte, 16)
sub, err := b.Conn.Subscribe(subject, func(m *nats.Msg) {
select {
case out <- m.Data:
default:
}
})
if err != nil {
return nil, nil, err
}
if err := b.Conn.Flush(); err != nil {
_ = sub.Unsubscribe()
return nil, nil, err
}
return out, func() { _ = sub.Unsubscribe() }, nil
}
func (l *natsLink) Hear(subject string) (<-chan []byte, func(), error) {
return OverNATS{Conn: l.conn, JS: l.js}.Hear(subject)
}
var (
_ EventBus = OverNATS{}
_ EventBus = (*natsLink)(nil)
)
// Hear listens for an event on the link open now, from before the caller asks for what it waits on.
func (q *Session) Hear(subject string) (<-chan []byte, func(), error) {
q.init()
q.mu.Lock()
bus := q.bus
q.mu.Unlock()
h, ok := bus.(EventBus)
if bus == nil || !ok {
return nil, nil, ErrNoLink
}
return h.Hear(subject)
}
+3
View File
@@ -14,6 +14,9 @@
}
]
},
"consumes": [
"restic.backed-up"
],
"config": {
"operator-signers": {
"type": "string",
+17
View File
@@ -52,3 +52,20 @@ func TestTheManifestDeclaresTheOperatorKeysFileFedByASetting(t *testing.T) {
}
t.Fatal("module.json declares no operator-keys file")
}
// The node agent hears the end of a backup on the event of node-backup's holder, so the manifest consumes it.
func TestTheManifestConsumesTheBackupEvent(t *testing.T) {
raw, err := os.ReadFile("../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Consumes []string `json:"consumes"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if len(m.Consumes) != 1 || m.Consumes[0] != "restic.backed-up" {
t.Fatalf("consumes %v", m.Consumes)
}
}