Merge origin/main into the join: a placed module's failed build said as refused

Brings mesh-host #224 (issue 1966): newestFPs returns a placed module whose
newest build failed, composedValues writes its refused key at that fingerprint,
takeBuildDone starts the pass on a failed build. Conflict in newestFPs settled
by keeping G's by-entries branch first (design 47 G §421, §628: the node reads
neither the indexer's modules nor the builder's builds; it returns no failed),
and main's failed-build path in the other.

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 17:00:32 +01:00
3 changed files with 89 additions and 17 deletions
+32 -10
View File
@@ -28,7 +28,9 @@ import (
// build of it, so the plan exists before the want moves (build done, the node composes and writes, the provisioner
// reads, the providers provide, the assigner moves the want); a module it runs that no want names, at what runs;
// - each module's plan (catalogue.Plan) exactly when the provisioner's plans name it, from the same pass's builds;
// - a module refused alone at a fingerprint as that key with its refusal (Refused), and no resources.
// - a module refused alone at a fingerprint as that key with its refusal (Refused), and no resources; so is a module
// placed here whose newest build the builder holds failed (a build queued or running stays unpublished), at the
// indexer's fingerprint with the build's own error, so the provisioner holds it with those words and does not wait.
//
// A key is written only when its value differs from what the state holds (read once per run), and a module's key is
// deleted once its fingerprint is not among the five it wrote last (engcomp.Keep) and no want names it. A pass
@@ -84,23 +86,30 @@ func (c *Composer) publishComposed(ctx context.Context) {
// newestFPs is, of the modules placed here, the fingerprint each is composed at for the state beyond the wanted ones:
// the indexer's, where the builder holds a done build of it; else the want's; else what runs of it. A module with none
// is left out.
func (c *Composer) newestFPs(ctx context.Context, at *published) map[string]string {
// is left out. A module whose newest build the builder holds failed is also returned in failed, by module, with the
// indexer's fingerprint and the build's error: it is published as a refusal, not composed.
func (c *Composer) newestFPs(ctx context.Context, at *published) (out map[string]string, failed map[string][2]string) {
if c.byEntries() {
// The newest build is the indexer's (and the builder's done key says it is built): neither is read, and the node
// follows no branch (design 47 G §421, §628), so the state holds the wanted builds alone.
return maps.Clone(at.fps)
return maps.Clone(at.fps), nil
}
out := map[string]string{}
out = map[string]string{}
failed = map[string][2]string{}
r := reader{c: c}
for _, module := range at.placed {
c.mu.Lock()
fp2 := c.modules[module].Fp2
c.mu.Unlock()
if fp2 != "" {
if b, err := r.Build(ctx, module, fp2); err == nil && b != nil && b.State == builds.BuildDone {
out[module] = fp2
continue
if b, err := r.Build(ctx, module, fp2); err == nil && b != nil {
switch b.State {
case builds.BuildDone:
out[module] = fp2
continue
case builds.BuildFailed:
failed[module] = [2]string{fp2, b.Error}
}
}
}
if fp, ok := at.fps[module]; ok {
@@ -109,7 +118,7 @@ func (c *Composer) newestFPs(ctx context.Context, at *published) map[string]stri
out[module] = fp
}
}
return out
return out, failed
}
// writeComposed works out the state's keys from at and writes those that differ, then deletes what is past keeping.
@@ -183,7 +192,7 @@ func (c *Composer) composedValues(ctx context.Context, at *published) (map[strin
held map[string]string
}{{at.scope, at.fps, at.comps, at.held}}
// The newest builds, composed only where they differ from the wanted ones.
newest := c.newestFPs(ctx, at)
newest, failed := c.newestFPs(ctx, at)
if !maps.Equal(newest, at.fps) {
scope := at.scope
scope.KeptAt = map[string]string{}
@@ -246,6 +255,19 @@ func (c *Composer) composedValues(ctx context.Context, at *published) (map[strin
}
}
}
// A placed module whose newest build failed is refused alone at that build, with the build's words, unless a
// composition of it at that fingerprint is already written above.
for module, f := range failed {
comp := engcomp.Composed{Node: c.env.Node, Module: module, FP: f[0], Refused: f[1]}
if key, err := composedKeyOf(comp); err != nil {
return nil, err
} else if _, written := out[key]; written {
continue
}
if err := put(comp, nil); err != nil {
return nil, err
}
}
return out, nil
}
+49 -3
View File
@@ -76,8 +76,11 @@ func TestTheNodePublishesItsCompositions(t *testing.T) {
digest := meshcatalogclient.ManifestDigest([]byte(lemurs))
oldie := `{"module":"oldie","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"oldie.service","state":"running","boot":"enabled"}]}`
odigest := meshcatalogclient.ManifestDigest([]byte(oldie))
for d, json := range map[string]string{digest: lemurs, odigest: oldie} {
man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: map[string]string{digest: "lemurs", odigest: "oldie"}[d], Manifest: json})
brokenJSON := strings.ReplaceAll(oldie, "oldie", "broken")
waitingJSON := strings.ReplaceAll(oldie, "oldie", "waiting")
bdigest, wdigest := meshcatalogclient.ManifestDigest([]byte(brokenJSON)), meshcatalogclient.ManifestDigest([]byte(waitingJSON))
for d, json := range map[string]string{digest: lemurs, odigest: oldie, bdigest: brokenJSON, wdigest: waitingJSON} {
man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: map[string]string{digest: "lemurs", odigest: "oldie", bdigest: "broken", wdigest: "waiting"}[d], Manifest: json})
key, _ := meshcatalogclient.ManifestsKey(d)
b.kv[buckets.Manifests+"/"+key] = man
}
@@ -100,7 +103,7 @@ func TestTheNodePublishesItsCompositions(t *testing.T) {
t.Fatal(err)
}
place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"},
Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}})
Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}, {Module: "broken"}, {Module: "waiting"}}})
have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}})
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"})
omod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: ofp, Manifest: odigest, Repository: "novox/o", Commit: "c"})
@@ -110,6 +113,12 @@ func TestTheNodePublishesItsCompositions(t *testing.T) {
b.put(t, buckets.Have, "n1.node", have)
b.put(t, buckets.Modules, "lemurs", mod)
b.put(t, buckets.Modules, "oldie", omod)
bfp := meshcatalogclient.SourcePrefix2 + strings.Repeat("d", 64)
wfp := meshcatalogclient.SourcePrefix2 + strings.Repeat("e", 64)
for m, f := range map[string]string{"broken": bfp, "waiting": wfp} {
raw, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: f, Manifest: map[string]string{"broken": bdigest, "waiting": wdigest}[m], Repository: "novox/o", Commit: "c"})
b.put(t, buckets.Modules, m, raw)
}
b.put(t, buckets.Wants, "want.n1.lemurs", want)
b.put(t, buckets.Wants, "want.n1.node", nodeWant)
for _, bucket := range []string{buckets.Wants, buckets.Assignments, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} {
@@ -195,6 +204,43 @@ func TestTheNodePublishesItsCompositions(t *testing.T) {
t.Errorf("the same build landing again wrote %v", got[3:])
}
// A placed module whose newest build failed is published as a refusal at that build with the build's words (the
// provisioner holds it and does not wait); one whose build is queued or running stays unpublished.
for m, st := range map[string]builds.BuilderBuild{
"broken": {State: builds.BuildFailed, Error: `refused kind: package of language "go"`},
"waiting": {State: builds.BuildQueued},
} {
f := map[string]string{"broken": bfp, "waiting": wfp}[m]
raw, _ := builds.EncodeBuilderBuild(st)
k, _ := builds.BuilderBuildKey(m, f)
b.mu.Lock()
b.kv[builds.BuilderBuildsBucket+"/"+k] = raw
b.mu.Unlock()
b.put(t, builds.BuilderBuildsBucket, k, raw)
}
brokenKey, _ := engcomp.Key("n1", "broken", bfp)
got = waitFor("broken's failed build", func(got []string) bool { return len(got) >= 4 })
// The pass that wrote it says so ("1 key(s) written" was said once before, for oldie); then only brokenKey is new.
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
n := 0
b.mu.Lock()
for _, l := range b.said {
if strings.Contains(l, "this node's composition state: 1 key(s) written") {
n++
}
}
b.mu.Unlock()
if n >= 2 {
break
}
}
if got := state.written(); len(got) != 4 || got[3] != brokenKey {
t.Errorf("a failed build and a queued one wrote %v, want only %s", got[3:], brokenKey)
}
if comp, err := engcomp.Decode(state.kv[brokenKey].Value); err != nil || comp.Refused != `refused kind: package of language "go"` || len(comp.Resources) != 0 {
t.Errorf("the failed build's key: %+v, %v", comp, err)
}
// What is kept: the five keys of a module written last, and one a want names; the rest is deleted.
keep := New(b.env())
keep.pubKept = map[string]ComposedEntry{}
+8 -4
View File
@@ -501,7 +501,8 @@ func (c *Composer) allInputs() []input {
}
// takeBuildDone says whether a build that landed is one this node composes from: done, of a module it wants at that
// build, or wanted or placed here whose trunk's build it is. Every other build, and a build not done, moves nothing.
// build, or wanted or placed here whose trunk's build it is; or failed, of a module placed here whose trunk's build it
// is, which the node publishes as a refusal (ADR 0593 §6). Every other build moves nothing.
func (c *Composer) takeBuildDone(u agent.Update) bool {
// Any write or delete of a build's key forgets the build kept as done: the builder claims a done key again as a new
// revision (a gone step, issue 1937), which lands done with other artifacts.
@@ -525,11 +526,14 @@ func (c *Composer) takeBuildDone(u agent.Update) bool {
c.mu.Unlock()
// A module placed here is composed at its trunk's build once that is done, for the composition state, before its
// want moves (ADR 0593 §6).
if !(wanted && w.Fp == fp) && !((wanted || placedHere) && m.Fp2 == fp) {
b, err := builds.DecodeBuilderBuild(u.Value)
if err != nil {
return false
}
b, err := builds.DecodeBuilderBuild(u.Value)
return err == nil && b.State == builds.BuildDone
if b.State == builds.BuildFailed {
return placedHere && m.Fp2 == fp
}
return b.State == builds.BuildDone && (wanted && w.Fp == fp || (wanted || placedHere) && m.Fp2 == fp)
}
// Watch starts the composer and answers the compositions it makes: each as a key of `composed-<node>` would be, then a