Cut a closed batch beside the open walks it shares nothing with, queue a related one with the reason, fold only related waiting walks (issue 973)
mesh/merge-gate head 300d3c1bf466 merged into trunk 6a62a7e552ea — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 300d3c1bf466 merged into trunk 6a62a7e552ea — fail: its merge-check.sh failed: --- FAIL: TestSSHClientOwnsTheOperatorsSSHConfig (0.01s)
mesh/delivery superseded: a newer head of the same pull request

This commit is contained in:
2026-10-17 00:49:53 +02:00
parent baf1501d81
commit 300d3c1bf4
6 changed files with 248 additions and 56 deletions
+118 -39
View File
@@ -27,9 +27,10 @@ import (
// commit in it, with at most one commit per repository, the latest of its branch, which contains the earlier // commit in it, with at most one commit per repository, the latest of its branch, which contains the earlier
// ones. The walk names every merge it answers. // ones. The walk names every merge it answers.
// //
// - One walk at a time. Merges that come while a walk runs join the next batch. A walk that started is never // - One walk at a time beside a related one (ADR 0407 decision 4, issue 973). Merges that come while a walk
// superseded; one still waiting for its delivery's word is folded into the next batch's walk, which // runs join the next batch; a closed batch that shares nothing with an open walk is cut beside it. A walk
// answers every merge it answered — the only supersession left. // that started is never superseded; one still waiting for its delivery's word is folded into the walk of a
// batch related to it, which answers every merge it answered — the only supersession left.
// - A merge heard after a later merge of its branch was put in a walk is answered by that walk: a batch // - A merge heard after a later merge of its branch was put in a walk is answered by that walk: a batch
// still assembling, an open walk that builds everything it moves, or a walk done that did, which then // still assembling, an open walk that builds everything it moves, or a walk done that did, which then
// names it at once. Otherwise it joins the next batch. // names it at once. Otherwise it joins the next batch.
@@ -39,8 +40,9 @@ import (
// //
// **A merge waits only for a batch it shares something with** (novox/hq ADR 0304 decision 1, ADR 0306 decisions // **A merge waits only for a batch it shares something with** (novox/hq ADR 0304 decision 1, ADR 0306 decisions
// 2 and 3, issue 525): it joins a batch it is related to, and one that is related to none and whose delivery // 2 and 3, issue 525): it joins a batch it is related to, and one that is related to none and whose delivery
// group has no member still to merge is a batch whose window is closed at once (batchToJoin). Walks stay one at // group has no member still to merge is a batch whose window is closed at once (batchToJoin). Such a batch is cut
// a time: such a batch is cut when no walk is open, and queued behind the one that is otherwise. // at once, beside any open walk it shares nothing with, and queued behind the first one it is related to
// (cutUnrelated).
// //
// A merge on the controller's own path (a module whose walk waits for nobody's word) never shares a batch with // A merge on the controller's own path (a module whose walk waits for nobody's word) never shares a batch with
// one that waits for mesh-delivery's: each kind has a batch of its own, an own-path batch is cut first and folds // one that waits for mesh-delivery's: each kind has a batch of its own, an own-path batch is cut first and folds
@@ -340,8 +342,8 @@ func ownPath(touches []inventory.Entry) bool {
// names why): a new batch, which waits for the window, beside the ones it shares nothing with; its words say // names why): a new batch, which waits for the window, beside the ones it shares nothing with; its words say
// why. // why.
// - none, and no member of its group is still to merge: a new batch whose window is closed at once. Cut at // - none, and no member of its group is still to merge: a new batch whose window is closed at once. Cut at
// once means only that: walks stay one at a time (ADR 0276 decision 4), so with a walk open the batch is // once means only that: with a walk open that it is related to the batch is queued behind it as any closed
// queued behind it as any closed batch is. // batch is (ADR 0276 decision 4, ADR 0407 decision 4); beside one it shares nothing with it is cut.
func batchToJoin(ctx context.Context, inv *inventory.Inventory, heard inventory.BatchedMerge, entries []inventory.Entry, func batchToJoin(ctx context.Context, inv *inventory.Inventory, heard inventory.BatchedMerge, entries []inventory.Entry,
read map[string][]inventory.ReadRepository, now time.Time, own bool, waits string) (inventory.Plan, string, error) { read map[string][]inventory.ReadRepository, now time.Time, own bool, waits string) (inventory.Plan, string, error) {
edges, err := inv.Dependencies(ctx) edges, err := inv.Dependencies(ctx)
@@ -406,7 +408,9 @@ func relatedBatches(ctx context.Context, inv *inventory.Inventory, merges []inve
} }
carry := newFootprint() carry := newFootprint()
if !own { if !own {
// An own-path batch folds no waiting walk (cutBatchesHeld); a catalogue batch may fold every one. // An own-path batch folds no waiting walk (cutBatchesHeld); a catalogue batch folds those it is related to
// (cutUnrelated). Every one is counted here, which only makes a merge join a batch more readily: it waits
// for the window, never walks beside what it shares something with (issue 973).
plans, err := inv.OpenPlans(ctx) plans, err := inv.OpenPlans(ctx)
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
@@ -544,6 +548,11 @@ func newBatch(ctx context.Context, inv *inventory.Inventory, first inventory.Bat
// keepBatch writes a batch as its merges and the clock say it now: the commits it holds, the merges they // keepBatch writes a batch as its merges and the clock say it now: the commits it holds, the merges they
// answer, its window, and assembling or queued behind a walk. Saved only when that changed. // answer, its window, and assembling or queued behind a walk. Saved only when that changed.
func keepBatch(ctx context.Context, inv *inventory.Inventory, b *inventory.Plan, now time.Time, behind string) error { func keepBatch(ctx context.Context, inv *inventory.Inventory, b *inventory.Plan, now time.Time, behind string) error {
return keepBatchBecause(ctx, inv, b, now, behind, "")
}
// keepBatchBecause is keepBatch for a batch queued behind a walk it is related to, which its words say (issue 973).
func keepBatchBecause(ctx context.Context, inv *inventory.Inventory, b *inventory.Plan, now time.Time, behind, why string) error {
merges, err := inv.MergesOf(ctx, b.ID) merges, err := inv.MergesOf(ctx, b.ID)
if err != nil { if err != nil {
return err return err
@@ -574,13 +583,13 @@ func keepBatch(ctx context.Context, inv *inventory.Inventory, b *inventory.Plan,
cutAtOnce = was.CutAtOnce // the merge's, until another joins it (ADR 0304 decision 1) cutAtOnce = was.CutAtOnce // the merge's, until another joins it (ADR 0304 decision 1)
waits = was.Waits // its group's, as said when it was made (ADR 0306 decision 2) waits = was.Waits // its group's, as said when it was made (ADR 0306 decision 2)
} }
b.Delivery.Batch = &inventory.PlanBatch{ClosesAt: closes, AtMost: latest, Behind: behind, Own: b.OwnPath(), b.Delivery.Batch = &inventory.PlanBatch{ClosesAt: closes, AtMost: latest, Behind: behind, BehindWhy: why, Own: b.OwnPath(),
Closed: closed, Withheld: withheld, CutAtOnce: cutAtOnce, Waits: waits} Closed: closed, Withheld: withheld, CutAtOnce: cutAtOnce, Waits: waits}
b.State = inventory.PlanAssembling b.State = inventory.PlanAssembling
if behind != "" && windowClosed(*b, now) { if behind != "" && windowClosed(*b, now) {
b.State = inventory.PlanQueued b.State = inventory.PlanQueued
} else { } else {
b.Delivery.Batch.Behind = "" b.Delivery.Batch.Behind, b.Delivery.Batch.BehindWhy = "", ""
} }
b.Note = batchWords(*b, now) b.Note = batchWords(*b, now)
if unNoted(*b) == before { if unNoted(*b) == before {
@@ -761,9 +770,9 @@ func batchCutter(ctx context.Context, open *stores) {
} }
// cutBatchesHeld takes the step that is due, for a caller holding the plans: after a failed walk, the search // cutBatchesHeld takes the step that is due, for a caller holding the plans: after a failed walk, the search
// for the merge that brought the failure; with no walk open, the next merge of that search walked alone, or a // for the merge that brought the failure; with no walk open, the next merge of that search walked alone; else
// closed batch cut into one walk with the waiting walks folded in; with a walk open, a closed batch said // each closed batch cut into a walk with the waiting walks it is related to folded in, unless it is related to a
// queued behind it. // walk open, behind which it is said queued (cutUnrelated). While the search runs everything waits.
func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error { func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
inv := open.inventory inv := open.inventory
if err := searchAfterFailures(ctx, inv); err != nil { if err := searchAfterFailures(ctx, inv); err != nil {
@@ -780,8 +789,7 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
if err != nil { if err != nil {
return err return err
} }
var started *inventory.Plan var started, waiting []inventory.Plan
var waiting []inventory.Plan
for i := range plans { for i := range plans {
p := plans[i] p := plans[i]
if p.Release != nil { if p.Release != nil {
@@ -789,8 +797,8 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
} }
if p.Waiting() { if p.Waiting() {
waiting = append(waiting, p) waiting = append(waiting, p)
} else if started == nil { } else {
started = &plans[i] started = append(started, p)
} }
} }
batches, err := inv.Batches(ctx) batches, err := inv.Batches(ctx)
@@ -805,46 +813,113 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
} }
return nil return nil
} }
if started != nil {
// **One walk at a time** (ADR 0276 decision 4): every batch waits behind it, whatever its window says.
return keepAll(started.ID)
}
if len(alone) > 0 { if len(alone) > 0 {
// A failed walk's search walks its merges alone, one at a time: everything waits for it (kept as it was,
// issue 973).
if len(started) > 0 {
return keepAll(started[0].ID)
}
if len(waiting) > 0 { if len(waiting) > 0 {
// The waiting walk is the one open; the search walks after it, and the batches wait behind both. // The waiting walk is the one open; the search walks after it, and the batches wait behind both.
return keepAll(waiting[0].ID) return keepAll(waiting[0].ID)
} }
return walkAlone(ctx, open, alone[0], now) return walkAlone(ctx, open, alone[0], now)
} }
if err := keepAll(""); err != nil { return cutUnrelated(ctx, open, batches, started, waiting, now)
}
// cutUnrelated cuts the closed batches that share nothing with an open walk, each into a walk beside the open
// ones, and says the others queued behind the first walk they are related to (novox/hq ADR 0407 decision 4,
// issue 973). A batch cut in this look counts as open for the batches after it.
//
// - An own-path batch goes first and folds no waiting walk; a catalogue batch folds the waiting walks it is
// related to, and those it shares nothing with keep waiting.
// - **The controller's own path is related to everything** (decided for issue 973): a walk or batch that
// replaces the controller, the node-engine, the tool runner, the bus or mesh-delivery never runs beside
// another, and nothing runs beside it.
// - A batch whose window is still open is assembling; one related to an open walk is queued behind it, and
// ADR 0276 decision 4 stands for it: nothing supersedes a walk that started.
func cutUnrelated(ctx context.Context, open *stores, batches, started, waiting []inventory.Plan, now time.Time) error {
inv := open.inventory
sort.SliceStable(batches, func(i, j int) bool { return batches[i].OwnPath() && !batches[j].OwnPath() })
var entries []inventory.Entry
var read map[string][]inventory.ReadRepository
var edges []inventory.Edge
judged := false
openWalks, err := openWalksOf(ctx, inv, started)
if err != nil {
return err return err
} }
// The oldest batch whose window closed is cut, an own-path one before a catalogue one (it goes first, and
// the catalogue batch queues behind it rather than being cut and overtaken). Several of a kind may be open
// since a merge that shares nothing is cut at once (ADR 0304 decision 1): walks stay one at a time, so one
// is cut per look, and once its walk started the others are queued behind it.
sort.SliceStable(batches, func(i, j int) bool { return batches[i].OwnPath() && !batches[j].OwnPath() })
for i := range batches { for i := range batches {
b := &batches[i] b := &batches[i]
if !windowClosed(*b, now) { if !windowClosed(*b, now) {
if err := keepBatch(ctx, inv, b, now, ""); err != nil {
return err
}
continue continue
} }
if b.OwnPath() { if !judged {
// A walk on the controller's own path folds no walk waiting for its word (decided during the build, var err error
// 2026-10-10): that walk keeps waiting beside it, and starts once this one ended (advanceOnce). if entries, err = inv.Catalogued(ctx); err != nil {
waiting = nil
}
if err := cutBatch(ctx, open, b, waiting, now); err != nil {
return err return err
} }
// The batches left wait behind the walk just cut when it started; the next look does the same. if read, err = readForPlanning(ctx, inv); err != nil {
if b.Open() && !b.Waiting() {
if batches, err = inv.Batches(ctx); err != nil {
return err return err
} }
return keepAll(b.ID) if edges, err = inv.Dependencies(ctx); err != nil {
return err
}
judged = true
}
held, err := inv.MergesOf(ctx, b.ID)
if err != nil {
return err
}
fp := footprintAsCut(held, entries, read, edges)
var fold, keep []inventory.Plan
if !b.OwnPath() {
for _, w := range waiting {
ww, err := openWalkOf(ctx, inv, w)
if err != nil {
return err
}
if ok, _ := related(fp, ww.fp, edges); ok {
fold = append(fold, w)
fp = unionFootprint(fp, ww.fp)
} else {
keep = append(keep, w)
}
}
}
if behind, why := relatedOpen(fp, b.OwnPath(), openWalks, edges); behind != "" {
if err := keepBatchBecause(ctx, inv, b, now, behind, why); err != nil {
return err
}
continue
}
if err := keepBatch(ctx, inv, b, now, ""); err != nil {
return err
}
if err := cutBatch(ctx, open, b, fold, now); err != nil {
return err
}
waiting = keep
cut, err := inv.PlanByID(ctx, b.ID)
if err != nil {
return err
}
if !cut.Open() {
continue
}
w, err := openWalkOf(ctx, inv, cut)
if err != nil {
return err
}
if cut.Waiting() {
waiting = append(waiting, cut)
} else {
openWalks = append(openWalks, w)
} }
return nil
} }
return nil return nil
} }
@@ -1396,7 +1471,11 @@ func batchWords(b inventory.Plan, now time.Time) string {
grouped += " (on the controller's own path)" grouped += " (on the controller's own path)"
} }
if b.State == inventory.PlanQueued { if b.State == inventory.PlanQueued {
return fmt.Sprintf("queued behind %s; grouped: %s; plan not yet calculated", w.Behind, grouped) because := ""
if w.BehindWhy != "" {
because = " (" + w.BehindWhy + ")"
}
return fmt.Sprintf("queued behind %s%s; grouped: %s; plan not yet calculated", w.Behind, because, grouped)
} }
if w.CutAtOnce { if w.CutAtOnce {
return fmt.Sprintf("cut at once, sharing nothing with a batch assembling; grouped: %s; plan not yet calculated", return fmt.Sprintf("cut at once, sharing nothing with a batch assembling; grouped: %s; plan not yet calculated",
+9 -9
View File
@@ -334,8 +334,8 @@ func captured(t *testing.T, run func() error) string {
return b.String() return b.String()
} }
// One walk at a time: a merge heard while a walk runs joins the next batch, which is cut when the walk ends; // A merge heard during a walk it is related to (the same repository, issue 973) joins the next batch, which is
// the walk that started is never superseded. // cut when the walk ends; the walk that started is never superseded (ADR 0276 decision 4, ADR 0407 decision 4).
func TestAMergeDuringAWalkJoinsTheNextBatch(t *testing.T) { func TestAMergeDuringAWalkJoinsTheNextBatch(t *testing.T) {
open := windowed(t) open := windowed(t)
asksWithPaths(t) asksWithPaths(t)
@@ -346,7 +346,7 @@ func TestAMergeDuringAWalkJoinsTheNextBatch(t *testing.T) {
t.Fatalf("the first batch was not cut: %+v", ws) t.Fatalf("the first batch was not cut: %+v", ws)
} }
first := ws[0] first := ws[0]
hear(t, open, repoMerge("two", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute)) hear(t, open, repoMerge("one", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute))
cutAt(t, open, t0.Add(5*time.Minute)) cutAt(t, open, t0.Add(5*time.Minute))
ws, bs := walks(t, open) ws, bs := walks(t, open)
if len(ws) != 1 || len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != first.ID { if len(ws) != 1 || len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != first.ID {
@@ -391,7 +391,7 @@ func TestAWaitingWalkIsFoldedIntoTheNextBatch(t *testing.T) {
t.Fatalf("the walk does not wait for its word: %+v", ws) t.Fatalf("the walk does not wait for its word: %+v", ws)
} }
waiting := ws[0] waiting := ws[0]
hear(t, open, repoMerge("two", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute)) hear(t, open, repoMerge("one", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute))
cutAt(t, open, t0.Add(2*time.Minute+90*time.Second)) cutAt(t, open, t0.Add(2*time.Minute+90*time.Second))
ws, bs := walks(t, open) ws, bs := walks(t, open)
if len(bs) != 0 || len(ws) != 2 { if len(bs) != 0 || len(ws) != 2 {
@@ -405,7 +405,7 @@ func TestAWaitingWalkIsFoldedIntoTheNextBatch(t *testing.T) {
if folded.State != inventory.PlanSuperseded || folded.Delivery.TakenOverBy != newer.ID { if folded.State != inventory.PlanSuperseded || folded.Delivery.TakenOverBy != newer.ID {
t.Fatalf("the waiting walk is %s, taken over by %q", folded.State, folded.Delivery.TakenOverBy) t.Fatalf("the waiting walk is %s, taken over by %q", folded.State, folded.Delivery.TakenOverBy)
} }
if len(newer.Delivery.Merges) != 2 || newer.CommitOf("novox/one") != "c1" || newer.CommitOf("novox/two") != "c2" { if len(newer.Delivery.Merges) != 2 || newer.CommitOf("novox/one") != "c2" {
t.Fatalf("the newer walk answers %+v", newer.Delivery.Merges) t.Fatalf("the newer walk answers %+v", newer.Delivery.Merges)
} }
if _, in := newer.Modules["one"]; !in { if _, in := newer.Modules["one"]; !in {
@@ -662,8 +662,8 @@ func TestALateMergeOfAOneModuleRepositoryIsNamed(t *testing.T) {
} }
} }
// One walk at a time holds against the delivery's word too: a waiting walk let go beside a started one takes the // A related walk waits for the delivery's word too: a waiting walk let go beside a started one it is related to
// word and starts once that one ended, asking nothing before. // (the same repository, issue 973) takes the word and starts once that one ended, asking nothing before.
func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) { func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) {
open := windowed(t) open := windowed(t)
asked := asksWithPaths(t) asked := asksWithPaths(t)
@@ -671,8 +671,8 @@ func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) {
hear(t, open, repoMerge("one", "c1", t0), t0) hear(t, open, repoMerge("one", "c1", t0), t0)
cutAt(t, open, t0.Add(2*time.Minute)) cutAt(t, open, t0.Add(2*time.Minute))
started, _ := walks(t, open) started, _ := walks(t, open)
waiting := inventory.Plan{ID: "plan-waiting", Repository: "novox/two", Branch: "main", Commit: "d1", Created: t0, waiting := inventory.Plan{ID: "plan-waiting", Repository: "novox/one", Branch: "main", Commit: "d1", Created: t0,
State: inventory.PlanBuilding, Tiers: [][]string{{"two"}}, Modules: map[string]*inventory.PlanModule{"two": {}}, State: inventory.PlanBuilding, Tiers: [][]string{{"one"}}, Modules: map[string]*inventory.PlanModule{"one": {}},
Delivery: &inventory.PlanDelivery{Awaits: catalogue.DeliverySeat}} Delivery: &inventory.PlanDelivery{Awaits: catalogue.DeliverySeat}}
if err := open.inventory.SavePlan(ctx, &waiting); err != nil { if err := open.inventory.SavePlan(ctx, &waiting); err != nil {
t.Fatal(err) t.Fatal(err)
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
)
// An unrelated closed batch is cut into a walk beside an open one, and that walk asks its first tier at once
// (issue 973, novox/hq ADR 0407 decision 4): a hotfix is never held by an unrelated delivery.
func TestAnUnrelatedBatchWalksBesideAnOpenWalk(t *testing.T) {
open := windowed(t)
asksWithPaths(t)
ctx := t.Context()
hear(t, open, repoMerge("one", "c1", t0), t0)
cutAt(t, open, t0.Add(90*time.Second))
if ws, _ := walks(t, open); len(ws) != 1 || !ws[0].Open() {
t.Fatalf("the first walk is not open: %+v", ws)
}
heard := t0.Add(100 * time.Second)
hear(t, open, lone(repoMerge("two", "c2", heard)), heard)
advanceHeld(ctx, open)
ws, bs := walks(t, open)
if len(ws) != 2 || len(bs) != 0 {
t.Fatalf("%d walk(s) and %d batch(es); want the unrelated batch cut beside the open walk", len(ws), len(bs))
}
for _, w := range ws {
if !w.Open() || w.Waiting() || deferred(w) || !askedAny(w) {
t.Errorf("%s (%s) is not walking: open %v, deferred %v, asked %v, note %q", w.ID, w.Named(), w.Open(),
deferred(w), askedAny(w), w.Note)
}
}
}
// A closed batch related to an open walk is queued behind it with the reason, while one that shares nothing is
// cut beside it; the walk that started is never superseded (ADR 0276 decision 4, issue 973).
func TestARelatedBatchWaitsBehindTheOpenWalk(t *testing.T) {
open := windowed(t)
asksWithPaths(t)
ctx := t.Context()
hear(t, open, repoMerge("one", "c1", t0), t0)
cutAt(t, open, t0.Add(90*time.Second))
first := func() inventory.Plan { ws, _ := walks(t, open); return ws[len(ws)-1] }()
heard := t0.Add(100 * time.Second)
hear(t, open, lone(repoMerge("one", "c2", heard)), heard)
hear(t, open, lone(repoMerge("three", "c3", heard.Add(time.Second))), heard.Add(time.Second))
ws, bs := walks(t, open)
if len(ws) != 2 || len(bs) != 1 {
t.Fatalf("%d walk(s) and %d batch(es); want the unrelated merge walking and the related one queued", len(ws), len(bs))
}
b := bs[0]
words := batchWords(b, heard)
if b.State != inventory.PlanQueued || b.Delivery.Batch.Behind != first.ID || b.CommitOf("novox/one") != "c2" ||
!strings.Contains(words, "queued behind "+first.ID) || !strings.Contains(words, "both change novox/one") {
t.Fatalf("the related batch is %s, behind %q, reading %q", b.State, b.Delivery.Batch.Behind, words)
}
if got, _ := open.inventory.PlanByID(ctx, first.ID); !got.Open() {
t.Fatalf("the started walk was %s by the related merge", got.State)
}
first.State = inventory.PlanDone
if err := open.inventory.SavePlan(ctx, &first); err != nil {
t.Fatal(err)
}
cutAt(t, open, heard.Add(2*time.Second))
if _, bs := walks(t, open); len(bs) != 0 {
t.Fatalf("the queued batch was not cut once the walk it waited for ended: %+v", bs)
}
}
// A batch on the controller's own path never walks beside another walk, and nothing walks beside it, whatever
// the two share (issue 973): it replaces the controller, so it goes alone.
func TestAnOwnPathBatchNeverWalksBeside(t *testing.T) {
open := windowed(t)
asksWithPaths(t)
ctx := t.Context()
hear(t, open, repoMerge("one", "c1", t0), t0)
cutAt(t, open, t0.Add(90*time.Second))
ws, _ := walks(t, open)
first := ws[0]
heard := t0.Add(100 * time.Second)
hear(t, open, lone(repoMerge("mesh-controller", "k1", heard)), heard)
ws, bs := walks(t, open)
if len(ws) != 1 || len(bs) != 1 || !bs[0].OwnPath() || bs[0].State != inventory.PlanQueued ||
bs[0].Delivery.Batch.Behind != first.ID || !strings.Contains(batchWords(bs[0], heard), "own path") {
t.Fatalf("an own-path batch beside an open walk: %d walk(s), batches %+v", len(ws), bs)
}
first.State = inventory.PlanDone
if err := open.inventory.SavePlan(ctx, &first); err != nil {
t.Fatal(err)
}
cutAt(t, open, heard.Add(time.Second))
ws, _ = walks(t, open)
if len(ws) != 2 || !ws[0].Open() || ws[0].CommitOf("novox/mesh-controller") != "k1" {
t.Fatalf("the own-path batch was not cut once the walk ended: %+v", ws)
}
own := ws[0]
// Unrelated to the own-path walk by every footprint, a merge still waits for it.
later := heard.Add(time.Minute)
hear(t, open, lone(repoMerge("two", "c2", later)), later)
ws, bs = walks(t, open)
if len(ws) != 2 || len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != own.ID {
t.Fatalf("a merge beside the own-path walk: %d walk(s), batches %+v", len(ws), bs)
}
}
+6 -4
View File
@@ -227,8 +227,9 @@ func TestAMergeRelatedToTwoBatchesFoldsThem(t *testing.T) {
} }
} }
// With a walk open, a batch cut at once still waits for it, said queued behind it (walks stay one at a time, ADR // With a walk open that it is related to (the same repository, issue 973), a batch cut at once still waits for
// 0276 decision 4); it is cut once that walk ended, with no window to wait for. // it, said queued behind it (ADR 0276 decision 4, ADR 0407 decision 4); it is cut once that walk ended, with no
// window to wait for.
func TestACutAtOnceBatchQueuesBehindAnOpenWalk(t *testing.T) { func TestACutAtOnceBatchQueuesBehindAnOpenWalk(t *testing.T) {
open := windowed(t) open := windowed(t)
groupsWaiting(t, "pr/c1") groupsWaiting(t, "pr/c1")
@@ -241,7 +242,7 @@ func TestACutAtOnceBatchQueuesBehindAnOpenWalk(t *testing.T) {
} }
first := ws[0] first := ws[0]
heard := t0.Add(100 * time.Second) heard := t0.Add(100 * time.Second)
hear(t, open, lone(repoMerge("two", "c2", heard)), heard) hear(t, open, lone(repoMerge("one", "c2", heard)), heard)
ws, bs := walks(t, open) ws, bs := walks(t, open)
if len(ws) != 1 || len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != first.ID || if len(ws) != 1 || len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != first.ID ||
!bs[0].Delivery.Batch.CutAtOnce { !bs[0].Delivery.Batch.CutAtOnce {
@@ -280,7 +281,8 @@ func TestAStopWalksTheOthersAgainInABatchOfTheirOwnWhenTheyShareNothing(t *testi
cutAt(t, open, t0.Add(2*time.Minute)) cutAt(t, open, t0.Add(2*time.Minute))
ws, _ := walks(t, open) ws, _ := walks(t, open)
walk := ws[0] walk := ws[0]
hear(t, open, repoMerge("two", "two1cccccccc", t0.Add(3*time.Minute)), t0.Add(3*time.Minute)) // Related to the walk by its repository (queued behind it), and to nothing the stop walks again.
hear(t, open, catalogueMerge("cat2cccccccc", "notes", t0.Add(3*time.Minute)), t0.Add(3*time.Minute))
_, bs := walks(t, open) _, bs := walks(t, open)
if len(bs) != 1 { if len(bs) != 1 {
t.Fatalf("the merge heard while the walk ran is in %d batches", len(bs)) t.Fatalf("the merge heard while the walk ran is in %d batches", len(bs))
+2
View File
@@ -119,6 +119,8 @@ type PlanBatch struct {
ClosesAt time.Time `json:"closes_at"` ClosesAt time.Time `json:"closes_at"`
AtMost time.Time `json:"at_most"` AtMost time.Time `json:"at_most"`
Behind string `json:"behind,omitempty"` Behind string `json:"behind,omitempty"`
// BehindWhy says why the batch waits behind that walk: what the two share (issue 973).
BehindWhy string `json:"behind_why,omitempty"`
// Own says the batch holds merges on the controller's own path, whose walk waits for nobody's word: such // Own says the batch holds merges on the controller's own path, whose walk waits for nobody's word: such
// a merge never shares a batch with one that waits for mesh-delivery's (ADR 0276, decided during the // a merge never shares a batch with one that waits for mesh-delivery's (ADR 0276, decided during the
// build, 2026-10-10). // build, 2026-10-10).
+3
View File
@@ -128,3 +128,6 @@
./cmd/mesh-controller TestAGroupsCompositionIntoAReleaseBranchIsJudgedByItsOwnController ./cmd/mesh-controller TestAGroupsCompositionIntoAReleaseBranchIsJudgedByItsOwnController
./cmd/mesh-controller TestAGroupsCompositionJudgedWithTheValidatorIntoAReleaseBranchIsJudgedByItsOwnController ./cmd/mesh-controller TestAGroupsCompositionJudgedWithTheValidatorIntoAReleaseBranchIsJudgedByItsOwnController
./cmd/mesh-controller TestAssigningTheClientIssuesItsAccount ./cmd/mesh-controller TestAssigningTheClientIssuesItsAccount
./cmd/mesh-controller TestAnUnrelatedBatchWalksBesideAnOpenWalk
./cmd/mesh-controller TestARelatedBatchWaitsBehindTheOpenWalk
./cmd/mesh-controller TestAnOwnPathBatchNeverWalksBeside