The backup module says on the bus when a backup asked for now has ended, so no one polls backed-up (issue 1944, ADR 0539 §16)

This commit is contained in:
2026-10-24 16:58:19 +02:00
parent eb79996336
commit e7ca8f7af1
6 changed files with 93 additions and 2 deletions
@@ -184,6 +184,10 @@ type Backups struct {
Run Runner
Now func() time.Time
Say func(format string, args ...any)
// Emit says an event of this module on the bus; nil where the process has no runtime to say it (the night and the
// measuring run as processes of their own). Node is the machine's name, said in the events.
Emit func(event string, body any) error
Node string
}
// The locks, files in this module's state. The night runs in a process of its own and `now`, `restore` and a
@@ -249,6 +253,28 @@ func (b *Backups) Nights() map[string]Night {
return nights
}
// EventBackedUp is what this module says when a backup asked for now (`node-backup.now`) has ended for a module, well or
// not: {node, module, ok, snapshot, at, error}. A caller that asked waits for it on the bus instead of asking `backed-up`
// again and again (novox/hq ADR 0539 §16); the nights run in a process with no runtime and say nothing.
const EventBackedUp = "backed-up"
// saidBackedUp says EventBackedUp for one module's backup taken by hand. A failure to say it is logged, never the backup's.
func (b *Backups) saidBackedUp(module string, n Night) {
if b.Emit == nil {
return
}
body := map[string]any{"node": b.Node, "module": module, "ok": n.OK, "at": n.At}
if n.Snapshot != "" {
body["snapshot"] = n.Snapshot
}
if n.Error != "" {
body["error"] = n.Error
}
if err := b.Emit(EventBackedUp, body); err != nil {
b.Say("%s's backup could not be said on the bus: %v", module, err)
}
}
func (b *Backups) record(module string, n Night) {
nights := b.Nights()
nights[module] = n
@@ -379,6 +405,9 @@ func (b *Backups) backUp(ctx context.Context, only string, hand bool) (map[strin
for _, d := range chosen {
outcome[d.Module] = b.one(ctx, d, hand)
b.record(d.Module, outcome[d.Module])
if hand {
b.saidBackedUp(d.Module, outcome[d.Module])
}
if n := outcome[d.Module]; !hand && n.OK && n.Snapshot != "" {
b.recordGoodNight(d.Module, n.Snapshot)
}
+3 -1
View File
@@ -33,7 +33,9 @@ const HandTag = "hand"
func (b *Backups) BackUpNow(ctx context.Context, only string) (map[string]Night, error) {
out, err := b.backUp(ctx, only, true)
if err != nil && only != "" {
b.record(only, Night{OK: false, At: b.Now().UTC(), Error: err.Error()})
n := Night{OK: false, At: b.Now().UTC(), Error: err.Error()}
b.record(only, n)
b.saidBackedUp(only, n)
}
return out, err
}
@@ -351,3 +351,57 @@ func TestABackupAskedForNowThatCannotStartIsRecordedAsThatModulesLastNight(t *te
t.Errorf("mail's last night is %+v, want a failure saying it declares nothing", n)
}
}
// R6 (novox/hq ADR 0539 §16): a backup asked for now says on the bus when it has ended for a module, well or not, with the node,
// the module, the snapshot and the time, so the node agent waits for the event instead of asking `backed-up` every five seconds.
func TestABackupAskedForNowSaysItEndedOnTheBusForEachModule(t *testing.T) {
type said struct {
event string
body map[string]any
}
var events []said
emit := func(event string, body any) error {
raw, _ := json.Marshal(body)
var m map[string]any
_ = json.Unmarshal(raw, &m)
events = append(events, said{event, m})
return nil
}
at := time.Date(2026, 10, 24, 14, 0, 0, 0, time.UTC)
h := &handRestic{made: "abcdef0123456789"}
b := &Backups{Where: placed(t, "# pg\npath /\n# app\npath /\n"), Run: h.run, Now: func() time.Time { return at }, Say: quiet,
Emit: emit, Node: "ace"}
_, err := b.BackUpNow(context.Background(), "")
must(t, err)
if len(events) != 2 {
t.Fatalf("said %d events for two modules: %v", len(events), events)
}
seen := map[string]bool{}
for _, e := range events {
m, _ := e.body["module"].(string)
seen[m] = true
if e.event != "backed-up" || e.body["node"] != "ace" || e.body["ok"] != true || e.body["snapshot"] == "" || e.body["snapshot"] == nil ||
e.body["at"] != at.Format(time.RFC3339) {
t.Errorf("%s: %s %v", m, e.event, e.body)
}
}
if !seen["pg"] || !seen["app"] {
t.Errorf("modules said: %v", seen)
}
// One that cannot start is said failed with why, so a caller waiting on it reads an answer and not silence.
events = nil
_, err = b.BackUpNow(context.Background(), "nobody")
if err == nil {
t.Fatal("a module that declares nothing was backed up")
}
if len(events) != 1 || events[0].body["module"] != "nobody" || events[0].body["ok"] != false || events[0].body["error"] == nil {
t.Errorf("the failure was not said: %v", events)
}
// A night says nothing: it runs in a process with no runtime.
events = nil
_, err = b.BackUp(context.Background(), "")
must(t, err)
if len(events) != 0 {
t.Errorf("a night said %v", events)
}
}
+4 -1
View File
@@ -39,11 +39,14 @@ func main() {
say("%v", err)
os.Exit(1)
}
b := &Backups{Where: where, Run: execRunner, Now: time.Now, Say: say}
b := &Backups{Where: where, Run: execRunner, Now: time.Now, Say: say, Node: os.Getenv("MESH_NODE")}
way := ""
if len(os.Args) > 1 {
way = os.Args[1]
}
if way == "" {
b.Emit = func(event string, body any) error { return stdio.Emit(event, body) }
}
switch way {
case "night":
nights(b)
+3
View File
@@ -13,6 +13,9 @@
]
}
],
"emits": [
"backed-up"
],
"tools": [
"backup_health",
"backup_delete_retired",
BIN
View File
Binary file not shown.