Indexer (A-326): refuse at take-in a cycle with a build edge no build seat's running version breaks

The take-in computes the revision's catalog graph over its dry run and refuses one whose edges hold a cycle with
a build edge (kind 1 or 3), naming every artifact on it, as a refusal by its validation is said; a kind-3 edge out
of a module holding a build seat into an artifact that seat builds breaks the cycle at the running version.

Builds: design 47 G §49, §51, §270; A-326

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-13 02:06:23 +01:00
parent 73582f92e1
commit df02ee40fd
4 changed files with 173 additions and 0 deletions
@@ -378,3 +378,82 @@ func (ix *Indexer) withActs(ctx context.Context, entries map[string]meshcatalog.
}
return WithActs(entries, defs)
}
// RefusedCycle is a cycle of the entries' edges that the take-in refuses (design 47 G §49, A-326), as the artifacts it passes
// through with the first named again at its end; nil when there is none. A cycle of delivery edges (kinds 2, 4, 5, 6) alone
// is delivered as one step, so only a cycle holding a build edge (kind 1 or 3) is refused. A build edge of kind 3 that goes
// out of an artifact of a module holding a build seat into an artifact that seat builds is where such a cycle is broken: the
// running version builds the new one (§270), so a cycle through it is allowed, as the cycle through a build module's own
// inputs is.
func RefusedCycle(entries map[string]meshcatalog.CatalogEntry) []meshcatalog.ArtifactRef {
var edges []meshcatalog.Edge
for _, m := range slices.Sorted(maps.Keys(entries)) {
for _, x := range entries[m].Edges {
if x.From != "" && x.To != "" && !brokenAtTheRunningVersion(x, entries) {
edges = append(edges, x)
}
}
}
next := map[meshcatalog.ArtifactRef][]meshcatalog.ArtifactRef{}
for _, x := range edges {
if !slices.Contains(next[x.From], x.To) {
next[x.From] = append(next[x.From], x.To)
}
}
for _, x := range edges {
if x.Kind != 1 && x.Kind != 3 {
continue
}
if path := shortestPath(next, x.To, x.From); path != nil {
return append([]meshcatalog.ArtifactRef{x.From}, path...)
}
}
return nil
}
// brokenAtTheRunningVersion says whether x is a build edge of kind 3 out of an artifact of a module that holds a build seat
// (an acting rule of step build) into an artifact that seat builds (A-326).
func brokenAtTheRunningVersion(x meshcatalog.Edge, entries map[string]meshcatalog.CatalogEntry) bool {
if x.Kind != 3 {
return false
}
builder, to := entries[strings.SplitN(x.From, ":", 2)[0]], entries[strings.SplitN(x.To, ":", 2)[0]]
i := slices.IndexFunc(to.Artifacts, func(a meshcatalog.Artifact) bool { return a.Ref == x.To })
if i < 0 {
return false
}
art := to.Artifacts[i]
return slices.ContainsFunc(builder.Acts, func(r meshcatalog.ActingRule) bool {
if r.Step != seatdef.StepBuild {
return false
}
if r.On == seatdef.OnKindImage {
return art.Kind == "image"
}
return seatdef.ActingRule{On: r.On}.ActsOn(art.Language)
})
}
// shortestPath is the shortest path of next from start to end, start and end included; nil when end cannot be reached.
func shortestPath(next map[meshcatalog.ArtifactRef][]meshcatalog.ArtifactRef, start, end meshcatalog.ArtifactRef) []meshcatalog.ArtifactRef {
before := map[meshcatalog.ArtifactRef]meshcatalog.ArtifactRef{start: ""}
queue := []meshcatalog.ArtifactRef{start}
for len(queue) > 0 {
at := queue[0]
queue = queue[1:]
if at == end {
var path []meshcatalog.ArtifactRef
for ; at != ""; at = before[at] {
path = append([]meshcatalog.ArtifactRef{at}, path...)
}
return path
}
for _, n := range next[at] {
if _, seen := before[n]; !seen {
before[n] = at
queue = append(queue, n)
}
}
}
return nil
}
@@ -227,3 +227,41 @@ func TestAKeptRevisionHoldsTheActsAndTheKindFiveEdges(t *testing.T) {
}
}
}
// A-326 (design 47 G §49, §51): a cycle through a build module's own inputs is broken at its running version and allowed; any
// other cycle with a build edge is refused, naming every artifact on it; a cycle of delivery edges alone is one step.
func TestACycleWithABuildEdgeIsRefusedUnlessABuildSeatsRunningVersionBreaksIt(t *testing.T) {
builder := meshcatalog.CatalogEntry{Module: "mesh-build-go", Artifacts: []meshcatalog.Artifact{{Ref: "mesh-build-go:files", Language: "go"}},
Acts: []meshcatalog.ActingRule{{Seat: "node-build-go", Step: seatdef.StepBuild, On: "go"}}}
handActs := meshcatalog.CatalogEntry{Module: "mesh-hand-acts", Artifacts: []meshcatalog.Artifact{{Ref: "mesh-hand-acts:tools", Language: "go"}},
Edges: []meshcatalog.Edge{{Kind: 3, From: "mesh-build-go:files", To: "mesh-hand-acts:tools", Word: "toolchains", Why: "go"}}}
trunkCycle := builder
trunkCycle.Edges = []meshcatalog.Edge{{Kind: 6, From: "mesh-hand-acts:tools", To: "mesh-build-go:files", Word: "seat:mesh-hand-acts.hand-act"}}
a := meshcatalog.CatalogEntry{Module: "a", Artifacts: []meshcatalog.Artifact{{Ref: "a:go", Language: "go"}},
Edges: []meshcatalog.Edge{{Kind: 1, From: "b:go", To: "a:go", Word: "packages"}}}
b := meshcatalog.CatalogEntry{Module: "b", Artifacts: []meshcatalog.Artifact{{Ref: "b:go", Language: "go"}},
Edges: []meshcatalog.Edge{{Kind: 1, From: "a:go", To: "b:go", Word: "packages"}}}
noSeat := trunkCycle
noSeat.Acts = nil
verbsOnly := meshcatalog.CatalogEntry{Module: "c", Artifacts: []meshcatalog.Artifact{{Ref: "c:go"}}, Edges: []meshcatalog.Edge{{Kind: 6, From: "d:go", To: "c:go"}}}
verbsBack := meshcatalog.CatalogEntry{Module: "d", Artifacts: []meshcatalog.Artifact{{Ref: "d:go"}}, Edges: []meshcatalog.Edge{{Kind: 4, From: "c:go", To: "d:go"}}}
for _, c := range []struct {
name string
entries []meshcatalog.CatalogEntry
want []meshcatalog.ArtifactRef
}{
{"the trunk catalogue's cycle through a build module's own inputs", []meshcatalog.CatalogEntry{trunkCycle, handActs}, nil},
{"two artifacts each needing the other's new package", []meshcatalog.CatalogEntry{a, b}, []meshcatalog.ArtifactRef{"b:go", "a:go", "b:go"}},
{"the same cycle from a module that holds no build seat", []meshcatalog.CatalogEntry{noSeat, handActs},
[]meshcatalog.ArtifactRef{"mesh-build-go:files", "mesh-hand-acts:tools", "mesh-build-go:files"}},
{"a cycle of delivery edges alone", []meshcatalog.CatalogEntry{verbsOnly, verbsBack}, nil},
} {
entries := map[string]meshcatalog.CatalogEntry{}
for _, e := range c.entries {
entries[e.Module] = e
}
if got := RefusedCycle(entries); !slices.Equal(got, c.want) {
t.Errorf("%s: refused %v, want %v", c.name, got, c.want)
}
}
}
@@ -792,6 +792,9 @@ func (ix *Indexer) evaluate(ctx context.Context, own string, start bool) (Applie
if err != nil {
return Applied{}, "", err
}
if words, refused, err := ix.refuseCycle(ctx, ready, dry); refused || err != nil {
return Applied{}, words, err
}
if names == own {
a, err := ix.takeInWhole(ctx, ready, own, "")
if errors.Is(err, errClaimLost) {
@@ -809,6 +812,34 @@ func (ix *Indexer) evaluate(ctx context.Context, own string, start bool) (Applie
return Applied{}, ix.waitingLine(w), ix.holdFor(ctx, ready, names, own, dry)
}
// refuseCycle refuses the revision whose catalog graph would hold a cycle with a build edge the take-in refuses (design 47 G
// §49, A-326; RefusedCycle), over the entries the dry run computed: written as the refusal of its newest value and said in
// take-in.pending with every artifact on the cycle, once, as a refusal by its validation is (§713). It answers whether it refused.
func (ix *Indexer) refuseCycle(ctx context.Context, ready []PendingEntry, d *dryRun) (string, bool, error) {
entries, err := d.ix.viewEntries(ctx)
if err != nil {
return "", false, err
}
cycle := RefusedCycle(WithEdges(ix.withActs(ctx, entries)))
if cycle == nil {
return "", false, nil
}
n := newestOfRevision(ready)
refused := "a cycle with a build edge, which no running version breaks (design 47 G §49): " + strings.Join(cycle, ", ")
words := fmt.Sprintf("the revision at %d is refused: %s", ready[n].Value.Seq, refused)
t := revisionWait(takein.TakeInPending, ready, "")
t.Refused = refused
if e := ready[n].Value; e.Refused == refused && e.Said == t.Key && e.SaidKind == takein.TakeInPending {
return words, true, nil
}
ix.logf("take-in: %s", words)
if !ix.sayTakeIn(ctx, t) {
return words, true, nil
}
ix.markSaid(ctx, ready[n:n+1], &t, func(p *Pending) { p.Refused = refused })
return words, true, nil
}
// holdFull marks the ready merges held with the reason objects full (Pending.Held), read again at their revisions now
// (the take-in's claim moved them), so indexer_pending shows why they wait.
func (ix *Indexer) holdFull(ctx context.Context, ready []PendingEntry) {
@@ -416,6 +416,31 @@ func TestTakeIn(t *testing.T) { // 1585/T3-T13 (ADR 0633)
}
}
})
t.Run("A-326 a revision whose catalog graph holds a cycle with a build edge no build seat breaks is refused, naming it", func(t *testing.T) {
// avahi's artifact needs the indexer's (kind 2), and the indexer is built on avahi (kind 3): avahi holds no build seat, so
// no running version breaks the cycle, and the revision is refused at take-in naming every artifact on it (design 47 G §49).
b := newTakeInBed(t)
f := b.merge(21, sha('c'), "tree-avahi-3", "tree-indexer-2")
f.Manifests = map[string]string{}
for dir, raw := range b.base.Manifests {
f.Manifests[dir] = raw
}
f.Manifests["modules/avahi"] = strings.Replace(f.Manifests["modules/avahi"], `"name": "tools",`, `"name": "tools", "needs": ["`+testSelf+`:tools"],`, 1)
f.Manifests["modules/"+testSelf] = strings.Replace(f.Manifests["modules/"+testSelf], `"build": {`,
`"build": {
"on": [{"arg": "RUNTIME_BASE", "module": "avahi", "artifact": "tools"}],`, 1)
b.arrive(t, 2, f)
want := "avahi:tools, " + testSelf + ":tools, avahi:tools"
if len(b.said) == 0 || !strings.Contains(b.said[len(b.said)-1].Refused, want) {
t.Fatalf("said %+v, want the revision refused naming %s", b.said, want)
}
if b.commit(t, "avahi") == sha('c') {
t.Errorf("avahi was taken in at the refused revision")
}
if waits := PendingWaits(readPending(t, b)); len(waits) != 1 || !strings.Contains(waits[0].Refused, want) {
t.Errorf("indexer_pending %+v, want the refusal naming the cycle", waits)
}
})
t.Run("T10 the named indexer takes the revision in at its start; a gathered group stays", func(t *testing.T) {
b := newTakeInBed(t)
b.arrive(t, 2, b.merge(21, sha('c'), "tree-avahi-3", "tree-indexer-2"))