From 57939d6dd4410d1c6da88136c313476019062a08 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 12:53:16 +0200 Subject: [PATCH 1/2] Ask a new module's first build when its merge is heard, not when the batch is cut, and not twice (issue 1617) --- cmd/mesh-controller/batches.go | 2 + .../new_module_asked_once_test.go | 37 +++++++++++++ .../new_module_on_merge_test.go | 35 ++++++++++++ cmd/mesh-controller/upgrades.go | 54 ++++++++++++++++++- 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 cmd/mesh-controller/new_module_asked_once_test.go diff --git a/cmd/mesh-controller/batches.go b/cmd/mesh-controller/batches.go index 9898ee8d..2a3fd21a 100644 --- a/cmd/mesh-controller/batches.go +++ b/cmd/mesh-controller/batches.go @@ -232,6 +232,8 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T fmt.Print(nothingReadsIt(repository, m)) return nil } + // **A module the merge adds is asked for now** (novox/hq issue 1617), not when the batch is cut. + askNewModulesAtHearing(ctx, inv, m, entries, read, facts.from) // **Its delivery group's word, before the plans are held** (ADR 0306 decision 2): a slow answer holds up no walk. own := ownPath(touches) waits, holding, err := groupWaitHolding(ctx, inv, m, deliverySeatHeld(entries)) diff --git a/cmd/mesh-controller/new_module_asked_once_test.go b/cmd/mesh-controller/new_module_asked_once_test.go new file mode 100644 index 00000000..d2dc87fa --- /dev/null +++ b/cmd/mesh-controller/new_module_asked_once_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "reflect" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// 1617/AC2 (unit): a new module whose first build a merge's hearing asked is not asked again when its batch is cut, +// and one whose ask failed, was never handed over or is long lost is. +func TestAModuleWhoseBuildIsComingIsNotAskedAgain(t *testing.T) { + now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC) + source := buildSource{Repository: "http://forge/novox/mesh-catalog.git", Seat: "git"} + asked := func(path string, mod func(*inventory.RequestOutcome)) inventory.RequestOutcome { + r := inventory.RequestOutcome{BuildRequest: inventory.BuildRequest{ID: "b-" + path, Repository: source.Repository, + Seat: "git", Path: path, For: "merge", At: now.Add(-3 * time.Minute)}} + if mod != nil { + mod(&r) + } + return r + } + requests := []inventory.RequestOutcome{ + asked("modules/go", nil), + asked("modules/failed", func(r *inventory.RequestOutcome) { r.Heard, r.Failed = true, "no module.json" }), + asked("modules/notasked", func(r *inventory.RequestOutcome) { r.NotAsked = "the bus is silent" }), + asked("modules/lost", func(r *inventory.RequestOutcome) { r.At = now.Add(-2 * time.Hour) }), + asked("modules/byhand", func(r *inventory.RequestOutcome) { r.For = "build" }), + } + got := unaskedDirs([]string{"modules/go", "modules/failed", "modules/notasked", "modules/lost", "modules/byhand", + "modules/nodejs"}, requests, source, now) + want := []string{"modules/failed", "modules/notasked", "modules/lost", "modules/byhand", "modules/nodejs"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("asked again %v, want %v", got, want) + } +} diff --git a/cmd/mesh-controller/new_module_on_merge_test.go b/cmd/mesh-controller/new_module_on_merge_test.go index 9dc955ea..c41c958f 100644 --- a/cmd/mesh-controller/new_module_on_merge_test.go +++ b/cmd/mesh-controller/new_module_on_merge_test.go @@ -6,6 +6,7 @@ import ( "context" "sync" "testing" + "time" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-controller/internal/inventory" @@ -99,3 +100,37 @@ func TestAMergeBuildsItsNewModuleBesideItsPlan(t *testing.T) { t.Fatalf("a merge adding no module asked for %v", *asked) } } + +// 1617/AC1 (integration): a merge adding a module to a repository the mesh builds modules from, heard while a batch of +// that repository is still assembling (its window running), asks the module's first build when it is heard, so the +// module is registered while the batch waits; cutting the batch asks it no second time (1617/AC2). +func TestAMergeAddingAModuleAsksItsBuildBeforeItsBatchIsCut(t *testing.T) { + open := windowed(t) + asked := asksWithPaths(t) + catalog := func(commit string, made time.Time, paths ...string) link.SourceMoved { + return link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit, + MergedAt: made.Format(time.RFC3339Nano), Paths: paths} + } + hear(t, open, catalog("c1", t0, "modules/app/index.ts"), t0) + if len(*asked) != 0 { + t.Fatalf("a merge adding no module asked %v", *asked) + } + hear(t, open, catalog("c2", t0.Add(10*time.Second), "modules/go/packages.json", "modules/go/module.json"), t0.Add(10*time.Second)) + if ws, bs := walks(t, open); len(ws) != 0 || len(bs) != 1 { + t.Fatalf("the batch should still be assembling: %d walk(s), %d batch(es)", len(ws), len(bs)) + } + want := [3]string{"novox/mesh-catalog", "modules/go", "main"} + if len(*asked) != 1 || (*asked)[0] != want { + t.Fatalf("heard with its batch assembling, the merge asked %v, not %v", *asked, want) + } + cutAt(t, open, t0.Add(10*time.Second+90*time.Second)) + n := 0 + for _, a := range *asked { + if a[1] == "modules/go" { + n++ + } + } + if n != 1 { + t.Fatalf("modules/go was asked %d times: %v", n, *asked) + } +} diff --git a/cmd/mesh-controller/upgrades.go b/cmd/mesh-controller/upgrades.go index 57a426ee..da962d91 100644 --- a/cmd/mesh-controller/upgrades.go +++ b/cmd/mesh-controller/upgrades.go @@ -415,7 +415,59 @@ func askNewModules(ctx context.Context, inv *inventory.Inventory, m link.SourceM if len(added) == 0 || len(from) == 0 { return nil } - return askBuilds(ctx, inv, m, buildSource{Repository: from[0].Source.Repository, Seat: from[0].Source.Seat}, added) + source := buildSource{Repository: from[0].Source.Repository, Seat: from[0].Source.Seat} + // Asked once (novox/hq issue 1617): a module is asked when its merge is heard, and again here when the batch is cut. + var requests []inventory.RequestOutcome + for _, dir := range added { + if got, err := inv.RequestsNamed(ctx, inventory.BuildRequest{Repository: source.Repository, Path: dir}.Name()); err == nil { + requests = append(requests, got...) + } + } + return askBuilds(ctx, inv, m, source, unaskedDirs(added, requests, source, time.Now())) +} + +// askedInFlightFor is how long a merge's build request stands as the build coming for its module: past it, a lost one +// is asked again. +const askedInFlightFor = time.Hour + +// unaskedDirs are the directories of a new module no build of a merge is on its way for: a kept request of the same +// repository and directory, asked for a merge and handed over, whose outcome has not come and is not given up on, +// is the build coming (novox/hq issue 1617). +func unaskedDirs(added []string, requests []inventory.RequestOutcome, source buildSource, now time.Time) []string { + var out []string + for _, dir := range added { + path := dir + if path == "." { + path = "" + } + coming := false + for _, r := range requests { + if r.For == "merge" && r.Repository == source.Repository && strings.Trim(r.Path, "/") == path && + r.NotAsked == "" && r.OutcomeUnknown == "" && !r.Heard && now.Sub(r.At) < askedInFlightFor { + coming = true + break + } + } + if !coming { + out = append(out, dir) + } + } + return out +} + +// askNewModulesAtHearing asks the first build of the modules a merge adds to a repository the mesh builds modules from, +// when the merge is heard, not when its batch is cut (novox/hq issue 1617): a batch waits out its window and the walk +// before it, and a module added by a merge delivered meanwhile could not be assigned. Outside the batch and its plan. +func askNewModulesAtHearing(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, entries []inventory.Entry, + read map[string][]inventory.ReadRepository, from []inventory.Entry) { + if len(from) == 0 { + return + } + _, added, _ := touchedBy(from, entries, m, read) + if asked := askNewModules(ctx, inv, m, from, added); len(asked) > 0 { + fmt.Printf("%s/%s merged into %s (%.8s) adds %s: the first build is asked now, before its batch is cut\n", + m.Owner, m.Repo, m.Base, m.Commit, strings.Join(asked, ", ")) + } } // askBuilds asks the first build of each directory of a merge's repository, as askNewModules does, from the source given. From 35829d5a9a8e959a485a43684181facf897be9a2 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 13:18:38 +0200 Subject: [PATCH 2/2] Keep the test's first batch assembling (issue 1617) --- cmd/mesh-controller/new_module_on_merge_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/mesh-controller/new_module_on_merge_test.go b/cmd/mesh-controller/new_module_on_merge_test.go index c41c958f..a5b38109 100644 --- a/cmd/mesh-controller/new_module_on_merge_test.go +++ b/cmd/mesh-controller/new_module_on_merge_test.go @@ -106,16 +106,17 @@ func TestAMergeBuildsItsNewModuleBesideItsPlan(t *testing.T) { // module is registered while the batch waits; cutting the batch asks it no second time (1617/AC2). func TestAMergeAddingAModuleAsksItsBuildBeforeItsBatchIsCut(t *testing.T) { open := windowed(t) + groupsWaiting(t, "pr/c1") asked := asksWithPaths(t) catalog := func(commit string, made time.Time, paths ...string) link.SourceMoved { return link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit, - MergedAt: made.Format(time.RFC3339Nano), Paths: paths} + Head: "pr/" + commit, MergedAt: made.Format(time.RFC3339Nano), Paths: paths} } hear(t, open, catalog("c1", t0, "modules/app/index.ts"), t0) if len(*asked) != 0 { t.Fatalf("a merge adding no module asked %v", *asked) } - hear(t, open, catalog("c2", t0.Add(10*time.Second), "modules/go/packages.json", "modules/go/module.json"), t0.Add(10*time.Second)) + hear(t, open, lone(catalog("c2", t0.Add(10*time.Second), "modules/go/packages.json", "modules/go/module.json")), t0.Add(10*time.Second)) if ws, bs := walks(t, open); len(ws) != 0 || len(bs) != 1 { t.Fatalf("the batch should still be assembling: %d walk(s), %d batch(es)", len(ws), len(bs)) }