Files
mesh-delivery/modules/mesh-delivery-assigner/internal/assigngraph/graph_test.go
T
jschoubben 05494e7bb9 W5.1, W5.2, W5.5: the assignment graph computed per branch, kept as objects, and served by mesh-wants' verbs
Design 47 at hq main b229f5c4, lines 1353-1366 (each entry one module version
on a node, its edges provisioning and acting; each revision naming its inputs,
kept as objects in the old section with its cap, served by the seat's verbs),
1385-1388 (under 256 KiB for 120 modules on four nodes; a 512 MiB cap), 1416-1418
and 522-525 (a node's entries from the catalog graph of the branch its placement
names, by default the registration's first), 909-914 (the placement's build is
the version), 1835-1836 (an edge from the acting part's version to each version
its rule names), and the Reverted table (assignment graph revisions as objects,
form 1).

internal/assigngraph computes a revision from the halves held per branch, the
placements and the capability readings; keeps it numbered after the newest
when it differs, each entry once by its digest; and answers revision, entry
and difference from the kept objects only. The manifest serves the three verbs
and declares the objects assignment-revisions. A placed module whose entry
cannot be computed is named in Uncomputed, never left out in silence.

Not built here (the design does not say, or held): contribution edges;
provisioning to a provider no binding or pin names; tiers; a candidate's
revision (W5.9); removal of a revision no record pins (pins are W5.9's).
The process does not compute and keep yet: that needs the registrations'
first view and a catalog half per branch (handover).

Milestone 47/G, issue 1966. Tests: TestTheAssignmentGraphIsComputedFromItsInputsAndKept,
TestARevisionOf120ModulesOnFourNodesFitsOneValue, TestTheSeatsVerbs/1953/D4-D6
(failed before), TestManifest's objects row (failed with the old manifest).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

Builds: design 47 G §173, §175, §176, §180, §194, §209, §223, §263, §346
2026-11-11 14:43:17 +01:00

177 lines
8.2 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/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
)
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 no binding or pin
// names a provider there.
if e := webL.Edges; len(e) != 2 || e[0].Module != "build-go" || e[0].Node != "anchor" || e[1].Module != "node-engine" || e[1].Node != "laptop" {
t.Errorf("web on laptop's edges: %+v", e)
}
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 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))
}
}