Files
mesh-delivery/modules/mesh-delivery-assigner/internal/assigngraph/graph_test.go
T
jschoubben 08d38f651b Merge branch 'feat/47-G-fix11-b5j' into feat/47-G-join11
# Conflicts:
#	modules/mesh-delivery-assigner/internal/assigngraph/graph_test.go
2026-11-12 10:37:21 +01:00

401 lines
19 KiB
Go

package assigngraph
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
type memObjects map[string][]byte
func (m memObjects) Put(_ context.Context, name string, data []byte) error {
m[name] = data
return nil
}
func (m memObjects) Get(_ context.Context, name string) ([]byte, error) {
d, ok := m[name]
if !ok {
return nil, ErrNoObject
}
return d, nil
}
func (m memObjects) Names(context.Context) ([]string, error) {
out := []string{}
for n := range m {
out = append(out, n)
}
return out, nil
}
func half(branch string, revision int64, entries ...meshcatalog.CatalogEntry) meshcatalogclient.Half {
h := meshcatalogclient.Half{Revision: meshcatalog.CatalogRevision{Kind: "trunk", Branch: branch, Revision: revision, Source: revision + 10},
Entries: map[string]meshcatalog.CatalogEntry{}}
for _, e := range entries {
h.Entries[e.Module] = e
h.Revision.Entries = append(h.Revision.Entries, meshcatalog.EntryRef{Module: e.Module, Digest: "sha256:" + e.Module + e.Fp})
}
return h
}
// Design 47 *Two graphs, one source of truth* (lines 1353-1366, 1416-1418) and *An acting part's new version acts first*
// (lines 1835-1836): each entry one module version on a node from the catalog graph of the branch its placement names,
// its edges provisioning and acting, a revision computed again from its inputs equal to the one kept.
func TestTheAssignmentGraphIsComputedFromItsInputsAndKept(t *testing.T) {
ctx := context.Background()
in := Inputs{
Halves: map[string]meshcatalogclient.Half{"main": half("main", 7,
meshcatalog.CatalogEntry{Module: "node-engine", Fp: "src2:e1", Acts: []meshcatalog.ActingRule{{Seat: "node-engine", Step: "compose", On: "every", Bound: "30m"}}},
meshcatalog.CatalogEntry{Module: "build-go", Fp: "src2:b1", Acts: []meshcatalog.ActingRule{{Seat: "node-build-go", Step: "build", On: "go"}}},
meshcatalog.CatalogEntry{Module: "postgres", Fp: "src2:p1", Provides: []string{"database"}},
meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w1", Requires: []string{"database", "secrets"}, Toolchains: map[string]meshcatalog.BuildEdge{"go": {Version: "1"}}},
)},
Placements: map[string]mma.Assignments{
"anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "node-engine"}, {Module: "postgres"}, {Module: "build-go"},
{Module: "web", Bindings: map[string]mma.Provider{"database": {Node: "anchor", Module: "postgres"}}}}},
"laptop": {Node: "laptop", Modules: []mma.PlacedModule{{Module: "node-engine"}, {Module: "web", Build: "src2:w0"},
{Module: "photos", Branch: "beta"}, {Module: "ghost"}}},
},
FirstBranch: func(module string) string {
if module == "ghost" {
return ""
}
return "main"
},
}
c := Compute(in)
byNode := map[string]meshwants.AssignmentEntry{}
for _, ref := range c.Revision.Entries {
byNode[ref.Node+"/"+ref.Module] = c.Entries[ref.Digest]
}
webA, webL := byNode["anchor/web"], byNode["laptop/web"]
if len(c.Revision.Entries) != 6 || len(c.Revision.Catalogs) != 1 || c.Revision.Catalogs[0] != (meshwants.CatalogOf{Branch: "main", Revision: 7, Source: 17}) {
t.Fatalf("revision %+v", c.Revision)
}
if webA.Branch != "main" || webA.Fp != "src2:w1" || webA.Catalog != "sha256:websrc2:w1" || webL.Fp != "src2:w0" {
t.Errorf("web's entries: %+v, %+v (the placement's build is the version on laptop)", webA, webL)
}
want := []meshwants.Edge{
{Kind: EdgeActing, Node: "anchor", Module: "build-go", Fp: "src2:b1", Seat: "node-build-go", Step: "build"},
{Kind: EdgeActing, Node: "anchor", Module: "node-engine", Fp: "src2:e1", Seat: "node-engine", Step: "compose", Bound: "30m"},
{Kind: EdgeProvisioning, Node: "anchor", Module: "postgres", Fp: "src2:p1", Provision: "database"},
}
if fmt.Sprint(webA.Edges) != fmt.Sprint(want) {
t.Errorf("web on anchor's edges:\n got %+v\nwant %+v", webA.Edges, want)
}
// laptop's web: its own node's node-engine composes it, the build module placed once builds it, and with no binding or
// pin its database is the only provider's (design 47 G §357).
if e := webL.Edges; len(e) != 3 || e[0].Module != "build-go" || e[0].Node != "anchor" || e[1].Module != "node-engine" || e[1].Node != "laptop" ||
e[2] != (meshwants.Edge{Kind: EdgeProvisioning, Node: "anchor", Module: "postgres", Fp: "src2:p1", Provision: "database"}) {
t.Errorf("web on laptop's edges: %+v", e)
}
// Its secrets no placed module provides: held on both nodes, naming no candidate.
if h := webA.Held; len(h) != 1 || h[0].Requirement != "secrets" || len(h[0].Candidates) != 0 {
t.Errorf("web on anchor is held on %+v", h)
}
if e := byNode["anchor/postgres"].Edges; len(e) != 1 || e[0].Module != "node-engine" {
t.Errorf("postgres builds no Go, so only the node-engine acts on it: %+v", e)
}
if len(byNode["anchor/node-engine"].Edges) != 0 {
t.Errorf("an acting part acts on itself: %+v", byNode["anchor/node-engine"].Edges)
}
if strings.Join(c.Uncomputed, "|") != "laptop/ghost: no watched branch: its placement names none and no registration of it is known|"+
"laptop/photos: the catalog graph of the branch beta is not held" {
t.Errorf("uncomputed %q", c.Uncomputed)
}
// Kept once; computed again from the same inputs it is the same revision, kept no second time.
o := memObjects{}
at := time.Date(2026, 11, 10, 18, 0, 0, 0, time.UTC)
rev, kept, err := Keep(ctx, o, c, "catalog-revision", at)
if err != nil || !kept || rev.Revision != 1 || rev.Cause != "catalog-revision" || len(o) != 7 {
t.Fatalf("kept %v %+v (%v), objects %d", kept, rev, err, len(o))
}
if again, kept, err := Keep(ctx, o, Compute(in), "placement-changed", at); err != nil || kept || again.Revision != 1 {
t.Errorf("recomputed from the same inputs: kept %v, %+v, %v", kept, again, err)
}
// A placement change moves laptop's web to the catalog's version: revision 2, one entry more stored, the rest shared.
in.Placements["laptop"].Modules[1].Build = ""
rev2, kept, err := Keep(ctx, o, Compute(in), "placement-changed", at)
if err != nil || !kept || rev2.Revision != 2 || len(o) != 9 || rev2.Placements == rev.Placements {
t.Fatalf("revision 2 %v %+v (%v), objects %d", kept, rev2, err, len(o))
}
// The verbs read only what is kept.
r, err := Revision(ctx, o, meshwants.RevisionInput{})
if err != nil || r.Revision.Revision != 2 {
t.Errorf("revision = %+v, %v", r, err)
}
if _, err := Revision(ctx, o, meshwants.RevisionInput{Group: "g", Heads: "h"}); !errors.Is(err, ErrNoCandidate) {
t.Errorf("a candidate's revision: %v", err)
}
e, err := Entry(ctx, o, meshwants.EntryInput{Revision: 1, Node: "laptop", Module: "web"})
if err != nil || e.Entry.Fp != "src2:w0" || !strings.HasPrefix(e.Digest, "sha256:") {
t.Errorf("entry = %+v, %v", e, err)
}
d, err := Difference(ctx, o, meshwants.DifferenceInput{From: 1, To: 2})
if err != nil || len(d.Changes) != 1 || fmt.Sprint(d.Changes[0]) != fmt.Sprint(meshwants.Change{Node: "laptop", Module: "web", From: "src2:w0", To: "src2:w1"}) ||
strings.Join(d.Nodes, ",") != "laptop" {
t.Errorf("difference = %+v, %v", d, err)
}
if _, err := Difference(ctx, o, meshwants.DifferenceInput{To: 2}); err == nil {
t.Error("a difference with one revision answered")
}
if ev := Event(rev2); ev.Revision != 2 || len(ev.Catalogs) != 1 || ev.Kind != "trunk" {
t.Errorf("event %+v", ev)
}
}
// Design 47 G §357, §708, §709 and A-167, A-229: a requirement points to the named seat's holder, else a pin, else the
// provider on the consumer's node, else the only provider; otherwise the entry is held, naming the candidates (and the
// unheld seat), and nothing is guessed. An optional requirement nothing provides is left absent; a requirement the
// manifest's contributes names gets a contribution edge to the same provider.
func TestARequirementPointsByTheOrderOrIsHeld(t *testing.T) {
for _, c := range []struct {
name string
kv []string // the nodes kv is placed on
pin bool // web's node pins kv on laptop
seat string // the seat delivering cache: "" none, "held" held by kv on server, "unheld" held by none
optional bool // web requires cache optionally
contributes bool // web's manifest contributes to cache
edge string // the node of the provisioning edge, "" for none
held string // the candidates' nodes of the hold, "-" for no hold
heldSeat string // the seat the hold names
}{
{name: "the provider on the consumer's node", kv: []string{"anchor", "laptop"}, edge: "anchor", held: "-"},
{name: "the only provider", kv: []string{"laptop"}, edge: "laptop", held: "-"},
{name: "a pin before the node's own", kv: []string{"anchor", "laptop"}, pin: true, edge: "laptop", held: "-"},
{name: "two providers elsewhere: held", kv: []string{"laptop", "server"}, held: "laptop,server"},
{name: "none: held naming none", held: ""},
{name: "the named seat's holder before the provider on the node", kv: []string{"anchor", "server"}, seat: "held", edge: "server", held: "-"},
{name: "a delivering seat no placement holds: held naming it", kv: []string{"anchor"}, seat: "unheld", held: "", heldSeat: "cache-seat"},
{name: "an optional requirement nothing provides: absent", optional: true, held: "-"},
{name: "an optional requirement with two providers elsewhere: held", optional: true, kv: []string{"laptop", "server"}, held: "laptop,server"},
{name: "a contributed requirement: a contribution edge too", kv: []string{"laptop"}, contributes: true, edge: "laptop", held: "-"},
} {
t.Run(c.name, func(t *testing.T) {
web := meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w1", Requires: []string{"cache"},
Manifest: map[string]any{"module": "web", "version": "1", "requires": []any{"cache"}}}
if c.optional {
web.Manifest["requires"] = []any{map[string]any{"name": "cache", "optional": true}}
}
if c.contributes {
web.Contributes = []string{"cache"}
}
h := half("main", 3, meshcatalog.CatalogEntry{Module: "kv", Fp: "src2:k1", Provides: []string{"cache"}}, web)
defs := map[string]seatdef.Definition{}
if c.seat != "" {
h.Revision.Definitions = []meshcatalog.CatalogRevisionDefinitionsItem{{Seat: "cache-seat", Digest: "sha256:d"}}
defs["cache-seat"] = seatdef.Definition{Seat: "cache-seat", Scope: seatdef.ScopeMesh, Delivers: "cache"}
}
pl := map[string]mma.Assignments{"anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}}}}
if c.pin {
pl["anchor"] = mma.Assignments{Node: "anchor", Modules: pl["anchor"].Modules, Pins: map[string]mma.Provider{"cache": {Node: "laptop", Module: "kv"}}}
}
for _, n := range c.kv {
a := pl[n]
a.Node, a.Modules = n, append(a.Modules, mma.PlacedModule{Module: "kv"})
pl[n] = a
}
if c.seat == "held" {
a := pl["server"]
a.Holds = append(a.Holds, mma.Holding{Claim: "cache-seat", Module: "kv"})
pl["server"] = a
}
out := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": h}, Placements: pl, FirstBranch: func(string) string { return "main" },
Definitions: defs})
var got meshwants.AssignmentEntry
for _, ref := range out.Revision.Entries {
if ref.Node == "anchor" && ref.Module == "web" {
got = out.Entries[ref.Digest]
}
}
edge, contribution := "", ""
for _, e := range got.Edges {
if e.Kind == EdgeProvisioning && e.Provision == "cache" {
edge = e.Node
}
if e.Kind == EdgeContribution && e.Provision == "cache" {
contribution = e.Node + "/" + e.Module
}
}
held, heldSeat := "-", ""
if len(got.Held) == 1 && got.Held[0].Requirement == "cache" {
var nodes []string
for _, cand := range got.Held[0].Candidates {
nodes = append(nodes, cand.Node)
}
held, heldSeat = strings.Join(nodes, ","), got.Held[0].Seat
}
if edge != c.edge || held != c.held || heldSeat != c.heldSeat || len(got.Held) > 1 {
t.Errorf("web points to %q, held on %q seat %q (%+v); want %q, %q, %q", edge, held, heldSeat, got.Held, c.edge, c.held, c.heldSeat)
}
if want := map[bool]string{true: c.edge + "/kv"}[c.contributes]; contribution != want {
t.Errorf("web's contribution edge points to %q, want %q", contribution, want)
}
})
}
}
// Design 47 line 1385: a whole assignment graph revision of about 120 modules on four nodes is under the bus's 256 KiB
// value limit.
func TestARevisionOf120ModulesOnFourNodesFitsOneValue(t *testing.T) {
var entries []meshcatalog.CatalogEntry
placements := map[string]mma.Assignments{}
for i := range 120 {
entries = append(entries, meshcatalog.CatalogEntry{Module: fmt.Sprintf("module-%03d", i), Fp: "src2:" + strings.Repeat("a", 64)})
}
for _, node := range []string{"anchor", "home", "desk", "laptop"} {
a := mma.Assignments{Node: node}
for _, e := range entries {
a.Modules = append(a.Modules, mma.PlacedModule{Module: e.Module})
}
placements[node] = a
}
h := half("main", 1, entries...)
for i := range h.Revision.Entries {
h.Revision.Entries[i].Digest = "sha256:" + strings.Repeat("b", 64)
}
c := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": h}, Placements: placements, FirstBranch: func(string) string { return "main" }})
raw, _ := json.Marshal(c.Revision)
if len(c.Revision.Entries) != 480 || len(raw) >= 256<<10 {
t.Errorf("%d entries in %d bytes", len(c.Revision.Entries), len(raw))
}
}
func (m memObjects) Delete(_ context.Context, name string) error {
delete(m, name)
return nil
}
// A-201, A-235, §186, §189: a run's candidate assignment graph is kept apart from trunk's under candidate.<group>.<heads>, served by
// revision, entry and difference against trunk, and dropped by its group alone.
func TestARunsCandidateGraphIsKeptApartServedAndDropped(t *testing.T) {
ctx := context.Background()
at := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC)
placed := map[string]mma.Assignments{"ace": {Modules: []mma.PlacedModule{{Module: "web"}}}}
first := func(string) string { return "main" }
o := memObjects{}
trunk := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": half("main", 3, meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w1"})},
Placements: placed, FirstBranch: first})
if _, _, err := Keep(ctx, o, trunk, "catalog-revision", at); err != nil {
t.Fatal(err)
}
cand := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": half("main", 4, meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w2"})},
Placements: placed, FirstBranch: first})
if _, _, err := KeepCandidate(ctx, o, cand, "", "h1", "catalog-revision", at); err == nil {
t.Error("a candidate naming no group was kept")
}
rev, fresh, err := KeepCandidate(ctx, o, cand, "run-7", "h1", "catalog-revision", at)
if err != nil || !fresh || rev.Kind != meshcatalogclient.KindCandidate || rev.Group != "run-7" || rev.Revision != 1 {
t.Fatalf("kept %+v fresh %v: %v", rev, fresh, err)
}
if _, ok := o[CandidateObject("run-7", "h1")]; !ok {
t.Fatalf("the candidate is not under candidate.run-7.h1: %v", o)
}
if _, fresh, _ := KeepCandidate(ctx, o, cand, "run-7", "h1", "catalog-revision", at); fresh {
t.Error("the same candidate kept again is a new revision")
}
newest, has, err := Newest(ctx, o)
if err != nil || !has || newest.Kind != meshcatalogclient.KindTrunk || newest.Revision != 1 {
t.Errorf("trunk's newest is %+v (%v, %v): the candidate is not kept apart", newest, has, err)
}
for _, in := range []meshwants.RevisionInput{{Group: "run-7"}, {Group: "run-7", Heads: "h1"}} {
a, err := Revision(ctx, o, in)
if err != nil || a.Revision.Group != "run-7" {
t.Errorf("revision %+v answered %+v: %v", in, a.Revision, err)
}
}
if _, err := Revision(ctx, o, meshwants.RevisionInput{Group: "run-8"}); !errors.Is(err, ErrNoCandidate) {
t.Errorf("an unkept candidate answered %v", err)
}
e, err := Entry(ctx, o, meshwants.EntryInput{Group: "run-7", Node: "ace", Module: "web"})
if err != nil || e.Entry.Fp != "src2:w2" {
t.Errorf("the candidate's entry is %+v: %v", e.Entry, err)
}
d, err := Difference(ctx, o, meshwants.DifferenceInput{Group: "run-7"})
if err != nil || len(d.Changes) != 1 || d.Changes[0].From != "src2:w1" || d.Changes[0].To != "src2:w2" {
t.Errorf("the candidate against trunk is %+v: %v", d, err)
}
if _, err := Difference(ctx, o, meshwants.DifferenceInput{}); err == nil {
t.Error("difference with none of from, to and group is not refused")
}
if _, _, err := KeepCandidate(ctx, o, cand, "run-9", "h2", "catalog-revision", at); err != nil {
t.Fatal(err)
}
if n, err := DropCandidate(ctx, o, "run-7"); err != nil || n != 1 {
t.Errorf("dropped %d: %v", n, err)
}
if _, err := ReadCandidate(ctx, o, "run-7", ""); !errors.Is(err, ErrNoCandidate) {
t.Errorf("the dropped candidate reads: %v", err)
}
if _, err := ReadCandidate(ctx, o, "run-9", ""); err != nil {
t.Errorf("another run's candidate went with it: %v", err)
}
if _, has, _ := Newest(ctx, o); !has {
t.Error("dropping a candidate dropped trunk")
}
}
// Design 47 G §512, §186, A-242: the difference orders its changes itself: each change names the changes it is ordered after
// with the kind of the edge, and Tiers holds the changes in that order, an acting part and a provider before what they act on.
func TestTheDifferenceFillsItsTiersAndEachChangesAfter(t *testing.T) {
ctx := context.Background()
build := func(ne, pg, web string) Computed {
return Compute(Inputs{
Halves: map[string]meshcatalogclient.Half{"main": half("main", 7,
meshcatalog.CatalogEntry{Module: "node-engine", Fp: ne, Acts: []meshcatalog.ActingRule{{Seat: "node-engine", Step: "compose", On: "every"}}},
meshcatalog.CatalogEntry{Module: "postgres", Fp: pg, Provides: []string{"database"}},
meshcatalog.CatalogEntry{Module: "web", Fp: web, Requires: []string{"database"}},
)},
Placements: map[string]mma.Assignments{"anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "node-engine"}, {Module: "postgres"},
{Module: "web", Bindings: map[string]mma.Provider{"database": {Node: "anchor", Module: "postgres"}}}}}},
FirstBranch: func(string) string { return "main" },
})
}
o := memObjects{}
at := time.Date(2026, 11, 12, 1, 0, 0, 0, time.UTC)
if _, _, err := Keep(ctx, o, build("src2:e1", "src2:p1", "src2:w1"), "catalog-revision", at); err != nil {
t.Fatal(err)
}
if _, _, err := Keep(ctx, o, build("src2:e2", "src2:p2", "src2:w2"), "catalog-revision", at); err != nil {
t.Fatal(err)
}
d, err := Difference(ctx, o, meshwants.DifferenceInput{From: 1, To: 2})
if err != nil || len(d.Changes) != 3 {
t.Fatalf("difference %+v, %v", d, err)
}
after := map[string]string{}
for _, c := range d.Changes {
var a []string
for _, x := range c.After {
a = append(a, x.Kind+":"+x.Node+"/"+x.Module)
}
after[c.Module] = strings.Join(a, ",")
}
if after["node-engine"] != "" || after["postgres"] != "acting:anchor/node-engine" ||
after["web"] != "acting:anchor/node-engine,provisioning:anchor/postgres" {
t.Errorf("after = %v", after)
}
var tiers []string
for _, tier := range d.Tiers {
var ms []string
for _, r := range tier {
ms = append(ms, r.Module)
}
tiers = append(tiers, strings.Join(ms, ","))
}
if strings.Join(tiers, "|") != "node-engine|postgres|web" {
t.Errorf("tiers = %v", tiers)
}
}