buildrun gains AssignerHolder, AssignmentsOf and SleepFor (moved from the builder's program, which uses them), so the builder and the checker follow the holder's first-view tool, views family and state alike; the checker's manifest consumes the seat's views assignments and reads its assignments state, its composition reads the picture once followed. The assigner's callers list for the first view gains the checker. Builds: design 47 G A-82, A-119, A-126, A-131 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
237 lines
9.7 KiB
Go
237 lines
9.7 KiB
Go
package buildrun
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"slices"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient"
|
|
meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
|
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
|
)
|
|
|
|
// A build module writes <node>.<language> of its toolchains state on every node it runs on, and nothing deletes the key
|
|
// when the module leaves a node: no part writes another module's state, and the module is gone. A report of a node the
|
|
// build module no longer runs on is therefore no builder's (issue 1940: mesh-build-node taken off ace on 2026-10-23 left
|
|
// ace.typescript at v26.12.0, and every Node fingerprint said "builders disagree").
|
|
//
|
|
// ToolchainGate stands between the toolchains buckets and what takes their entries in (the indexer's PutToolchain, a
|
|
// ToolchainSet): an entry is passed on only while the assignments (the seat mesh-module-assigner's state `assignments`, held by
|
|
// the assigner, its holder's state assignments, read by meshmoduleassignerclient.Placed; ADR 0567, ADR 0549 §3) places its build module on
|
|
// its node, and only once it was written since that assignments; it is passed on as deleted otherwise, said once, and
|
|
// passed on when its node writes it again. A report written before the assignments is one an earlier run of the module
|
|
// left (issue 1940, 2026-10-25 20:56: mesh-build-node put on ace again brought ace.typescript v26.12.0 of 2026-10-23
|
|
// back into the agreement before the module ran there). Until the assignments is settled, and while no node's assignments
|
|
// is held (before the assigner's adopt writes the first one, or where the state cannot be read), every entry is passed
|
|
// on. Builders that run and report different versions still disagree: that refusal is the guard of novox/hq ADR 0465,
|
|
// and it stays.
|
|
//
|
|
// This replaces the copies of the gate the indexer, the builder and the checker each kept over the controller's
|
|
// assignments (issue 1940's subtask, issue 774: those go with the controller's lease, ADR 0549 §6).
|
|
type ToolchainGate struct {
|
|
mu sync.Mutex
|
|
modules map[string]bool // the build modules whose toolchains pass the gate
|
|
forward func(module, key string, value []byte, deleted bool) bool // answers whether it changed what it holds
|
|
logf func(format string, a ...any)
|
|
placed placementsSince
|
|
known bool // the assignments is settled
|
|
raw map[string]map[string][]byte // module -> toolchains key -> value, as the bucket holds it
|
|
left map[string]string // <module>/<key> -> why it was said left out, so each reason is said once
|
|
// OnPlaced is told, outside the gate's lock, once the assignments's history is read and after each revision of it
|
|
// FollowAssignmentsIn hears from then on (the counted nodes, Nodes, may have changed). Nil tells nobody.
|
|
OnPlaced func()
|
|
}
|
|
|
|
// placementsSince is where the gate reads which modules stand on which node, and since when: the assigner's state
|
|
// (meshmoduleassignerclient.Placed, fed by Assignments) or the assignment half (meshwantsclient.Placed, fed by Revision).
|
|
type placementsSince interface {
|
|
Seeded() bool
|
|
Since(node, module string) (time.Time, bool)
|
|
ByNode() map[string][]string
|
|
}
|
|
|
|
// NewToolchainGate is a gate over the toolchains of modules, passing what stands on to forward and saying with logf what
|
|
// it leaves out.
|
|
func NewToolchainGate(modules []string, forward func(module, key string, value []byte, deleted bool) bool, logf func(string, ...any)) *ToolchainGate {
|
|
g := &ToolchainGate{modules: map[string]bool{}, forward: forward, logf: logf, placed: meshmoduleassignerclient.NewPlaced(),
|
|
raw: map[string]map[string][]byte{}, left: map[string]string{}}
|
|
for _, m := range modules {
|
|
g.modules[m] = true
|
|
}
|
|
return g
|
|
}
|
|
|
|
// UseModules replaces the build modules whose reports the gate passes on again when the assignments moves (a part that
|
|
// finds them from the seat list and the manifests, as the indexer does, gives them at each change), and passes every
|
|
// report it holds on again. It answers whether what it passed on changed.
|
|
func (g *ToolchainGate) UseModules(modules []string) bool {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.modules = map[string]bool{}
|
|
for _, m := range modules {
|
|
g.modules[m] = true
|
|
}
|
|
return g.all()
|
|
}
|
|
|
|
// Toolchain takes one change of a build module's toolchains bucket in and answers whether what it passed on changed.
|
|
func (g *ToolchainGate) Toolchain(module, key string, value []byte, deleted bool) bool {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
if g.raw[module] == nil {
|
|
g.raw[module] = map[string][]byte{}
|
|
}
|
|
if deleted {
|
|
delete(g.raw[module], key)
|
|
} else {
|
|
g.raw[module][key] = value
|
|
}
|
|
return g.pass(module, key)
|
|
}
|
|
|
|
// Assignments takes one revision of the seat mesh-module-assigner's assignments in (at is when the bus wrote it) and answers whether what it
|
|
// passed on changed. A value that does not read is said, and places nothing on its node.
|
|
func (g *ToolchainGate) Assignments(key string, value []byte, deleted bool, at time.Time) bool {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
placed, ok := g.placed.(*meshmoduleassignerclient.Placed)
|
|
if !ok {
|
|
g.logf("the assignments: %s is not read, the gate takes the assignment half (design 47)", key)
|
|
return false
|
|
}
|
|
was := placed.Seeded()
|
|
node, changed, err := placed.Put(key, value, deleted, at)
|
|
if err != nil {
|
|
g.logf("the assignments: %v (issue 1940)", err)
|
|
}
|
|
if !g.known {
|
|
return false
|
|
}
|
|
if was != g.placed.Seeded() {
|
|
// The first assignments came, or the last went: every report's standing changed.
|
|
return g.all()
|
|
}
|
|
return g.again(node, changed)
|
|
}
|
|
|
|
// Revision takes one assignment graph revision the part's view holds in (design 47 *Each part acts on its own entries*:
|
|
// the placements come from the seat mesh-wants' assignment half, never the assigner's state; assignmentview's Held hands
|
|
// each) and answers whether what it passed on changed. The first revision taken settles the gate, as the assignments'
|
|
// history read does; a later one passes on again the reports of each node whose build modules' standing changed. A gate
|
|
// fed by Revision reads no Assignments.
|
|
func (g *ToolchainGate) Revision(r meshwants.AssignmentRevision) bool {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
placed, ok := g.placed.(*meshwantsclient.Placed)
|
|
if !ok {
|
|
placed = meshwantsclient.NewPlaced()
|
|
g.placed = placed
|
|
}
|
|
was := placed.Seeded()
|
|
changed := placed.Take(r)
|
|
if changed == nil {
|
|
return false
|
|
}
|
|
if !g.known || was != placed.Seeded() {
|
|
g.known = true
|
|
return g.all()
|
|
}
|
|
moved := false
|
|
for _, node := range slices.Sorted(maps.Keys(changed)) {
|
|
moved = g.again(node, changed[node]) || moved
|
|
}
|
|
return moved
|
|
}
|
|
|
|
// Nodes are the nodes the assignments places any module on, sorted: the nodes the mesh counts (a node leaves the count
|
|
// when the assignments no longer names it; ADR 0624 §1). None until the assignments is read.
|
|
func (g *ToolchainGate) Nodes() []string {
|
|
var out []string
|
|
for node, modules := range g.placed.ByNode() {
|
|
if len(modules) > 0 {
|
|
out = append(out, node)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// Settled says the assignments's history is read: from now on only the reports of placed nodes pass, once a node's
|
|
// assignments is held. It answers whether what it passed on changed.
|
|
func (g *ToolchainGate) Settled() bool {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.known = true
|
|
return g.all()
|
|
}
|
|
|
|
// all passes on every report again. The caller holds mu.
|
|
func (g *ToolchainGate) all() bool {
|
|
changed := false
|
|
for _, module := range slices.Sorted(maps.Keys(g.raw)) {
|
|
for _, k := range slices.Sorted(maps.Keys(g.raw[module])) {
|
|
changed = g.pass(module, k) || changed
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// again passes on every report of node by a build module among modules. The caller holds mu.
|
|
func (g *ToolchainGate) again(node string, modules []string) bool {
|
|
changed := false
|
|
for _, module := range modules {
|
|
if !g.modules[module] {
|
|
continue
|
|
}
|
|
for _, k := range slices.Sorted(maps.Keys(g.raw[module])) {
|
|
if n, _, err := buildstep.ParseToolchainKey(k); err == nil && n == node {
|
|
changed = g.pass(module, k) || changed
|
|
}
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// pass hands one key on as it stands: its value when its node is placed and wrote it since (or the assignments is not
|
|
// known), deleted otherwise. The caller holds mu.
|
|
func (g *ToolchainGate) pass(module, key string) bool {
|
|
value, held := g.raw[module][key]
|
|
if !held {
|
|
return g.forward(module, key, nil, true)
|
|
}
|
|
node, _, err := buildstep.ParseToolchainKey(key)
|
|
why := ""
|
|
if err == nil && g.known && g.placed.Seeded() {
|
|
why = g.leftOut(module, node, value)
|
|
}
|
|
if why == "" {
|
|
delete(g.left, module+"/"+key)
|
|
return g.forward(module, key, value, false)
|
|
}
|
|
if g.left[module+"/"+key] != why {
|
|
g.left[module+"/"+key] = why
|
|
g.logf("%s.%s is left out of the agreed toolchain: %s (issue 1940)", ToolchainsBucket(module), key, why)
|
|
}
|
|
return g.forward(module, key, nil, true)
|
|
}
|
|
|
|
// leftOut is why a node's report is no builder's, or empty when it counts: the catalogue does not place the module on its
|
|
// node, or the report was written before it did. A value that is no toolchain is passed on for its reader to refuse.
|
|
// The caller holds mu.
|
|
func (g *ToolchainGate) leftOut(module, node string, value []byte) string {
|
|
since, placed := g.placed.Since(node, module)
|
|
if !placed {
|
|
return fmt.Sprintf("the catalogue does not place %s on %s, so it is no builder", module, node)
|
|
}
|
|
tc, err := buildstep.DecodeToolchain(value)
|
|
if err == nil && tc.At.Before(since) {
|
|
return fmt.Sprintf("it was written at %s, before %s was placed on %s at %s: it counts once %s writes it again",
|
|
tc.At.UTC().Format(time.RFC3339), module, node, since.UTC().Format(time.RFC3339), node)
|
|
}
|
|
return ""
|
|
}
|