From 4538d057325c10a273d6568774479f924c756c4b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 16:19:45 +0200 Subject: [PATCH] Ask a held first build again when a delivery of build-agent ends (issue 1173) ADR 0482 section 5: the build waiting for a build-agent that reads its manifest is asked once the plan delivering it ends. --- cmd/mesh-controller/first_builds.go | 55 +++++++++++ .../first_builds_held_integration_test.go | 91 +++++++++++++++++++ cmd/mesh-controller/first_builds_held_test.go | 22 +++++ cmd/mesh-controller/plan_looks.go | 4 + 4 files changed, 172 insertions(+) create mode 100644 cmd/mesh-controller/first_builds_held_integration_test.go diff --git a/cmd/mesh-controller/first_builds.go b/cmd/mesh-controller/first_builds.go index f173948d..961e20cf 100644 --- a/cmd/mesh-controller/first_builds.go +++ b/cmd/mesh-controller/first_builds.go @@ -310,3 +310,58 @@ func probeFirstBuilds(ctx context.Context, d *doctor) ([]conditions.Observation, } return firstBuildObservations(waiting, time.Now().UTC()), nil } + +// askHeldFirstBuildsAgain asks again the first build of each first merge held for a build-agent that reads its manifest, +// when the plan that ended delivered build-agent (novox/hq issue 1173): once for each end, since the ask answers the merge +// and a refusal that comes again holds it anew. Answers how many merges it asked for. +func askHeldFirstBuildsAgain(ctx context.Context, open *stores, p inventory.Plan) int { + if !deliversBuildAgent(p) { + return 0 + } + actingOnMerges.Lock() + defer actingOnMerges.Unlock() + inv := open.inventory + waiting, err := inv.WaitingMerges(ctx) + if err != nil { + fmt.Printf("%s delivered build-agent, and the first merges held for it could not be read: %v\n", p.ID, err) + return 0 + } + asked := 0 + for _, w := range waiting { + k := announcedOf(w) + if k.Held == nil { + continue + } + entries, err := inv.Catalogued(ctx) + if err != nil { + fmt.Printf("%s delivered build-agent, and the catalogue could not be read: %v\n", p.ID, err) + return asked + } + read, err := readForPlanning(ctx, inv) + if err != nil { + fmt.Printf("%s delivered build-agent, and what the builds read could not be read: %v\n", p.ID, err) + return asked + } + added, why, err := firstBuildsOf(ctx, inv, k.SourceMoved, entries, read) + if err != nil || len(added) == 0 { + fmt.Printf("%s delivered build-agent, and the merge %.8s of %s held for it asks no build now (%v): it stays "+ + "waiting\n", p.ID, w.Commit, w.Repository, err) + continue + } + fmt.Printf("%s delivered build-agent: the first build of %s, held for the word %q, is asked again\n", p.ID, + k.Held.Module, k.Held.Word) + if err := askFirstBuilds(ctx, inv, k.SourceMoved, time.Now().UTC(), added, why); err != nil { + fmt.Printf(" and could not be asked: %v\n", err) + continue + } + asked++ + } + return asked +} + +// deliversBuildAgent says a delivery plan ended delivered with build-agent built in it: the reader a held first build +// waits for is the one now running. +func deliversBuildAgent(p inventory.Plan) bool { + m := p.Modules["build-agent"] + return p.WalkSaid == inventory.WalkEnded && p.Outcome() == inventory.WalkDelivered && m != nil && m.State == "built" +} diff --git a/cmd/mesh-controller/first_builds_held_integration_test.go b/cmd/mesh-controller/first_builds_held_integration_test.go new file mode 100644 index 00000000..8fb33193 --- /dev/null +++ b/cmd/mesh-controller/first_builds_held_integration_test.go @@ -0,0 +1,91 @@ +//go:build integration + +package main + +import ( + "context" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// 1173/AC1, 1173/AC3 whole against the store: a first merge's build refused for a word the running build-agent does not +// read keeps the merge waiting with no failed build recorded; a delivery plan that does not deliver build-agent asks +// nothing; the one that ends delivering it asks the first build again once, and its pass registers the module. +func TestAHeldFirstMergeIsAskedAgainWhenABuildAgentIsDelivered(t *testing.T) { + const head, commit = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "0177ea7a00000000" + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + + a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head}) + wasKept, wasAsk, realAsk := keptDeliveryMerge, routerAskOf, askABuild + keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) { + return a.merge, true, nil + } + routerAskOf = func(context.Context, catalogueReader, string, string) (*asks.Ask, *asks.Warrant, error) { + return &a.ask, &a.warrant, nil + } + var asked []string + askABuild = func(_ context.Context, source buildSource, path, ref string) (string, error) { + id := "build-" + string(rune('1'+len(asked))) + asked = append(asked, id) + return id, nil + } + t.Cleanup(func() { keptDeliveryMerge, routerAskOf, askABuild = wasKept, wasAsk, realAsk }) + + m := link.SourceMoved{Owner: "novox", Repo: "mesh-delivery", Base: "main", HeadSHA: head, Commit: commit, Number: 7, + MergedAt: time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano), Paths: []string{"module.json", "main.go"}, + ModuleDirs: []string{"."}, ModuleDirsSaid: true} + if err := (following{open: open}).SourceMoved(ctx, m); err != nil { + t.Fatal(err) + } + if len(asked) != 1 { + t.Fatalf("1173/AC3: the first merge asked %v, want the one first build", asked) + } + + // The running build-agent refuses the manifest's word. + refused := onTrunk("build-1", "novox/mesh-delivery", "git", "", nil) + refused.Failed, refused.Manifest, refused.Commit = refusedKeyTTL, nil, commit + if err := (builds{inv: inv, open: open}).Built(ctx, refused); err != nil { + t.Fatal(err) + } + if _, recorded, _ := inv.BuildByID(ctx, "build-1"); recorded { + t.Fatal("1173/AC1: a refused first build was recorded as a failed build") + } + waiting, err := inv.WaitingMerges(ctx) + if err != nil || len(waiting) != 1 || announcedOf(waiting[0]).Held == nil { + t.Fatalf("1173/AC1: the merges waiting are %+v (%v), want the first merge, held", waiting, err) + } + if obs := firstBuildObservations(waiting, time.Now()); len(obs) != 1 || obs[0].Kind != kindFirstBuildHeld { + t.Fatalf("1173/AC2: the held merge is named by %+v", obs) + } + + // A plan that ended without delivering build-agent asks nothing; one that delivered it asks once. + other := inventory.Plan{ID: "p1", State: inventory.PlanDone, WalkSaid: inventory.WalkEnded, + Modules: map[string]*inventory.PlanModule{"mesh-delivery": {State: "built"}}} + if n := askHeldFirstBuildsAgain(ctx, open, other); n != 0 || len(asked) != 1 { + t.Fatalf("1173/AC3: a plan that delivered no build-agent asked %d builds again (%v)", n, asked) + } + delivered := inventory.Plan{ID: "p2", State: inventory.PlanDone, WalkSaid: inventory.WalkEnded, + Modules: map[string]*inventory.PlanModule{"build-agent": {State: "built"}}} + if n := askHeldFirstBuildsAgain(ctx, open, delivered); n != 1 || len(asked) != 2 { + t.Fatalf("1173/AC3: the plan that delivered build-agent asked %d builds again (%v), want one", n, asked) + } + if n := askHeldFirstBuildsAgain(ctx, open, delivered); n != 0 || len(asked) != 2 { + t.Fatalf("1173/AC3: the same end asked again: %d (%v)", n, asked) + } + + // Its pass registers the module. + pass := onTrunk("build-2", "novox/mesh-delivery", "git", "", map[string]any{"module": "mesh-delivery-watchman", "version": "1"}) + pass.Commit = commit + if err := (builds{inv: inv, open: open}).Built(ctx, pass); err != nil { + t.Fatal(err) + } + if _, known := inventoryHolds(t, inv, "mesh-delivery-watchman"); !known { + t.Fatal("1173/AC3: the module is not registered after the first build passed") + } +} diff --git a/cmd/mesh-controller/first_builds_held_test.go b/cmd/mesh-controller/first_builds_held_test.go index fbfe8db6..7190024d 100644 --- a/cmd/mesh-controller/first_builds_held_test.go +++ b/cmd/mesh-controller/first_builds_held_test.go @@ -83,3 +83,25 @@ func TestAFirstBuildFailingForAnyOtherReasonIsNotHeld(t *testing.T) { } } } + +// 1173/AC3 (the part that needs no store): only a plan that ended delivered with build-agent built in it is the end a held +// first build waits for. +func TestOnlyADeliveredPlanOfBuildAgentIsWhatAHeldFirstBuildWaitsFor(t *testing.T) { + plan := func(state, walk string, modules map[string]*inventory.PlanModule) inventory.Plan { + return inventory.Plan{ID: "p", State: state, WalkSaid: walk, Modules: modules} + } + built := map[string]*inventory.PlanModule{"build-agent": {State: "built"}} + if !deliversBuildAgent(plan(inventory.PlanDone, inventory.WalkEnded, built)) { + t.Fatal("1173/AC3: a plan that delivered build-agent is not the end a held first build waits for") + } + for what, p := range map[string]inventory.Plan{ + "a plan still running": plan(inventory.PlanBuilding, "", built), + "a plan that failed": plan(inventory.PlanFailed, inventory.WalkEnded, built), + "a failed build-agent": plan(inventory.PlanDone, inventory.WalkEnded, map[string]*inventory.PlanModule{"build-agent": {State: "failed"}}), + "another module's plan": plan(inventory.PlanDone, inventory.WalkEnded, map[string]*inventory.PlanModule{"mailu": {State: "built"}}), + } { + if deliversBuildAgent(p) { + t.Fatalf("1173/AC3: %s is the end a held first build waits for", what) + } + } +} diff --git a/cmd/mesh-controller/plan_looks.go b/cmd/mesh-controller/plan_looks.go index 855d6e02..c29e4065 100644 --- a/cmd/mesh-controller/plan_looks.go +++ b/cmd/mesh-controller/plan_looks.go @@ -209,6 +209,10 @@ func servingPlanSaved(ctx context.Context, bus link.Bus, open *stores) func(*inv keepBuildingKey(ctx, p) if p.WalkSaid == inventory.WalkEnded { go forgetChecksOfWalk(context.WithoutCancel(ctx), open.inventory, p) + // A first build refused for a word only a newer build-agent reads is asked again (novox/hq issue 1173). + if deliversBuildAgent(p) { + go askHeldFirstBuildsAgain(context.WithoutCancel(ctx), open, p) + } } } }