Say what a walk touches, its bound and its outcome when it starts and ends (ADR 0308, issue 532)
The router holds what an open walk touches and tells its result once; it can do so only if plan-moved names the walk's start and end, the modules and machines it moves, what a moved tool runner or node-engine carries there, and how long to hold.
This commit is contained in:
@@ -61,6 +61,10 @@ type Plan struct {
|
||||
// Phases is the walk's time from its merge, phase by phase (ADR 0282 decision 6): never kept, worked out
|
||||
// from the walk's own moments by whoever says the walk (`delivery walks`, `plan-moved`).
|
||||
Phases *WalkPhases `json:"phases,omitempty"`
|
||||
// WalkSaid is, on the save that started or ended the walk, which of the two it was (novox/hq ADR 0308
|
||||
// decision 1): set by the save, never kept, read by whoever says the save (`plan-moved`). Empty on every
|
||||
// other save.
|
||||
WalkSaid string `json:"-"`
|
||||
}
|
||||
|
||||
// PlanTimes are a walk's own moments beside its modules' (novox/hq ADR 0282 decision 6).
|
||||
@@ -73,6 +77,10 @@ type PlanTimes struct {
|
||||
// Class is the walk's module class, read at its cut: core when it moves a module on the controller's own
|
||||
// path or the mesh's resolver, leaf otherwise (ADR 0282 decision 1).
|
||||
Class string `json:"class,omitempty"`
|
||||
// Started is when the walk was said started, and Ended when it was said ended (novox/hq ADR 0308 decision
|
||||
// 1): each said once, on the save that marked it.
|
||||
Started *time.Time `json:"started,omitempty"`
|
||||
Ended *time.Time `json:"ended,omitempty"`
|
||||
}
|
||||
|
||||
// PlanCommit is one repository's commit a walk carries: the latest merge of its branch in the batch.
|
||||
@@ -451,7 +459,7 @@ type MergeMove struct {
|
||||
// leaves a merge answered by two plans, nor a walk stopped whose merges were not moved). A plan written by
|
||||
// somebody else since it was read refuses the whole act with ErrPlanMoved. Each plan is said (PlanSaved) once
|
||||
// the transaction is committed, in order.
|
||||
func (i *Inventory) SavePlansMoving(ctx context.Context, plans []*Plan, moves []MergeMove) error {
|
||||
func (i *Inventory) SavePlansMoving(ctx context.Context, plans []*Plan, moves []MergeMove) (err error) {
|
||||
epoch, err := i.actingEpoch(ctx)
|
||||
if err != nil {
|
||||
if len(plans) > 0 {
|
||||
@@ -472,6 +480,20 @@ func (i *Inventory) SavePlansMoving(ctx context.Context, plans []*Plan, moves []
|
||||
entered time.Time
|
||||
}
|
||||
done := make([]written, len(plans))
|
||||
// **The save that starts or ends a walk is marked** (novox/hq ADR 0308 decision 1), in the same write, and
|
||||
// unmarked should the write not be made, so the next save marks it again.
|
||||
marked := time.Now()
|
||||
undo := make([]func(), 0, len(plans))
|
||||
defer func() {
|
||||
if err != nil {
|
||||
for _, u := range undo {
|
||||
u()
|
||||
}
|
||||
}
|
||||
}()
|
||||
for _, p := range plans {
|
||||
undo = append(undo, p.markWalk(marked))
|
||||
}
|
||||
for n, p := range plans {
|
||||
revision, entered, err := writePlan(ctx, tx, p, epoch)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package inventory
|
||||
|
||||
import "time"
|
||||
|
||||
// A walk says when it starts and when it ends (novox/hq ADR 0308 decision 1): the save that starts it and the
|
||||
// save that ends it are marked, so `plan-moved` names what the walk touches, its bound and its outcome on those
|
||||
// two saves, and on no other. The marks are kept in the walk's times, so a controller replaced mid-walk (a core
|
||||
// walk replaces it) neither says a start again nor misses the end.
|
||||
|
||||
// The moments of a walk `plan-moved` names.
|
||||
const (
|
||||
WalkStarted = "started"
|
||||
WalkEnded = "ended"
|
||||
)
|
||||
|
||||
// The outcomes of an ended walk (ADR 0308 decision 1).
|
||||
const (
|
||||
WalkDelivered = "delivered"
|
||||
WalkFailed = "failed"
|
||||
WalkStopped = "stopped"
|
||||
)
|
||||
|
||||
// Live says the walk is walking: cut, not waiting for its delivery's word, and not ended.
|
||||
func (p Plan) Live() bool { return p.Open() && !p.Batch() && !p.Waiting() }
|
||||
|
||||
// Ended says the walk is in a state it does not leave by itself: done, failed or superseded.
|
||||
func (p Plan) Ended() bool {
|
||||
return p.State == PlanDone || p.State == PlanFailed || p.State == PlanSuperseded
|
||||
}
|
||||
|
||||
// Outcome is how an ended walk ended: delivered when done; stopped when a person or the delivery's owner stopped
|
||||
// it, or a newer walk took it over; failed otherwise. Empty for a walk not ended.
|
||||
func (p Plan) Outcome() string {
|
||||
switch {
|
||||
case p.State == PlanDone:
|
||||
return WalkDelivered
|
||||
case p.State == PlanSuperseded:
|
||||
return WalkStopped
|
||||
case p.State == PlanFailed && p.Delivery != nil && p.Delivery.Stopped != "":
|
||||
return WalkStopped
|
||||
case p.State == PlanFailed:
|
||||
return WalkFailed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// markWalk marks the save that starts or ends the walk, in its times and in WalkSaid, and returns what undoes
|
||||
// the mark should the save not be written. A walk starts on the first save that finds it live — when it is cut
|
||||
// and needs no word, or when it is let go — which is before its first tier is asked, and so before anything is
|
||||
// sent: what it restarts is held from its first send on. It starts again when a failed walk is retried. It ends
|
||||
// on the first save that finds it ended after it started; one that ends without ever starting (a batch that
|
||||
// moved nothing, a walk stopped while it waited for its word) touched nothing and says neither. A walk kept
|
||||
// before its times were (ADR 0282 decision 6) is not marked; a release plan is, its times holding only these.
|
||||
func (p *Plan) markWalk(now time.Time) (undo func()) {
|
||||
p.WalkSaid = ""
|
||||
if p.Batch() || (p.Times == nil && p.Release == nil) {
|
||||
return func() {}
|
||||
}
|
||||
var was *PlanTimes
|
||||
if p.Times != nil {
|
||||
kept := *p.Times
|
||||
was = &kept
|
||||
}
|
||||
times := PlanTimes{}
|
||||
if p.Times != nil {
|
||||
times = *p.Times
|
||||
}
|
||||
at := now.UTC()
|
||||
switch {
|
||||
case p.Live() && (times.Started == nil || times.Ended != nil):
|
||||
times.Started, times.Ended = &at, nil
|
||||
p.WalkSaid = WalkStarted
|
||||
case p.Ended() && times.Started != nil && times.Ended == nil:
|
||||
times.Ended = &at
|
||||
p.WalkSaid = WalkEnded
|
||||
default:
|
||||
return func() {}
|
||||
}
|
||||
p.Times = ×
|
||||
return func() { p.Times, p.WalkSaid = was, "" }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A walk is said started on the save that finds it live, ended on the save that finds it ended after it started,
|
||||
// and neither on any other save (novox/hq ADR 0308 decision 1).
|
||||
func TestAWalkIsSaidStartedOnceAndEndedOnce(t *testing.T) {
|
||||
now := time.Date(2026, 10, 12, 11, 0, 0, 0, time.UTC)
|
||||
cut := now.Add(-time.Second)
|
||||
p := Plan{ID: "plan-1", State: PlanBuilding, Tiers: [][]string{{"messenger"}},
|
||||
Modules: map[string]*PlanModule{"messenger": {}}, Times: &PlanTimes{Cut: &cut, Class: ClassLeaf}}
|
||||
p.markWalk(now)
|
||||
if p.WalkSaid != WalkStarted || p.Times.Started == nil || !p.Times.Started.Equal(now) {
|
||||
t.Fatalf("a live walk's first save starts it: %q %+v", p.WalkSaid, p.Times)
|
||||
}
|
||||
p.State = PlanRolling
|
||||
p.markWalk(now.Add(time.Minute))
|
||||
if p.WalkSaid != "" || !p.Times.Started.Equal(now) {
|
||||
t.Fatalf("a later save says nothing and keeps the start: %q %+v", p.WalkSaid, p.Times)
|
||||
}
|
||||
p.State = PlanDone
|
||||
p.markWalk(now.Add(2 * time.Minute))
|
||||
if p.WalkSaid != WalkEnded || p.Times.Ended == nil || p.Outcome() != WalkDelivered {
|
||||
t.Fatalf("the save that finds it done ends it, delivered: %q %+v %q", p.WalkSaid, p.Times, p.Outcome())
|
||||
}
|
||||
p.markWalk(now.Add(3 * time.Minute))
|
||||
if p.WalkSaid != "" {
|
||||
t.Fatalf("an ended walk saved again says nothing: %q", p.WalkSaid)
|
||||
}
|
||||
// Retried, it walks again: a new start.
|
||||
p.State = PlanBuilding
|
||||
p.markWalk(now.Add(4 * time.Minute))
|
||||
if p.WalkSaid != WalkStarted || p.Times.Ended != nil || !p.Times.Started.Equal(now.Add(4*time.Minute)) {
|
||||
t.Fatalf("a retried walk starts again: %q %+v", p.WalkSaid, p.Times)
|
||||
}
|
||||
}
|
||||
|
||||
// What is never said: a batch, a walk waiting for its word, a walk that ends without having started, and a walk
|
||||
// kept before its times were. A release plan is said.
|
||||
func TestWhatIsNotAWalkStartIsNotSaid(t *testing.T) {
|
||||
now := time.Now()
|
||||
for name, p := range map[string]Plan{
|
||||
"a batch": {State: PlanAssembling, Times: &PlanTimes{}},
|
||||
"waiting for a word": {State: PlanBuilding, Times: &PlanTimes{}, Delivery: &PlanDelivery{Awaits: "mesh-delivery"}},
|
||||
"ended, never begun": {State: PlanDone, Times: &PlanTimes{}},
|
||||
"kept before times": {State: PlanBuilding},
|
||||
"stopped while waits": {State: PlanFailed, Times: &PlanTimes{}, Delivery: &PlanDelivery{Awaits: "mesh-delivery", Stopped: "x"}},
|
||||
} {
|
||||
p.markWalk(now)
|
||||
if p.WalkSaid != "" {
|
||||
t.Errorf("%s: said %q", name, p.WalkSaid)
|
||||
}
|
||||
}
|
||||
r := Plan{State: PlanRolling, Release: &PlanRelease{Order: []string{"ace"}}}
|
||||
r.markWalk(now)
|
||||
if r.WalkSaid != WalkStarted || r.Times == nil || r.Times.Started == nil || r.Times.Class != "" {
|
||||
t.Fatalf("a release plan is said started, its times holding only that: %q %+v", r.WalkSaid, r.Times)
|
||||
}
|
||||
// Let go, the walk starts.
|
||||
go1 := now
|
||||
w := Plan{State: PlanBuilding, Times: &PlanTimes{}, Delivery: &PlanDelivery{Awaits: "mesh-delivery", Go: &go1}}
|
||||
w.markWalk(now)
|
||||
if w.WalkSaid != WalkStarted {
|
||||
t.Fatalf("a walk let go starts: %q", w.WalkSaid)
|
||||
}
|
||||
}
|
||||
|
||||
// An ended walk's outcome: delivered, failed, or stopped by a person or the delivery's owner, or by a newer walk.
|
||||
func TestAWalksOutcome(t *testing.T) {
|
||||
for want, p := range map[string]Plan{
|
||||
WalkDelivered: {State: PlanDone},
|
||||
WalkFailed: {State: PlanFailed},
|
||||
WalkStopped: {State: PlanFailed, Delivery: &PlanDelivery{Stopped: "mesh-delivery"}},
|
||||
"": {State: PlanRolling},
|
||||
} {
|
||||
if got := p.Outcome(); got != want {
|
||||
t.Errorf("%s: outcome %q, want %q", p.State, got, want)
|
||||
}
|
||||
}
|
||||
if (Plan{State: PlanSuperseded}).Outcome() != WalkStopped {
|
||||
t.Error("a superseded walk is stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// The marks are kept with the walk, so a controller replaced mid-walk neither says its start again nor misses
|
||||
// its end; and a save refused leaves the walk unmarked, so the save that is made says it.
|
||||
func TestAWalksStartIsKeptAndARefusedSaveLeavesItUnsaid(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
cut := time.Now().UTC().Add(-time.Minute)
|
||||
p := Plan{ID: "plan-308", Repository: "novox/mesh-catalog", Commit: "abc", Created: cut, State: PlanBuilding,
|
||||
Tiers: [][]string{{"messenger"}}, Modules: map[string]*PlanModule{"messenger": {}},
|
||||
Times: &PlanTimes{Cut: &cut, Class: ClassLeaf}}
|
||||
var said []string
|
||||
was := PlanSaved
|
||||
PlanSaved = func(p Plan) { said = append(said, p.WalkSaid) }
|
||||
t.Cleanup(func() { PlanSaved = was })
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := inv.PlanByID(ctx, p.ID)
|
||||
if err != nil || back.Times == nil || back.Times.Started == nil || back.Times.Class != ClassLeaf {
|
||||
t.Fatalf("the start is kept with the walk: %v %+v", err, back.Times)
|
||||
}
|
||||
// Read back by another controller and saved: not said again.
|
||||
back.State = PlanRolling
|
||||
if err := inv.SavePlan(ctx, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A stale copy ending it is refused, and unmarked.
|
||||
stale := p
|
||||
stale.Revision = 1
|
||||
stale.State = PlanDone
|
||||
if err := inv.SavePlan(ctx, &stale); err == nil {
|
||||
t.Fatal("a stale save is refused")
|
||||
}
|
||||
if stale.WalkSaid != "" || stale.Times.Ended != nil {
|
||||
t.Fatalf("a refused save is unmarked: %q %+v", stale.WalkSaid, stale.Times)
|
||||
}
|
||||
back.State = PlanDone
|
||||
if err := inv.SavePlan(ctx, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(said) != 3 || said[0] != WalkStarted || said[1] != "" || said[2] != WalkEnded {
|
||||
t.Fatalf("said: %q, want started, nothing, ended", said)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user