From 4799f792424cd40a6b72b5f9c71a630902d7f907 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 17 Oct 2026 22:45:46 +0200 Subject: [PATCH] Read the plan's backups as the controller says them: machine, module, state, words (issue 1068) --- .../cmd/mesh-delivery/backup_test.go | 8 ++-- .../cmd/mesh-delivery/bus_test.go | 6 +-- .../cmd/mesh-delivery/fakes_test.go | 2 +- .../mesh-delivery/cmd/mesh-delivery/model.go | 37 +++++++++++++------ .../mesh-delivery/cmd/mesh-delivery/ports.go | 2 +- .../cmd/mesh-delivery/table_test.go | 2 +- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/modules/mesh-delivery/cmd/mesh-delivery/backup_test.go b/modules/mesh-delivery/cmd/mesh-delivery/backup_test.go index c95c3af3..e30022bf 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/backup_test.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/backup_test.go @@ -6,10 +6,12 @@ import ( ) // A failed backup before the send holds the delivery, and a release asks the backups again (novox/hq ADR 0404 -// clause 4, issue 1068). The walk's field names are provisional until the controller's contract settles them. +// clause 4, issue 1068). The walk's backups field is the controller's contract. func failedBackup() *WalkBackups { - return &WalkBackups{Failed: []WalkBackupFailure{{Node: "ace", Module: "pg", Error: "no space left on device"}}} + return &WalkBackups{State: "failed", Asked: []WalkBackupAsked{ + {Machine: "ace", Module: "mail", State: "done"}, + {Machine: "ace", Module: "pg", State: "failed", Words: "no space left on device"}}} } // aDeliveringWalk is a delivery whose walk was let go and runs, nothing sent yet. @@ -119,7 +121,7 @@ func TestADeliveryHeldForAFailedBackupIsNotLetGoByItsWalkAlone(t *testing.T) { // The controller's own path starts a walk with nobody's word: the held-to-delivering row for that must not // undo the backup hold. d := &Delivery{ID: "novox/app@c0", State: Held, MergedAs: "aaaa", Walk: &WalkSeen{ID: "plan-1", State: walkBuilding, Revision: 3, - BackupsFailed: failedBackup().Failed}} + BackupsFailed: []WalkBackupFailure{{Machine: "ace", Module: "pg", Words: "no space left on device"}}}} if _, err := Apply(d, EvGo, false, Facts{}); err == nil { t.Fatal("a walk started on the controller's own path lifted the backup hold") } diff --git a/modules/mesh-delivery/cmd/mesh-delivery/bus_test.go b/modules/mesh-delivery/cmd/mesh-delivery/bus_test.go index 36298d3c..f05fa263 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/bus_test.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/bus_test.go @@ -566,8 +566,8 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeInOnePassOv // **The acceptance of a failed backup before the send, on the mesh-delivery side** (novox/hq ADR 0404 clause 4, design 47 // milestone 4, issue 1068): a merged delivery whose walk runs is held when the walk's record, as the controller's // `plan-moved` carries it, names a backup that failed and nothing was sent; the hold names the machine, the module and the -// backup's words, and nothing asks the controller to let it go. One test function, one bus. The walk's backups field names -// are provisional until the controller's contract (issue 1065) settles them. +// backup's words, and nothing asks the controller to let it go. One test function, one bus. The walk's backups field is the controller's +// contract (issue 1065). func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverARealBus(t *testing.T) { b := newTestBus(t) rt := b.start(t) @@ -603,7 +603,7 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA rt.until("the delivery to be delivering", 10*time.Second, func() bool { return deliveryOf().State == Delivering }) // A backup failed before the send: the delivery is held with the backup's words. - walk.Revision, walk.Backups = 2, &WalkBackups{Failed: []WalkBackupFailure{{Node: "ace", Module: "pg", Error: "no space left on device"}}} + walk.Revision, walk.Backups = 2, &WalkBackups{State: "failed", Asked: []WalkBackupAsked{{Machine: "ace", Module: "pg", State: "failed", Words: "no space left on device"}}} b.publish(t, "mesh.seat."+ControllerSeat+".event.plan-moved", walk) rt.until("the delivery to be held for the failed backup", 10*time.Second, func() bool { return deliveryOf().State == Held }) d := deliveryOf() diff --git a/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go b/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go index 4535513f..bf616f19 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go @@ -131,7 +131,7 @@ func (c *fakeController) BackupsAgain(walk, why string) error { return errors.New("down") } w, known := c.walks[walk] - if !known || w.Backups == nil || len(w.Backups.Failed) == 0 { + if !known || w.Backups == nil || w.Backups.State != "failed" { return fmt.Errorf("%s holds no failed backup", walk) } w.Backups = nil diff --git a/modules/mesh-delivery/cmd/mesh-delivery/model.go b/modules/mesh-delivery/cmd/mesh-delivery/model.go index 3637cfa4..822c5a97 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/model.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/model.go @@ -5,7 +5,6 @@ import ( "fmt" "maps" "regexp" - "slices" "sort" "strings" "time" @@ -506,23 +505,35 @@ type Walk struct { Release *WalkRelease `json:"release,omitempty"` Delivery *WalkDelivery `json:"delivery,omitempty"` // Backups is what the backups the controller asks before it sends anything came to (novox/hq ADR 0404, issue - // 1068). PROVISIONAL field name and shape until the controller's contract (issue 1065) settles them. + // 1068); absent when the plan moves no backed-up item. Backups *WalkBackups `json:"backups,omitempty"` // Phases is the walk's time from its merge, phase by phase, as the controller works it out (novox/hq ADR // 0282): what a delivery's time is kept from. Absent from a controller that does not say it. Phases *WalkPhases `json:"phases,omitempty"` } -// WalkBackups is the backups of a walk before its send: the failed ones, each with the backup's own words. +// WalkBackups is the backups of a walk before its send, as the controller's plan record says them: state pending, +// done or failed (failed as soon as any entry failed), each backup asked, and the machines with no backup holder. type WalkBackups struct { - Failed []WalkBackupFailure `json:"failed,omitempty"` + State string `json:"state"` + Asked []WalkBackupAsked `json:"asked,omitempty"` + NoHolder []string `json:"no_holder,omitempty"` } -// WalkBackupFailure is one backup that failed or stayed silent past its bound. +// WalkBackupAsked is one backup asked of a machine for a module: its state, and the backup's own words. +type WalkBackupAsked struct { + Machine string `json:"machine"` + Module string `json:"module"` + State string `json:"state"` + AskedAt *time.Time `json:"asked_at,omitempty"` + Words string `json:"words,omitempty"` +} + +// WalkBackupFailure is one backup that failed or stayed silent past its bound, as a delivery keeps it. type WalkBackupFailure struct { - Node string `json:"node"` - Module string `json:"module"` - Error string `json:"error"` + Machine string `json:"machine"` + Module string `json:"module"` + Words string `json:"words"` } // WalkModule is one module of a walk. @@ -740,7 +751,7 @@ type WalkSeen struct { func (w *WalkSeen) backupWords() string { var parts []string for _, f := range w.BackupsFailed { - parts = append(parts, fmt.Sprintf("%s on %s: %s", f.Module, f.Node, f.Error)) + parts = append(parts, fmt.Sprintf("%s on %s: %s", f.Module, f.Machine, f.Words)) } return "a backup before the send failed, and nothing was sent: " + strings.Join(parts, "; ") } @@ -776,8 +787,12 @@ func (w Walk) Seen() *WalkSeen { } sort.Strings(s.Moved) s.MovedSaid = true - if w.Backups != nil && len(w.Backups.Failed) > 0 { - s.BackupsFailed = slices.Clone(w.Backups.Failed) + if w.Backups != nil { + for _, a := range w.Backups.Asked { + if a.State == "failed" { + s.BackupsFailed = append(s.BackupsFailed, WalkBackupFailure{Machine: a.Machine, Module: a.Module, Words: a.Words}) + } + } } return s } diff --git a/modules/mesh-delivery/cmd/mesh-delivery/ports.go b/modules/mesh-delivery/cmd/mesh-delivery/ports.go index 01c85fcf..c893fe0f 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/ports.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/ports.go @@ -54,7 +54,7 @@ type Controller interface { // Deliver lets a waiting walk start. Deliver(walk, why string) error // BackupsAgain asks the controller to take a held walk's failed backups again, and to go on when they pass - // (novox/hq ADR 0404 clause 4). PROVISIONAL verb name until the controller's contract settles it. + // (novox/hq ADR 0404 clause 4). BackupsAgain(walk, why string) error // Stop ends a walk, said as stopped by whom. Stop(walk, why, by string) error diff --git a/modules/mesh-delivery/cmd/mesh-delivery/table_test.go b/modules/mesh-delivery/cmd/mesh-delivery/table_test.go index b382b9c8..573e4935 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/table_test.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/table_test.go @@ -90,7 +90,7 @@ func aDeliveryFor(r Row) (*Delivery, Facts) { d.Walk = &WalkSeen{ID: "plan-1", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"} case EvBackupFailed: d.Walk = &WalkSeen{ID: "plan-1", State: walkBuilding, Revision: 4, - BackupsFailed: []WalkBackupFailure{{Node: "ace", Module: "pg", Error: "no space left on device"}}} + BackupsFailed: []WalkBackupFailure{{Machine: "ace", Module: "pg", Words: "no space left on device"}}} case EvFailed: d.Walk = &WalkSeen{ID: "plan-1", State: walkFailed} case EvSuperseded: