From df02ee40fde837f79d186dcb2bdaef2a99ec275f Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 13 Nov 2026 02:06:23 +0100 Subject: [PATCH] Indexer (A-326): refuse at take-in a cycle with a build edge no build seat's running version breaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../internal/index/catalog_edges.go | 79 +++++++++++++++++++ .../internal/index/catalog_edges_test.go | 38 +++++++++ .../internal/index/pending.go | 31 ++++++++ .../internal/index/pending_test.go | 25 ++++++ 4 files changed, 173 insertions(+) diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_edges.go b/modules/mesh-delivery-indexer/internal/index/catalog_edges.go index ab65952b..2ed9cfb7 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_edges.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_edges.go @@ -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 +} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go index 6be8356e..cc1c2da6 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go @@ -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) + } + } +} diff --git a/modules/mesh-delivery-indexer/internal/index/pending.go b/modules/mesh-delivery-indexer/internal/index/pending.go index 6a18d144..c2f9af9c 100644 --- a/modules/mesh-delivery-indexer/internal/index/pending.go +++ b/modules/mesh-delivery-indexer/internal/index/pending.go @@ -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) { diff --git a/modules/mesh-delivery-indexer/internal/index/pending_test.go b/modules/mesh-delivery-indexer/internal/index/pending_test.go index df1f43f9..43f3cbfb 100644 --- a/modules/mesh-delivery-indexer/internal/index/pending_test.go +++ b/modules/mesh-delivery-indexer/internal/index/pending_test.go @@ -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"))