Files
mesh-delivery/modules/mesh-delivery-builder/internal/build/members_test.go
T
jschoubben efcc8125af BL9: a group run's build names each package member's build by its digest
A candidate's module is fetched against each package member of its group by the digest of the member's build (fetch `builds`),
the member out of the fetched packages; a member not built holds it with wait-held naming the member, its bound a key of the
builder's bounds state with its own time to live (A-77), a failed member or one missing past the bound fails the step naming it.

Builds: design 47 G §373, §465 (A-77)
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:09:33 +01:00

167 lines
6.3 KiB
Go

package build
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/keys"
)
const memberPackage = "example.org/p"
var fpP = "src2:" + hex64('2')
// groupValue is a group's candidate: the module m, built against the head of the package member p, and p.
func groupValue(t *testing.T) []byte {
t.Helper()
pulls := []meshcatalog.CandidatesByGroupValuePullsItem{{Repository: "novox/mesh-catalog", Number: 7, Head: headX, Modules: []string{"m", "p"},
Fps: map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{
"m": {Fp2: fp1, Manifest: mdigest('a'), Packages: map[string]string{memberPackage: headY, "example.org/q": "v1.2.3"}},
"p": {Fp2: fpP, Manifest: mdigest('b')}}}}
hash, err := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{{Repository: pulls[0].Repository, Number: 7, Head: headX}})
if err != nil {
t.Fatal(err)
}
data, err := json.Marshal(meshcatalog.CandidatesByGroupValue{V: keys.Version, Group: "feat/x", Heads: hash, Pulls: pulls, Fpset: meshcatalogclient.FingerprintSet(pulls)})
if err != nil {
t.Fatal(err)
}
return data
}
// memberRig is a rig whose group changes m and its package member p (artifact lib); p has a trunk record when known.
func memberRig(t *testing.T, known bool) *rig {
t.Helper()
r := newRig(t, goBundle)
lib := catalogue.Artifact{Name: "lib", Kind: "bundle", Language: "go"}
r.mf.byDigest[mdigest('b')] = catalogue.Manifest{Module: "p", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{lib}}}
r.b.Members = func(_ context.Context, pkg string) (string, string, bool, error) {
if pkg == memberPackage {
return "p", "lib", true, nil
}
return "", "", false, nil
}
trunkModule(t, r)
if known {
data, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: "novox/mesh-catalog", Commit: trunkHead, Fp2: "src2:" + hex64('d'),
Manifest: mdigest('b'), Path: "modules/p", Toolchains: CatalogToolchains(agreedToolchains)})
if err != nil {
t.Fatal(err)
}
r.b.Module(context.Background(), "p", data)
r.b.Wait()
}
return r
}
func TestAGroupRunBuildsADependentAgainstItsPackageMembersBuildByDigest(t *testing.T) { // design 47 G §373, §465; test 103
t.Run("the member is fetched by its build's digest, and no package of it is fetched by version", func(t *testing.T) {
r := memberRig(t, true)
r.b.Candidate(context.Background(), "feat%2Fx", groupValue(t))
r.b.Wait()
if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone {
t.Fatalf("m = %+v, want built", v)
}
var found bool
for _, c := range r.bm.callsOf("build_go_fetch") {
f := c.body.(map[string]any)
bs, _ := f["builds"].([]any)
if len(bs) == 0 {
continue
}
found = true
want := map[string]any{"package": memberPackage, "module": "p", "artifact": "lib", "digest": digest}
if len(bs) != 1 || !jsonEqual(bs[0], want) {
t.Errorf("builds = %v, want %v", bs, want)
}
pk, _ := f["packages"].(map[string]any)
if _, named := pk[memberPackage]; named || pk["example.org/q"] != "v1.2.3" {
t.Errorf("packages = %v, want the member out of it and the other package kept", pk)
}
}
if !found {
t.Error("no fetch named the member's build")
}
})
t.Run("a member not built holds the dependent with wait-held naming it, and past the bound fails it", func(t *testing.T) {
r := memberRig(t, false)
var mu sync.Mutex
bounds := &fakeMemberBounds{}
r.b.MemberBounds = bounds
var said []builds.BuildLogEntry
r.b.Log = func(e builds.BuildLogEntry) { mu.Lock(); said = append(said, e); mu.Unlock() }
r.b.Candidate(context.Background(), "feat%2Fx", groupValue(t))
r.b.Wait()
if _, written := r.st.vals[r.key]; written || len(r.bm.callsOf("build_go_fetch")) != 0 {
t.Fatal("the dependent was claimed or built while its package member is not built")
}
mu.Lock()
if len(said) != 1 || said[0].Why != "wait-held" || !strings.Contains(said[0].Line, "p/lib") {
t.Errorf("said = %+v, want one wait-held naming p/lib", said)
}
mu.Unlock()
if got := bounds.armed(); len(got) != 1 || got[0] != r.key+" "+MemberBound.String() {
t.Fatalf("bounds armed = %v, want one of 60 minutes on the dependent's key", got)
}
r.b.MemberBoundEnded(context.Background(), r.key) // the bus's marker of the bound's expiry
r.b.Wait()
if got := bounds.ended(); len(got) != 1 || got[0] != r.key {
t.Errorf("bounds ended = %v, want the wait's bound deleted", got)
}
if v := valueOf(t, r.st, r.key); v.State != builds.BuildFailed || !strings.Contains(v.Error, "p/lib") || len(r.bm.callsOf("build_go_fetch")) != 0 {
t.Errorf("m = %+v, want failed naming the member and nothing built", v)
}
})
t.Run("a member whose build failed fails the dependent's step naming it", func(t *testing.T) {
r := memberRig(t, false)
mk, _ := builds.BuilderBuildKey("p", fpP)
data, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildFailed, Error: "boom"})
if err != nil {
t.Fatal(err)
}
r.st.put(mk, data)
r.b.Candidate(context.Background(), "feat%2Fx", groupValue(t))
r.b.Wait()
if v := valueOf(t, r.st, r.key); v.State != builds.BuildFailed || !strings.Contains(v.Error, "p/lib") || !strings.Contains(v.Error, "boom") || len(r.bm.callsOf("build_go_fetch")) != 0 {
t.Errorf("m = %+v, want failed naming the member and its error", v)
}
})
}
// fakeMemberBounds keeps the bounds a builder arms and ends.
type fakeMemberBounds struct {
mu sync.Mutex
arms, ends_ []string
}
func (f *fakeMemberBounds) Arm(_ context.Context, key, _ string, bound time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
f.arms = append(f.arms, key+" "+bound.String())
return nil
}
func (f *fakeMemberBounds) End(_ context.Context, key string) {
f.mu.Lock()
defer f.mu.Unlock()
f.ends_ = append(f.ends_, key)
}
func (f *fakeMemberBounds) armed() []string { f.mu.Lock(); defer f.mu.Unlock(); return f.arms }
func (f *fakeMemberBounds) ended() []string { f.mu.Lock(); defer f.mu.Unlock(); return f.ends_ }
func jsonEqual(a, b any) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}