A group's composer is built against the group's tagged versions once heard, as the check's other Go builds are (issue 1921, issue 1966)

ADR 0607 §3, §5 and design 51 *Tagged versions*: the composition is handed the check's heard tags
(CompositionReader.Compose's tagged), so the node-engine member's composer no longer takes the other members'
heads where a tag stands. The trunk's manifest validator stays without tags (ADR 0633 clause 6).

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-06 02:41:58 +01:00
parent 6bd77f3d31
commit 25d58a4595
4 changed files with 25 additions and 13 deletions
@@ -13,6 +13,7 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
buildsrec "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
@@ -25,7 +26,7 @@ type fakeCompositions struct {
waits bool
}
func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (meshwants.CompositionsByGroupHeadsValue, error) {
func (f *fakeCompositions) Compose(ctx context.Context, c *Case, _ buildstep.PackageVersions) (meshwants.CompositionsByGroupHeadsValue, error) {
f.asked = append(f.asked, c.Key)
if f.waits {
<-ctx.Done()
@@ -76,7 +76,7 @@ func composerHead(c *Case) string {
}
// Compose asks every placed node and judges their answers (JudgeEngines). An error is only a case it cannot ask at all.
func (e EngineCompositions) Compose(ctx context.Context, c *Case) (meshwants.CompositionsByGroupHeadsValue, error) {
func (e EngineCompositions) Compose(ctx context.Context, c *Case, tagged buildstep.PackageVersions) (meshwants.CompositionsByGroupHeadsValue, error) {
began := time.Now()
key, err := nodeengineclient.CompositionKey(c.Group, c.Heads)
if err != nil {
@@ -109,7 +109,7 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (meshwants.Com
// The composer's build and run are the check's to bound, not the engine's answer bound. A composer that
// cannot run is no silent node (ADR 0593 §9 lets only that pass): the node's composition fails, naming
// the head.
if composed, serr := e.inSandbox(ctx, c, head, node, a); serr != nil {
if composed, serr := e.inSandbox(ctx, c, tagged, head, node, a); serr != nil {
a = ne.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: nec.Refused,
Refused: "the composer at the node-engine's head " + head + " could not compose it: " + serr.Error()}
} else {
@@ -198,7 +198,7 @@ func JudgeEngines(group, heads, composerName string, nodes []string, answers map
// inSandbox composes a node's answer again with the composer built from the node-engine member's head, over the input the
// node's engine composed from: the node's answer becomes the sandbox's, refused with the composer's words where the
// composer refuses the node.
func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node string, a ne.ComposeCandidateAnswer) (ne.ComposeCandidateAnswer, error) {
func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, tagged buildstep.PackageVersions, head, node string, a ne.ComposeCandidateAnswer) (ne.ComposeCandidateAnswer, error) {
if len(a.Input) == 0 {
return a, fmt.Errorf("the engine of %s answered no composition input, so the node-engine's head cannot compose it", node)
}
@@ -242,7 +242,7 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s
if err != nil {
return a, err
}
job := buildstep.ComposeJob{Fetch: buildstep.Fetch{Repository: nodeEngineRepo, Commit: head, Packages: packagesFor(e.Published, c, nil, nodeEngineRepo)}, Dir: meshcatalogclient.RootPath,
job := buildstep.ComposeJob{Fetch: buildstep.Fetch{Repository: nodeEngineRepo, Commit: head, Packages: packagesFor(e.Published, c, tagged, nodeEngineRepo)}, Dir: meshcatalogclient.RootPath,
Package: composer.Package, Request: raw, Reader: "the node-engine head of " + c.Group}
verdict, err := e.Sandbox.Compose(ctx, job)
if err != nil {
@@ -91,7 +91,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
t.Helper()
ec := EngineCompositions{Asker: e, Placed: func() ([]string, error) { return nodes, nil }, Bound: 50 * time.Millisecond,
Composer: func() string { return strings.Repeat("7", 40) }}
v, err := ec.Compose(context.Background(), cs)
v, err := ec.Compose(context.Background(), cs, nil)
if err != nil {
t.Fatal(err)
}
@@ -159,7 +159,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
}}
ec := EngineCompositions{Asker: e, Placed: func() ([]string, error) { return []string{"anchor"}, nil }, Bound: time.Second,
Sandbox: sb, Manifests: digests{"web": "sha256:" + strings.Repeat("c", 64)}, Published: published{}}
v, err := ec.Compose(context.Background(), host)
v, err := ec.Compose(context.Background(), host, nil)
if err != nil {
t.Fatal(err)
}
@@ -177,6 +177,15 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
if pkgs := job.Fetch.Packages; len(pkgs) != 1 || pkgs[trunk.ForgeModulePrefix+"mesh-delivery"] != head1 {
t.Errorf("the composer is fetched with the packages %v, want the group's mesh-delivery member at its head %s alone", pkgs, head1)
}
// ADR 0607 §3, §5, design 51 *Tagged versions*: once the check heard the group's tagged version of a member's Go
// package, the composer is built against that, not the member's head, as the check's other Go builds are.
const tagged = "v0.1.15-feat-x.1"
if _, err := ec.Compose(context.Background(), host, buildstep.PackageVersions{trunk.ForgeModulePrefix + "mesh-delivery": tagged}); err != nil {
t.Fatal(err)
}
if pkgs := sb.jobs[len(sb.jobs)-1].Fetch.Packages; len(pkgs) != 1 || pkgs[trunk.ForgeModulePrefix+"mesh-delivery"] != tagged {
t.Errorf("the composer is fetched with the packages %v, want the group's tagged version %s of mesh-delivery", pkgs, tagged)
}
if v.State != meshdeliverycheckerclient.CompositionFail || v.Sdk != head2 || !strings.Contains(v.Words, "the new composer refuses it") {
t.Errorf("verdict = %+v", v)
}
@@ -193,7 +202,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
sb.v = func(node string) composer.Verdict {
return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change", Machines: map[string]composer.Machine{node: row.m}}}}
}
v, err = ec.Compose(context.Background(), host)
v, err = ec.Compose(context.Background(), host, nil)
named := len(v.Warnings) == 1 && strings.Contains(v.Warnings[0], "anchor: "+nec.NoAnswer+" (its node-engine sends no running state for web)")
if err != nil || (v.State == meshdeliverycheckerclient.CompositionPass) != row.pass || named != row.pass {
t.Errorf("%+v: verdict %+v, %v", row.m, v, err)
@@ -202,7 +211,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
// Review finding 3 (ADR 0593 §9 lets only a node that does not answer pass): a composer at the head that cannot
// run fails the composition, naming the head, never passing as a silent node.
sb.err = errors.New("the build of mesh-compose failed")
v, err = ec.Compose(context.Background(), host)
v, err = ec.Compose(context.Background(), host, nil)
if err != nil || v.State != meshdeliverycheckerclient.CompositionFail || !strings.Contains(v.Words, head2) ||
!strings.Contains(v.Words, "the build of mesh-compose failed") || len(v.Warnings) != 0 {
t.Errorf("a composer that cannot run: %+v, %v", v, err)
@@ -212,7 +221,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
// Review finding 4: assignments that cannot be read is no mesh with no node: the composition is not made, said.
t.Run("an unreadable assignments makes no composition", func(t *testing.T) {
ec := EngineCompositions{Asker: &fakeEngines{}, Placed: func() ([]string, error) { return nil, errors.New("no such bucket") }}
if _, err := ec.Compose(context.Background(), cs); err == nil || !strings.Contains(err.Error(), "no such bucket") {
if _, err := ec.Compose(context.Background(), cs, nil); err == nil || !strings.Contains(err.Error(), "no such bucket") {
t.Errorf("an unreadable assignments: %v", err)
}
b := newBench(t)
@@ -180,9 +180,11 @@ type TryCandidates interface {
TryCandidate(ctx context.Context, group, heads string, bound time.Duration) (meshcatalog.CandidatesByGroupValue, error)
}
// CompositionReader makes a check's composition verdict; an error is only a case that cannot be composed at all.
// CompositionReader makes a check's composition verdict; an error is only a case that cannot be composed at all. tagged
// are the group's tagged versions of its Go packages the check heard (design 51 *Tagged versions*), nil for none: a
// composer built at the node-engine member's head is built against them, as the check's other Go builds are.
type CompositionReader interface {
Compose(ctx context.Context, c *Case) (meshwants.CompositionsByGroupHeadsValue, error)
Compose(ctx context.Context, c *Case, tagged buildstep.PackageVersions) (meshwants.CompositionsByGroupHeadsValue, error)
}
func (j *Examiner) now() time.Time {
@@ -395,7 +397,7 @@ func (st *state) composed(ctx context.Context, v *meshdeliverychecker.VerdictsBy
return
}
words := st.awaitBuilds(ctx, v) // ADR 0603 §10: the check composes with the candidate's builds
comp, err := st.j.Compositions.Compose(ctx, st.c)
comp, err := st.j.Compositions.Compose(ctx, st.c, st.tags.goPackages)
if err != nil {
// Said in the verdict, never left as a composition not yet run (review finding 4).
st.j.logf("%s: the composition could not be made, so it is said as not run: %v", st.c.Key, err)