From f609be786463a1841d1f9904e0058955d51328fb Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 23:49:22 +0100 Subject: [PATCH 1/2] Keep as today what a node keeps today and what is held only behind it, saying how; a stub hands and collects nothing of its own (issue 1126) --- go/compose/composed.go | 4 ++ go/compose/frombus/answer.go | 60 +++++++++++++++++++++----- go/compose/frombus/answer_rows_test.go | 20 ++++++++- go/compose/frombus/frombus.go | 8 ++-- go/compose/judge/judge.go | 8 +++- go/compose/judge/judge_test.go | 2 +- go/delivery/placement_candidate.go | 5 ++- 7 files changed, 86 insertions(+), 21 deletions(-) diff --git a/go/compose/composed.go b/go/compose/composed.go index afa9a77..5c60334 100644 --- a/go/compose/composed.go +++ b/go/compose/composed.go @@ -318,6 +318,10 @@ func Stub(m catalogue.Manifest) catalogue.Manifest { m.OwnsPaths = catalogue.DeclaredPaths(m) } m.Build = nil + // Nor is anything of its own handed or collected: the templates and items its build kept fill resources and items a + // stub does not have (issue 1126: novox's node refused "postgres: its build kept a template for consumers, which it + // does not declare" with postgres held). + m.Handed, m.Items = nil, nil var dirs []map[string]any for _, r := range m.Resources { if r["type"] == "directory" { diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go index 031231f..fee3a66 100644 --- a/go/compose/frombus/answer.go +++ b/go/compose/frombus/answer.go @@ -209,21 +209,59 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st return a } -// heldAsToday moves each refusal of a module the node holds today (Scope.HeldToday) into Held: the candidate holds it as -// the node does, which is no change (issue 1126: a seed reproduces today, and today holds it too). -func heldAsToday(a *delivery.CandidateAnswer, today []string) { +// heldBehind is why a module is held with a held one it reads (composeEach's dependents). +func heldBehind(m string) string { + return "it reads what " + m + " contributes or listens on, and " + m + " is held" +} + +// behind is the held module a refusal's words say it is held behind, or "". +func behind(why string) string { + rest, ok := strings.CutPrefix(why, "it reads what ") + if !ok { + return "" + } + m, _, ok := strings.Cut(rest, " contributes or listens on, and ") + if !ok || why != heldBehind(m) { + return "" + } + return m +} + +// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how: +// held, failed, running a build of the old form), and of each module held only behind one of those: the candidate keeps +// them as the node does, which is no change (issue 1126: a seed reproduces today, and today keeps them so). Each says +// how it stands today (ModuleRefused.Today). +func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) { if len(today) == 0 { return } - kept := a.Refusals[:0] - for _, r := range a.Refusals { - if slices.Contains(today, r.Module) { - a.Held = append(a.Held, r) - } else { - kept = append(kept, r) + how := map[string]string{} + for m, w := range today { + how[m] = w + } + for moved := true; moved; { + moved = false + kept := a.Refusals[:0] + for _, r := range a.Refusals { + w, keep := how[r.Module] + if !keep { + if m := behind(r.Why); m != "" { + if _, behindKept := how[m]; behindKept { + w, keep = "held behind "+m, true + } + } + } + if keep { + r.Today = w + how[r.Module] = w + a.Held = append(a.Held, r) + moved = true + } else { + kept = append(kept, r) + } } + a.Refusals = kept } - a.Refusals = kept if len(a.Refusals) == 0 { a.Refusals = nil } @@ -313,7 +351,7 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b // What the others read of its manifest (what it contributes, what it listens on) is its new manifest's, not // necessarily the build's that runs: what reads it is held with it (review of the apply, round 2). for _, r := range dependents(in, node, src[m], failed) { - hold(r, "it reads what "+m+" contributes or listens on, and "+m+" is held", false) + hold(r, heldBehind(m), false) } } for _, m := range sortedKeys(failed) { diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go index 46461d1..b72850a 100644 --- a/go/compose/frombus/answer_rows_test.go +++ b/go/compose/frombus/answer_rows_test.go @@ -93,17 +93,25 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { } // Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every // refused module held today the verdict is no refusal, and each is named under Held. - today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: []string{"api", "spiegel", "web"}}, + today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}}, map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" { t.Errorf("held as today: %+v", today) } // A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused. - part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: []string{"api"}}, + part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}}, map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" { t.Errorf("held as today in part: %+v", part) } + // A module held only behind one kept today is kept as today too, saying behind which (novox's mailu behind de-spiegel). + if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"}, + {Module: "mailu", Why: heldBehind("de-spiegel")}, {Module: "proxy", Why: heldBehind("mailu")}, {Module: "web", Why: "no build"}}}); true { + heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"}) + if len(a.Refusals) != 1 || a.Refusals[0].Module != "web" || len(a.Held) != 3 || a.Held[2].Today != "held behind mailu" { + t.Errorf("held behind a module kept today: %+v", a) + } + } // The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as // they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself. @@ -286,6 +294,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) } + // Issue 1126: a held module hands and collects nothing of its own, so a template its build kept for a file it no + // longer carries refuses nothing (novox's node: "postgres: its build kept a template for consumers"). + kept := f1 + kept.Handed = &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"}} + kept.Items = []catalogue.Item{{Collection: "routes", Content: "x"}} + if st := compose.Stub(kept); st.Handed != nil || st.Items != nil { + t.Errorf("a stub hands %+v and collects %+v", st.Handed, st.Items) + } var k1, k2 any _ = json.Unmarshal(keptComposed, &k1) _ = json.Unmarshal(keptFile, &k2) diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index a013c97..d034a48 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -134,10 +134,10 @@ type Scope struct { Placements map[string]*delivery.Placement InOwn []string Running []delivery.Composed - // HeldToday is, in an answer, the modules the node holds today ("node" for its own composition): their have's phase - // is held, or they are held inside its own composition. A candidate that holds one too holds it as today, which is - // said under CandidateAnswer.Held and is no refusal (issue 1126). - HeldToday []string + // HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with + // how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only + // behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126). + HeldToday map[string]string // SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own // key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them // as differing. diff --git a/go/compose/judge/judge.go b/go/compose/judge/judge.go index 43c3a91..8d68a39 100644 --- a/go/compose/judge/judge.go +++ b/go/compose/judge/judge.go @@ -258,9 +258,13 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { if held := v.Answers[n].Held; len(held) > 0 { var names []string for _, h := range held { - names = append(names, h.Module) + today := h.Today + if today == "" { + today = "held" + } + names = append(names, h.Module+" ("+today+")") } - fmt.Fprintf(&b, "; stays held as today: %s", strings.Join(names, ", ")) + fmt.Fprintf(&b, "; stays as today: %s", strings.Join(names, ", ")) } } return b.String() diff --git a/go/compose/judge/judge_test.go b/go/compose/judge/judge_test.go index 510830a..701f197 100644 --- a/go/compose/judge/judge_test.go +++ b/go/compose/judge/judge_test.go @@ -79,7 +79,7 @@ func TestJudge(t *testing.T) { t.Errorf("the difference is named %q", got) } } - if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays held as today: spiegel") { + if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays as today: spiegel (held)") { t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v)) } if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) { diff --git a/go/delivery/placement_candidate.go b/go/delivery/placement_candidate.go index 241bbce..cef4c32 100644 --- a/go/delivery/placement_candidate.go +++ b/go/delivery/placement_candidate.go @@ -190,10 +190,13 @@ type ModuleAdded struct { Unissued []string `json:"unissued,omitempty"` } -// ModuleRefused is a value missing for a module the node runs, in the words the composition stood in for it with. +// ModuleRefused is a value missing for a module the node runs, in the words the composition stood in for it with. Today, +// in CandidateAnswer.Held, is how the node keeps it today (held, failed, runs a build of the old form, held behind +// another), which the candidate keeps. type ModuleRefused struct { Module string `json:"module"` Why string `json:"why"` + Today string `json:"today,omitempty"` } // VerdictOf is the verdict of an answer's lists: refused on any refusal, differs on any difference, addition or From 76f7c4d2238ffc0963a36a556fcde6cb71ed2943 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 23:56:33 +0100 Subject: [PATCH 2/2] A stub keeps its items and their templates, so a held module still contributes to other modules' collections; it drops only the templates for its own files (issue 1126) --- go/compose/composed.go | 13 +++++++---- go/compose/frombus/answer_rows_test.go | 31 ++++++++++++++++++++------ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/go/compose/composed.go b/go/compose/composed.go index 5c60334..599e890 100644 --- a/go/compose/composed.go +++ b/go/compose/composed.go @@ -318,10 +318,15 @@ func Stub(m catalogue.Manifest) catalogue.Manifest { m.OwnsPaths = catalogue.DeclaredPaths(m) } m.Build = nil - // Nor is anything of its own handed or collected: the templates and items its build kept fill resources and items a - // stub does not have (issue 1126: novox's node refused "postgres: its build kept a template for consumers, which it - // does not declare" with postgres held). - m.Handed, m.Items = nil, nil + // Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node + // refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its + // items stay, and the templates they render through: a held module's contributions to other modules' collections + // (seat contributions, shell blocks, seat data) still reach their collectors. + if m.Handed != nil { + h := *m.Handed + h.Templates = nil + m.Handed = &h + } var dirs []map[string]any for _, r := range m.Resources { if r["type"] == "directory" { diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go index b72850a..a870cd9 100644 --- a/go/compose/frombus/answer_rows_test.go +++ b/go/compose/frombus/answer_rows_test.go @@ -294,13 +294,30 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) } - // Issue 1126: a held module hands and collects nothing of its own, so a template its build kept for a file it no - // longer carries refuses nothing (novox's node: "postgres: its build kept a template for consumers"). - kept := f1 - kept.Handed = &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"}} - kept.Items = []catalogue.Item{{Collection: "routes", Content: "x"}} - if st := compose.Stub(kept); st.Handed != nil || st.Items != nil { - t.Errorf("a stub hands %+v and collects %+v", st.Handed, st.Items) + // Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node: + // "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it + // contributes to other modules' collections still reaches them. + for _, row := range []struct { + name string + handed *catalogue.Handed + items []catalogue.Item + }{ + {"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"}, + Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}}, + {"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}}, + } { + kept := f1 + kept.Handed, kept.Items = row.handed, row.items + st := compose.Stub(kept) + if st.Handed != nil && st.Handed.Templates != nil { + t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates) + } + if !reflect.DeepEqual(st.Items, row.items) { + t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items) + } + if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) { + t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items) + } } var k1, k2 any _ = json.Unmarshal(keptComposed, &k1)