Compare commits

..
Author SHA1 Message Date
jochen d86baebe9a A plan settles an asked build from the build records (hq issue 214)
A merge to the controller's own repository replaces the controller in its first tier; the build
that produced the new one was recorded, the plan never heard it, and it waited for ever with every
later plan behind it. The record is the fact: a build recorded after the ask is the tier's outcome,
whoever was listening when it came.
2026-10-03 22:20:33 +02:00
4 changed files with 90 additions and 62 deletions
+56
View File
@@ -399,6 +399,24 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
} }
return true, nil return true, nil
} }
// **Asked: settle from the build records first** (novox/hq 04-ISSUES/214). An outcome is taken
// in by whichever controller hears it, and a merge to the controller's own repository replaces
// the controller in its first tier: the build that produced the new one is recorded, and the
// plan never hears it. The record is the fact; a build recorded after the ask is that tier's
// outcome, whoever was listening.
recorded := map[string][]inventory.Build{}
for _, m := range tier {
if s := p.Modules[m]; s != nil && s.State == "asked" {
builds, err := inv.Builds(ctx, m, 5)
if err != nil {
return false, err
}
recorded[m] = builds
}
}
if settleFromRecords(p, tier, recorded) {
return true, nil
}
// Asked: wait for every build. // Asked: wait for every build.
var latest time.Time var latest time.Time
for _, m := range tier { for _, m := range tier {
@@ -755,3 +773,41 @@ func splitList(s string) []string {
} }
return out return out
} }
// settleFromRecords marks every module of the tier still `asked` built — or failed — from a build
// recorded after it was asked, and says whether it changed anything (novox/hq 04-ISSUES/214).
// Newest first, as Builds answers: the first record after the ask is the outcome of that ask.
func settleFromRecords(p *inventory.Plan, tier []string, recorded map[string][]inventory.Build) bool {
changed := false
for _, m := range tier {
s := p.Modules[m]
if s == nil || s.State != "asked" || s.AskedAt == nil {
continue
}
var outcome *inventory.Build
for i := range recorded[m] {
b := recorded[m][i]
if b.At.Before(*s.AskedAt) {
break
}
outcome = &b
}
if outcome == nil {
continue
}
at := outcome.At
if outcome.Worked() {
s.State = "built"
s.BuiltAt = &at
s.Commit = outcome.Commit
} else {
s.State = "failed"
s.Why = outcome.Failed
p.State = inventory.PlanFailed
p.Note = fmt.Sprintf("%s failed to build in tier %d", m, p.Tier)
}
fmt.Printf("%s: %s settled from the build records as %s (%s)\n", p.ID, m, s.State, outcome.ID)
changed = true
}
return changed
}
+34 -8
View File
@@ -116,13 +116,39 @@ func TestACycleIsOneLastTierAndSaidSo(t *testing.T) {
} }
} }
// novox/hq 04-ISSUES/211: a merge moving the toolchain and a bundle compiled in it builds the // novox/hq 04-ISSUES/214: a plan whose build outcome was recorded while no controller followed it —
// bundle a tier after the toolchain, not beside it. // the controller rebuilding itself — settles from the build records instead of waiting for ever.
func TestABundleIsPlannedAfterTheToolchainItIsCompiledIn(t *testing.T) { func TestAPlanSettlesAnAskedBuildFromTheRecords(t *testing.T) {
edges := []inventory.Edge{{From: "node-tools", To: "mesh-tools", Kind: inventory.EdgeStandsOn}} asked := time.Date(2026, 10, 3, 19, 20, 0, 0, time.UTC)
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"}, p := inventory.Plan{ID: "plan-1", Tiers: [][]string{{"mesh-controller", "builder"}, {"route-proxy"}},
[]string{"mesh-tools", "node-tools"}, edges) Modules: map[string]*inventory.PlanModule{
if len(p.Tiers) != 2 || p.Tiers[0][0] != "mesh-tools" || p.Tiers[1][0] != "node-tools" { "mesh-controller": {State: "asked", AskedAt: &asked},
t.Fatalf("the toolchain, then the bundle: %v", p.Tiers) "builder": {State: "asked", AskedAt: &asked},
}}
records := map[string][]inventory.Build{
// Newest first, as Builds answers: the build after the ask is the outcome.
"mesh-controller": {
{ID: "build-2", Commit: "2ebbb799", At: asked.Add(4 * time.Minute)},
{ID: "build-1", Commit: "06ea2168", At: asked.Add(-10 * time.Minute)},
},
// Only a build from before the ask: not this ask's outcome.
"builder": {{ID: "build-0", Commit: "06ea2168", At: asked.Add(-time.Hour)}},
}
if !settleFromRecords(&p, p.Tiers[0], records) {
t.Fatal("nothing settled, though the controller's build is recorded after the ask")
}
if s := p.Modules["mesh-controller"]; s.State != "built" || s.Commit != "2ebbb799" || s.BuiltAt == nil {
t.Errorf("the controller's ask is %+v, want built from 2ebbb799", s)
}
if s := p.Modules["builder"]; s.State != "asked" {
t.Errorf("an ask with no record after it was settled: %+v", s)
}
// A failure recorded after the ask fails the plan, as hearing it would have.
q := inventory.Plan{ID: "plan-2", Tiers: [][]string{{"x"}},
Modules: map[string]*inventory.PlanModule{"x": {State: "asked", AskedAt: &asked}}}
settleFromRecords(&q, q.Tiers[0], map[string][]inventory.Build{"x": {{ID: "b", Failed: "no", At: asked.Add(time.Minute)}}})
if q.State != inventory.PlanFailed || q.Modules["x"].State != "failed" {
t.Errorf("a recorded failure did not fail the plan: %+v %+v", q, q.Modules["x"])
} }
} }
-16
View File
@@ -5,7 +5,6 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/novox/mesh-controller/internal/builder"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
) )
@@ -97,21 +96,6 @@ func dependenciesOf(entries []Entry, against map[string][]string, read map[strin
add(name, on.Module, EdgeDeclared) add(name, on.Module, EdgeDeclared)
} }
} }
// **A bundle stands on the toolchain it is compiled in** (novox/hq 04-ISSUES/211). A
// manifest names its toolchain by language, not in `build.on`, so the edge was implicit
// and a merge that moved the toolchain and a bundle together built both in one tier —
// the bundle against the toolchain as it was, recorded as built from the new commit. Read
// from the manifest, so it holds before any build has recorded what it stood on; and a
// toolchain that moves rebuilds every bundle compiled in it, which is what a toolchain
// carrying a bundle's dependencies requires.
for _, a := range e.Manifest.Build.Artifacts {
if a.Kind != catalogue.ArtifactBundle {
continue
}
if chain, err := builder.ToolchainFor(a.Language); err == nil {
add(name, chain.Base, EdgeStandsOn)
}
}
} }
for _, ref := range against[name] { for _, ref := range against[name] {
if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok { if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok {
-38
View File
@@ -62,41 +62,3 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
} }
} }
} }
// novox/hq 04-ISSUES/211: a bundle stands on the toolchain it is compiled in, so a merge moving both
// builds the toolchain first — read from the manifest, before any build recorded it.
func TestABundleStandsOnTheToolchainItIsCompiledIn(t *testing.T) {
entries := []Entry{
{Manifest: catalogue.Manifest{Module: "mesh-tools"}, Source: Source{Repository: "novox/mesh-tools"}},
{Manifest: catalogue.Manifest{Module: "mesh-tools-go"}, Source: Source{Repository: "novox/mesh-tools-go"}},
{Manifest: catalogue.Manifest{Module: "node-tools", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
{Name: "runtime", Kind: catalogue.ArtifactBundle, Language: "go", System: "arch", From: "cmd/node-tools"}}}},
Source: Source{Repository: "novox/mesh-tools"}},
{Manifest: catalogue.Manifest{Module: "nftables", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}},
Source: Source{Repository: "novox/mesh-catalog"}},
{Manifest: catalogue.Manifest{Module: "photos", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
{Name: "server", Kind: catalogue.ArtifactImage, From: "Dockerfile"}}}},
Source: Source{Repository: "novox/photos"}},
}
edges := dependenciesOf(entries, nil, nil)
has := func(from, to string) bool {
for _, e := range edges {
if e.From == from && e.To == to && e.Kind == EdgeStandsOn {
return true
}
}
return false
}
if !has("nftables", "mesh-tools") {
t.Errorf("a TypeScript bundle does not stand on the TypeScript toolchain: %v", edges)
}
if !has("node-tools", "mesh-tools-go") {
t.Errorf("a Go bundle does not stand on the Go toolchain: %v", edges)
}
for _, e := range edges {
if e.From == "photos" && e.Kind == EdgeStandsOn {
t.Errorf("an image stands on a toolchain it is not compiled in: %v", e)
}
}
}