Read the plan's backups as the controller says them: machine, module, state, words (issue 1068)

This commit is contained in:
2026-10-17 22:45:46 +02:00
parent 8f7aeeeb81
commit 4799f79242
6 changed files with 37 additions and 20 deletions
@@ -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")
}
@@ -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()
@@ -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
@@ -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
}
@@ -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
@@ -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: