Merge pull request 'Delete a closed pull request's placement candidates, and give indexer_placement a summary (issue 1126, group fix/1126-seed-same)' (#93) from fix/1126-seed-same into main

This commit was merged in pull request #93.
This commit is contained in:
2026-10-28 09:46:34 +00:00
12 changed files with 245 additions and 27 deletions
@@ -284,7 +284,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
defer calls.Close()
ix := &index.Indexer{Store: kvStore{candidates}, Modules: kvStore{modules}, Manifests: kvStore{manifests}, Taken: awaitedStore{taken}, Builds: known, Logf: logger.Printf,
Named: namedVia(calls.Call), Trunk: trunkVia(calls.Call), Contains: containsVia(calls.Call), Read: readVia(calls.Call),
Placement: awaitedStore{placement}, PlacementCandidates: awaitedStore{placementCandidates}, ReadPlacement: placementVia(calls.Call), Say: func(event string, body any) error { return calls.Emit(event, body) }}
Placement: awaitedStore{placement}, PlacementCandidates: awaitedStore{placementCandidates}, ReadPlacement: placementVia(calls.Call), ReadOpen: openVia(calls.Call), Say: func(event string, body any) error { return calls.Emit(event, body) }}
// The gate's source bucket (mesh-delivery-gate_source) comes with the gate (release 3e, novox/hq ADR 0450 §1): its key
// main is the mesh's source revision, which stamps the modules (issue 1344). Where it does not exist yet the indexer does
// not wait for it (that needs a timer, ADR 0437 §8): it says so, and is started again with the gate.
@@ -179,6 +179,34 @@ const (
placementTrunk = "main"
)
// openVia asks the forge which pull requests into the trunk are open now (gitea_open_pulls), every repository's.
func openVia(call toolCaller) func(ctx context.Context) ([]delivery.OpenPull, error) {
return func(context.Context) ([]delivery.OpenPull, error) {
raw, err := call(openPullsTool, map[string]any{})
if err != nil {
return nil, err
}
var o struct {
Pulls *[]struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Base string `json:"base"`
} `json:"pulls"`
}
if err := json.Unmarshal(raw, &o); err != nil || o.Pulls == nil {
return nil, fmt.Errorf("the answer of %s is not {pulls}: %v", openPullsTool, err)
}
out := []delivery.OpenPull{}
for _, p := range *o.Pulls {
if p.Base == placementTrunk && p.Number > 0 {
out = append(out, delivery.OpenPull{Repository: p.Owner + "/" + p.Repo, Number: p.Number})
}
}
return out, nil
}
}
// placementVia reads the catalogue's placement now through call: the trunk's head (gitea_list_branches), every placement
// file at it, and each open pull request of the catalogue into the trunk (gitea_open_pulls) with every placement file at
// its head, each read whole or not at all (gitea_commit_trees with listed, gitea_get_file).
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-indexer
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 h1:TmveT3Bija643RGWkilvkzP/h8UWlVvHFZZtQfJK5oU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2 h1:eSwObLdxs3jxPrlcwdaMo9hzIDTUz2ogWw8cjX8To7I=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -73,6 +73,10 @@ type Indexer struct {
// ReadPlacement asks the forge for the catalogue's placement now, at the trunk's head and each open pull request's
// (indexer_placement_reread, ADR 0559 §8); nil rereads none.
ReadPlacement func(ctx context.Context) (PlacementRead, error)
// ReadOpen asks the forge which pull requests into the trunk are open now (gitea_open_pulls). A catalogue pull message
// that finds the candidates of another pull request asks it and deletes those of a pull request no longer open, so a
// closed message the indexer missed heals at the next one (issue 1126); nil asks none.
ReadOpen func(ctx context.Context) ([]delivery.OpenPull, error)
// Say says one of the indexer's events (placement-changed); nil says none.
Say func(event string, body any) error
// Drained says whether the indexer has applied every message of the forge stream (the consumer has none pending and none
@@ -48,6 +48,7 @@ func (ix *Indexer) applyPlacementCandidates(ctx context.Context, f delivery.Forg
}
return nil
}
ix.dropCandidatesOfClosedPulls(ctx, f)
trunk, err := ix.trunkPlacements(ctx)
if err != nil {
ix.logf("%s#%d at %s: its placement candidates are not written: %v", f.Repository, f.Number, f.Head, err)
@@ -97,6 +98,55 @@ func (ix *Indexer) applyPlacementCandidates(ctx context.Context, f delivery.Forg
}, fmt.Sprintf("%s#%d at %s", f.Repository, f.Number, f.Head))
}
// dropCandidatesOfClosedPulls heals a closed or merged message the indexer never heard (issue 1126: closed probe 532 kept
// its candidates, and ace answered them for 22 minutes before the open 534). When a catalogue pull message arrives and a
// candidate of another pull request exists, the forge is asked which pull requests are open, and a candidate of one that is
// not is deleted. A forge that cannot say leaves them as they are and is said in the log; indexer_placement_reread is the
// same by hand.
func (ix *Indexer) dropCandidatesOfClosedPulls(ctx context.Context, f delivery.Forge) {
if ix.ReadOpen == nil {
return
}
keys, err := ix.PlacementCandidates.Keys(ctx)
if err != nil {
return
}
other := false
for _, key := range keys {
value, _, found, err := ix.PlacementCandidates.Get(ctx, key)
if err != nil || !found {
continue
}
if c, err := delivery.DecodePlacementCandidate(value); err != nil || c.Pull.Number != f.Number || !delivery.SameRepository(c.Pull.Repository, f.Repository) {
other = true
break
}
}
if !other {
return
}
open, err := ix.ReadOpen(ctx)
if err != nil {
ix.logf("%s#%d: the open pull requests could not be read, so no stale placement candidate is deleted: %v", f.Repository, f.Number, err)
return
}
isOpen := map[delivery.OpenPull]bool{}
for _, p := range open {
isOpen[delivery.OpenPull{Repository: p.Repository, Number: p.Number}] = true
}
isOpen[delivery.OpenPull{Repository: f.Repository, Number: f.Number}] = true // the message in hand is an open one
if err := ix.dropPlacementCandidates(ctx, func(_ string, c delivery.PlacementCandidate) bool {
for p := range isOpen {
if p.Number == c.Pull.Number && delivery.SameRepository(p.Repository, c.Pull.Repository) {
return false
}
}
return true
}, "its pull request is not open (the forge says so at the next pull message)"); err != nil {
ix.logf("%s#%d: stale placement candidates are not deleted: %v", f.Repository, f.Number, err)
}
}
// trunkPlacements is every node's placement in the trunk's state placement, by node.
func (ix *Indexer) trunkPlacements(ctx context.Context) (map[string]delivery.Placement, error) {
out := map[string]delivery.Placement{}
@@ -121,28 +121,46 @@ func placementIsWrittenAtAMergeOnlyWhereItChanged(t *testing.T) {
}
keys := func() string { k, _ := cands.Keys(ctx); slices.Sort(k); return strings.Join(k, ",") }
g14New := `{"v":1,"node":"g14","modules":[{"module":"zsh"}]}`
g14P := `{"v":1,"node":"g14","modules":[{"module":"htop"}]}`
g14Q := `{"v":1,"node":"g14","modules":[{"module":"mc"}]}`
for _, row := range []struct {
name string
f delivery.Forge
keys string
open []int // the pull requests the forge says are open; nil: the indexer asks none
}{
{"a pull request adding g14", pullOf("feat/p", 9, h1, map[string]string{"ace": ace, "g14": g14New}), "feat/p.g14"},
{"the same head again", pullOf("feat/p", 9, h1, map[string]string{"ace": ace, "g14": g14New}), "feat/p.g14"},
{"a pull message that read no placement", pullOf("feat/p", 9, h2, nil), "feat/p.g14"},
{"a pull request changing no placement", pullOf("feat/q", 10, h1, map[string]string{"ace": ace}), "feat/p.g14"},
{"a new head removing ace", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14"},
{"a head whose g14 file does not read", pullOf("feat/p", 9, h3, map[string]string{"g14": `{"v":1,"node":"ace","modules":[]}`}), "feat/p.ace"},
{"the head adding g14 again", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14"},
{"a head the forge says changes no placement file", func() delivery.Forge { f := pullOf("feat/p", 9, h3, nil); f.DirsKnown = true; return f }(), ""},
{"the head adding g14 and removing ace again", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14"},
{"an open message not naming it", delivery.Forge{V: delivery.Version, Kind: delivery.ForgeOpen, Open: []delivery.OpenPull{{Repository: delivery.CatalogueRepository, Number: 10}}}, ""},
{"the pull request heard again", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14"},
{"a pull request adding g14", pullOf("feat/p", 9, h1, map[string]string{"ace": ace, "g14": g14New}), "feat/p.g14", nil},
{"the same head again", pullOf("feat/p", 9, h1, map[string]string{"ace": ace, "g14": g14New}), "feat/p.g14", nil},
{"a pull message that read no placement", pullOf("feat/p", 9, h2, nil), "feat/p.g14", nil},
{"a pull request changing no placement", pullOf("feat/q", 10, h1, map[string]string{"ace": ace}), "feat/p.g14", nil},
{"a new head removing ace", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14", nil},
{"a head whose g14 file does not read", pullOf("feat/p", 9, h3, map[string]string{"g14": `{"v":1,"node":"ace","modules":[]}`}), "feat/p.ace", nil},
{"the head adding g14 again", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14", nil},
{"a head the forge says changes no placement file", func() delivery.Forge { f := pullOf("feat/p", 9, h3, nil); f.DirsKnown = true; return f }(), "", nil},
{"the head adding g14 and removing ace again", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14", nil},
{"an open message not naming it", delivery.Forge{V: delivery.Version, Kind: delivery.ForgeOpen, Open: []delivery.OpenPull{{Repository: delivery.CatalogueRepository, Number: 10}}}, "", nil},
{"the pull request heard again", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14", nil},
{"its merge", func() delivery.Forge {
m := merged("m7", map[string]string{"g14": g14New})
m.Branch, m.Number = "feat/p", 9
return m
}(), ""},
}(), "", nil},
// A closed message the indexer never heard heals at the next pull message (issue 1126, closed probe 532).
{"pull 9 changes g14, both open", pullOf("feat/p", 9, h2, map[string]string{"g14": g14P}), "feat/p.g14", []int{9, 10}},
{"pull 10 changes g14, both open", pullOf("feat/q", 10, h1, map[string]string{"g14": g14Q}), "feat/p.g14,feat/q.g14", []int{9, 10}},
{"pull 9 closed unheard, pull 10 heard again", pullOf("feat/q", 10, h2, map[string]string{"g14": g14Q}), "feat/q.g14", []int{10}},
{"pull 10 closed", func() delivery.Forge { f := pullOf("feat/q", 10, h2, nil); f.Action = delivery.PullClosed; return f }(), "", []int{10}},
} {
ix.ReadOpen = nil
if row.open != nil {
ix.ReadOpen = func(context.Context) ([]delivery.OpenPull, error) {
var out []delivery.OpenPull
for _, n := range row.open {
out = append(out, delivery.OpenPull{Repository: delivery.CatalogueRepository, Number: n})
}
return out, nil
}
}
_, revBefore, _ := candidate("feat/p.g14")
if err := ix.Apply(ctx, row.f); err != nil {
t.Fatalf("%s: %v", row.name, err)
@@ -63,15 +63,28 @@ func Tools(r view.Reader, u view.UsageReader) []stdio.Tool {
return map[string]any{"modules": mods}, err
}},
{Name: view.PlacementReadTool,
Description: "Where modules run, as the indexer read it from the catalogue's placement files at the last merge (placement.<node>, novox/hq ADR 0549): " +
"each node's modules in order with their host ports, bindings and settings placement, its pins and the seats its modules hold, and the catalogue revision. " +
Description: "Placement candidates and the shadow answers per node: where modules run, as the indexer read it from the catalogue's placement files at the last merge (placement.<node>, novox/hq ADR 0549), " +
"and beside it, the candidates, each open catalogue pull request's placement of a node that differs from the trunk's (placement-candidates <group>.<node>, issue 1126) " +
"with that node's shadow answer from its <node>.node have (answered: same, differs, refused, stale for an earlier head, none when it has not answered), as the node wrote it: " +
"the modules that are the same, those that differ with each differing resource's why, bodies and the JSON paths that differ, the refusals. " +
"Each node's placement lists its modules in order with their host ports, bindings and settings placement, its pins and the seats its modules hold, and the catalogue revision. " +
"With node, that node's; with module, every node placing it; with neither, every node's. Keys that do not read are listed as unreadable. " +
"Beside it, candidates: each open catalogue pull request's placement of a node that differs from the trunk's (placement-candidates <group>.<node>, issue 1126), " +
"narrowed the same way, with the node's answer from its <node>.node have (answered: same, differs, refused, stale for an earlier head, none when it has not answered). Read-only.",
Input: map[string]any{"type": "object", "properties": nodeModule},
"With summary true, only the compact form: per node and candidate the answer, the counts of same and differing, and per differing resource its module, id, why and paths, with no bodies and no placement (a read for one node is about 60 KB whole). Read-only.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"node": nodeModule["node"],
"module": nodeModule["module"],
"summary": map[string]any{"type": "boolean", "description": "the compact form: the answer, the counts and the differing resources' paths, without bodies or placements"}}},
Run: func(args map[string]any) (any, error) {
node, module := nodeModuleOf(args)
return r.Placement(node, module)
out, err := r.Placement(node, module)
if err != nil {
return out, err
}
if summary, _ := args["summary"].(bool); summary {
rows, _ := out["candidates"].([]view.CandidateRow)
return map[string]any{"candidates": view.SummarizeCandidates(rows)}, nil
}
return out, nil
}},
{Name: view.CandidateReadTool,
Description: "What the indexer stored for one candidate (the open pull requests of a branch) under candidates.<group>, as stored: " +
@@ -182,6 +182,35 @@ func TestModulesToolAnswersEveryKeyAsStored(t *testing.T) {
if c := out.(map[string]any)["candidates"].([]view.CandidateRow); len(c) != 2 {
t.Errorf("the candidates placing plex: %v (g14's removes its file)", c)
}
// Issue 1126, summary: true keeps the verdict, the counts and each differing resource's module, id, why and paths, and
// drops the bodies and the placements; a field the node adds to a differing resource (paths) rides through whole.
p.HavesState = fake{
"ace.node": `{"v":1,"composed":"x","candidates":{"feat/p":{"head":"` + h2 + `","revision":0,"verdict":"differs","same":["plex","zsh"],` +
`"differs":[{"module":"brother","resource":"brother.cfg","composed":"sha256:a","running":"sha256:b","why":"the resource differs",` +
`"bodies":[{"id":"brother.cfg","why":"the resource differs","paths":["sealed","links.0"],"composed":"BODYBODYBODY","running":"BODYBODYBODY"}]}]}}}`,
}.reader().State
out, err = run(t, p, view.PlacementReadTool, map[string]any{"summary": true})
if err != nil {
t.Fatal(err)
}
sums := out.(map[string]any)["candidates"].([]view.CandidateSummary)
raw, _ := json.Marshal(sums)
var ace2 *view.CandidateSummary
for i := range sums {
if sums[i].Node == "ace" {
ace2 = &sums[i]
}
}
if _, has := out.(map[string]any)["placement"]; has || ace2 == nil || ace2.Verdict != "differs" || ace2.Same != 2 || ace2.Differing != 1 ||
strings.Contains(string(raw), "BODY") || !strings.Contains(string(raw), `"paths":["sealed","links.0"]`) || !strings.Contains(string(raw), `"module":"brother"`) ||
!strings.Contains(string(raw), `"why":"the resource differs"`) {
t.Errorf("the summary of a differing answer: %s", raw)
}
whole, _ := run(t, p, view.PlacementReadTool, map[string]any{})
if w, _ := json.Marshal(whole); !strings.Contains(string(w), "BODYBODYBODY") || !strings.Contains(string(w), `"paths"`) {
t.Errorf("the whole answer drops what the node wrote: %s", w)
}
}
const (
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-view
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028003907-362a3f1378aa
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -4
View File
@@ -1,7 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 h1:TmveT3Bija643RGWkilvkzP/h8UWlVvHFZZtQfJK5oU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028003907-362a3f1378aa h1:DFYX7pIluzeeEyM+YXGeB6GvEnOYyRhz/1Ke/LFa8+8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028003907-362a3f1378aa/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2 h1:eSwObLdxs3jxPrlcwdaMo9hzIDTUz2ogWw8cjX8To7I=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -852,6 +852,84 @@ func (r Reader) PlacementCandidates(node, module string) ([]CandidateRow, error)
return rows, nil
}
// CandidateSummary is one placement candidate in the compact form of indexer_placement (summary: true): the node's answer
// counted and its differing resources named, without the candidate's value or any resource body.
type CandidateSummary struct {
Group string `json:"group"`
Node string `json:"node"`
Pull delivery.Head `json:"pull"`
Revision uint64 `json:"revision"`
Removes bool `json:"removes,omitempty"`
Answered string `json:"answered"`
// Verdict, Head and the counts are the node's answer as it wrote it; Differs names each differing module and, in
// Resources, each differing resource with why and the JSON paths that differ when the node says them.
Verdict string `json:"verdict,omitempty"`
Head string `json:"head,omitempty"`
Same int `json:"same"`
Differing int `json:"differing"`
Differs []map[string]any `json:"differs,omitempty"`
Added int `json:"added,omitempty"`
Removed int `json:"removed,omitempty"`
Refused string `json:"refused,omitempty"`
Refusals json.RawMessage `json:"refusals,omitempty"`
}
// summaryKeep are the keys a summary keeps of a differing module and of a differing resource; a body (composed, running)
// is not among them, and a key a newer node adds is left out until it is named here.
var (
summaryModuleKeys = []string{"module", "resource", "why", "paths"}
summaryResourceKeys = []string{"id", "why", "paths"}
)
// SummarizeCandidates is the compact form of rows: per candidate the answer's verdict, the counts of same and differing
// resources, and per differing resource its module, id, why and paths list when the node says one, with no body.
func SummarizeCandidates(rows []CandidateRow) []CandidateSummary {
out := make([]CandidateSummary, 0, len(rows))
pick := func(m map[string]json.RawMessage, keys []string) map[string]any {
o := map[string]any{}
for _, k := range keys {
if v, ok := m[k]; ok {
o[k] = v
}
}
return o
}
for _, row := range rows {
sum := CandidateSummary{Group: row.Group, Node: row.Node, Pull: row.Pull, Revision: row.Revision, Removes: row.Removes, Answered: row.Answered}
var a struct {
Verdict string `json:"verdict"`
Head string `json:"head"`
Same []json.RawMessage `json:"same"`
Differs []map[string]json.RawMessage `json:"differs"`
Added []json.RawMessage `json:"added"`
Removed []json.RawMessage `json:"removed"`
Refusals json.RawMessage `json:"refusals"`
Refused string `json:"refused"`
}
if len(row.Answer) > 0 && json.Unmarshal(row.Answer, &a) == nil {
sum.Verdict, sum.Head, sum.Same, sum.Added, sum.Removed = a.Verdict, a.Head, len(a.Same), len(a.Added), len(a.Removed)
sum.Refused, sum.Refusals = a.Refused, a.Refusals
for _, d := range a.Differs {
m := pick(d, summaryModuleKeys)
var bodies []map[string]json.RawMessage
if json.Unmarshal(d["bodies"], &bodies) == nil && len(bodies) > 0 {
res := make([]map[string]any, 0, len(bodies))
for _, b := range bodies {
res = append(res, pick(b, summaryResourceKeys))
}
m["resources"] = res
sum.Differing += len(bodies)
} else {
sum.Differing++
}
sum.Differs = append(sum.Differs, m)
}
}
out = append(out, sum)
}
return out
}
// AnswerOf is the state of a node's answer a to its group's candidates at head: its verdict when it answers that head
// and, for a node with a key of its own (keyed), that key's revision rev; AnswerStale otherwise.
func AnswerOf(head string, rev uint64, keyed bool, a delivery.CandidateAnswer) string {