The assigner's state and verb placement are assignments, with its events, tools, views and every caller; the assigner copies the retired state at its start (issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 22:13:20 +01:00
parent 369ebd092d
commit 0eafb2fe95
164 changed files with 1699 additions and 1498 deletions
@@ -25,7 +25,7 @@ func holdsTool(read stdio.KeptState, ask asker, now func() time.Time, acts meshh
") which not every counted node-engine states, present or away (ADR 0624 §1). Per module: what it needs, which nodes " +
"lack what (or why no node is counted yet), the manifest the modules state keeps and the one held, the merge that " +
"brought it and since when. The hold moves by itself once every counted node-engine states it (on manifest-reads-changed, " +
"the placement's change or a merge). With module and ask_again, the engines of the nodes that lack it are asked again " +
"the assignments's change or a merge). With module and ask_again, the engines of the nodes that lack it are asked again " +
"and the hold is weighed now; with module, release and why, it moves now by your word whatever the engines state (a hand " +
"act, recorded through mesh-hand-acts, which names the nodes it strands: each node whose engine lacks the reading, an " +
"away node from its return, reads the new entry without it and an engine before form:seat-reach finds no store). A hold " +
@@ -29,7 +29,7 @@ func viewTools(getenv func(string) string, state func(string) views.Source, now
for _, t := range index.ReadTools {
out = append(out, views.ReadTool(index.Module, t.Tool, t.State, t.Words, state(t.State), marks, now))
}
// Its own first views, of the assigner's placement (DLV-F3r): read, and asked again by hand.
// Its own first views, of the assigner's assignments (DLV-F3r): read, and asked again by hand.
return append(out, views.StatusTools(index.Module, "indexer_first_views", "indexer_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, now)...)
}
@@ -24,8 +24,8 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
@@ -559,7 +559,7 @@ func startIndexerRun(t *testing.T, put func(context.Context, buildLog), before .
b(ctx, js)
}
serveAssignerPlacement(t, ctx, js, ctl)
serveAssignerAssignments(t, ctx, js, ctl)
broker := filepath.Join(t.TempDir(), "broker")
cred, _ := json.Marshal(process.Account{URL: srv.ClientURL(), Node: "n1", Module: "mesh-delivery-indexer", User: "n1.mesh-delivery-indexer", Password: "pw"})
@@ -978,33 +978,33 @@ func TestIndexerJudgesAnUnreadableVerdictAgainAtStart(t *testing.T) { // 1923/AC
// TestIndexerCountsOnlyThePlacedBuildNodesToolchains (issue 1940, issue 774): the toolchains bucket holds the report of
// n2, where the catalogue no longer places mesh-build-go, at another version than n1's, as ace's v26.12.0 stayed beside
// the v26.12.1 of the nodes mesh-build-node runs on. The indexer gives the merged module a fingerprint from n1's
// toolchain alone; when the placement puts mesh-build-go on n2 again, its report written since counts and the builders
// toolchain alone; when the assignments puts mesh-build-go on n2 again, its report written since counts and the builders
// disagree (the guard stays); when it is taken off again, the fingerprint comes back. No restart, no timer: the
// placement's watch (ADR 0549 §3), not the controller's assignments.
// assignments's watch (ADR 0549 §3), not the controller's assignments.
func TestIndexerCountsOnlyThePlacedBuildNodesToolchains(t *testing.T) {
manifest, err := os.ReadFile("../../internal/index/testdata/catalogue-a67f0196-avahi.module.json")
if err != nil {
t.Fatal(err)
}
const goSum = "3b5028f0c1e9bde04dd766abd63eb5931bc516bae695cf46f00abcd8510b0381"
var placement, toolchains jetstream.KeyValue
var assignments, toolchains jetstream.KeyValue
place := func(ctx context.Context, t *testing.T, node string, modules ...string) {
p := meshmoduleassigner.Placement{Node: node}
p := meshmoduleassigner.Assignments{Node: node}
for _, m := range modules {
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m})
}
// Its node key, the commit that names the modules placed (research 072 row 37).
values, err := meshmoduleassignerclient.PlacementValues(p)
values, err := meshmoduleassignerclient.AssignmentsValues(p)
if err != nil {
t.Fatal(err)
}
key := meshmoduleassigner.PlacementByNodeNodeKey(node)
if _, err := placement.Put(ctx, key, values[key]); err != nil {
key := meshmoduleassigner.AssignmentsByNodeNodeKey(node)
if _, err := assignments.Put(ctx, key, values[key]); err != nil {
t.Fatal(err)
}
}
// DLV-F5r (ADR 0621 §3): the assigner's runtime starts together with the indexer and answers its first view only after
// the indexer asked; within the bound the indexer follows the placement it answers (only n1's toolchain counts).
// the indexer asked; within the bound the indexer follows the assignments it answers (only n1's toolchain counts).
up := make(chan struct{})
assignerLate = up
t.Cleanup(func() { assignerLate = nil })
@@ -1024,8 +1024,8 @@ func TestIndexerCountsOnlyThePlacedBuildNodesToolchains(t *testing.T) {
t.Fatal(err)
}
}
// The seat mesh-placement's placement, as the assigner writes it (ADR 0567): n1 places mesh-build-go, n2 nothing.
if placement, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: index.PlacementBucket, History: index.PlacementHistory}); err != nil {
// The seat mesh-module-assigner's assignments, as the assigner writes it (ADR 0567): n1 places mesh-build-go, n2 nothing.
if assignments, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: index.AssignmentsBucket, History: index.AssignmentsHistory}); err != nil {
t.Fatal(err)
}
place(ctx, t, "n1", "mesh-build-go")
@@ -1072,13 +1072,13 @@ func TestIndexerCountsOnlyThePlacedBuildNodesToolchains(t *testing.T) {
if _, err := toolchains.Put(ctx, key, value); err != nil {
t.Fatal(err)
}
// n2's report, written since its placement, counts: the builders disagree. A fingerprint already computed is kept
// n2's report, written since its assignments, counts: the builders disagree. A fingerprint already computed is kept
// (issue 1940), and only a new one needs the agreement: a merge moving avahi gets none until they agree.
time.Sleep(500 * time.Millisecond)
if e, err := modules.Get(ctx, "avahi"); err != nil {
t.Fatal(err)
} else if m, err := meshcatalogclient.DecodeModule(e.Value()); err != nil || m.Fp2 != held.Fp2 {
t.Fatalf("after n2's placement avahi has fp2 %q, none2 %q: want its fingerprint %q kept", m.Fp2, m.None2, held.Fp2)
t.Fatalf("after n2's assignments avahi has fp2 %q, none2 %q: want its fingerprint %q kept", m.Fp2, m.None2, held.Fp2)
}
moved := strings.Replace(string(data), `"newtree"`, `"movedtree"`, 1)
if moved == string(data) {
@@ -1091,11 +1091,11 @@ func TestIndexerCountsOnlyThePlacedBuildNodesToolchains(t *testing.T) {
return m.Fp2 == "" && strings.HasPrefix(m.None2, "builders disagree: go1.27.2 recipe 1 on n2")
}
if m := read(disagree); !disagree(m) {
t.Fatalf("within 20 s of a merge after n2's placement avahi has fp2 %q, none2 %q: want the builders to disagree", m.Fp2, m.None2)
t.Fatalf("within 20 s of a merge after n2's assignments avahi has fp2 %q, none2 %q: want the builders to disagree", m.Fp2, m.None2)
}
place(ctx, t, "n2")
if m := read(agreed); !agreed(m) {
t.Fatalf("within 20 s of n2's placement taken away avahi has fp2 %q, none2 %q", m.Fp2, m.None2)
t.Fatalf("within 20 s of n2's assignments taken away avahi has fp2 %q, none2 %q", m.Fp2, m.None2)
}
}
@@ -1103,14 +1103,14 @@ func TestIndexerCountsOnlyThePlacedBuildNodesToolchains(t *testing.T) {
// assigner's runtime started together with the indexer's, answering after the indexer asked (DLV-F5r).
var assignerLate chan struct{}
// serveAssignerPlacement is the assigner as the owner of its placement (ADR 0615, ADR 0621), with the real owner's side:
// its first-view tool placement answered from the bucket at its mark (the bucket made empty where a test made none),
// serveAssignerAssignments is the assigner as the owner of its assignments (ADR 0615, ADR 0621), with the real owner's side:
// its first-view tool assignments answered from the bucket at its mark (the bucket made empty where a test made none),
// and its views family said by the views emitter onto ASSIGNER_VIEWS, which the indexer's durable consumer reads.
func serveAssignerPlacement(t *testing.T, ctx context.Context, js jetstream.JetStream, nc *nats.Conn) {
func serveAssignerAssignments(t *testing.T, ctx context.Context, js jetstream.JetStream, nc *nats.Conn) {
t.Helper()
placement, err := js.KeyValue(ctx, index.PlacementBucket)
assignments, err := js.KeyValue(ctx, index.AssignmentsBucket)
if err != nil {
if placement, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: index.PlacementBucket, History: index.PlacementHistory}); err != nil {
if assignments, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: index.AssignmentsBucket, History: index.AssignmentsHistory}); err != nil {
t.Fatal(err)
}
}
@@ -1124,11 +1124,11 @@ func serveAssignerPlacement(t *testing.T, ctx context.Context, js jetstream.JetS
}
em := views.Emitter{Owner: index.AssignerModule, Subject: process.Account{Node: "n1", Module: index.AssignerModule}.EventSubject,
Publish: views.JetStreamPublish(js), Marks: marks}
if err := em.Start(ctx, index.PlacementState, placement, nil); err != nil {
if err := em.Start(ctx, index.AssignmentsState, assignments, nil); err != nil {
t.Fatal(err)
}
late := assignerLate
sub, err := nc.Subscribe("mesh.mod.mesh-delivery-assigner.call.placement.>", func(m *nats.Msg) {
sub, err := nc.Subscribe("mesh.mod.mesh-delivery-assigner.call.assignments.>", func(m *nats.Msg) {
if late != nil { // its runtime comes up after the indexer started (DLV-F5r)
select {
case <-late:
@@ -1136,7 +1136,7 @@ func serveAssignerPlacement(t *testing.T, ctx context.Context, js jetstream.JetS
return
}
}
a, err := views.FirstView(index.AssignerModule, index.PlacementState, jsSource{ctx, placement}, jsSource{ctx, marks}, "", time.Now())
a, err := views.FirstView(index.AssignerModule, index.AssignmentsState, jsSource{ctx, assignments}, jsSource{ctx, marks}, "", time.Now())
out := map[string]any{"result": a, "node": "n0"}
if err != nil {
out = map[string]any{"error": err.Error()}
@@ -472,10 +472,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
}
// Only the toolchain reports of nodes the catalogue places the build module on count (issue 1940, ADR 0549 §3): the
// placement is settled first, so the toolchains settle through the gate already knowing it.
// assignments is settled first, so the toolchains settle through the gate already knowing it.
gate := buildrun.NewToolchainGate(sortedModules(), ix.PutToolchain, logger.Printf)
// What each counted node's engine reads (ADR 0624 §1), for the mesh-wide hold of a manifest every node reads (ADR 0632
// §2): asked through the node-engine seat's verb as the indexer's own account, for every node the placement names,
// §2): asked through the node-engine seat's verb as the indexer's own account, for every node the assignments names,
// kept in the state engine-reads once it is bound; each change weighs the holds again.
reads := newEngineReads(calls.CallOn, gate, func() { ix.KickHolds(ctx) }, logger.Printf)
gate.OnPlaced = reads.placed
@@ -506,14 +506,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
process.AwaitBucket(ctx, js, conn, index.EngineReadsBucket, "what each node's engine reads is kept in memory only", logger.Printf).Ready(func(kv jetstream.KeyValue) {
reads.bind(ctx, kv, errs, logger.Printf)
})
// The placement is the assigner's: read through its first view and its views family, never its bucket (DLV-F1r).
placementErrs := make(chan error)
// The assignments is the assigner's: read through its first view and its views family, never its bucket (DLV-F1r).
assignmentsErrs := make(chan error)
// The indexer's one pull of its durable on the shared events stream (process.EventHub): every handler is registered
// before it starts.
hub := process.NewEventHub(logger.Printf)
followPlacement(ctx, hub, cred, calls.Call, gate, func(ctx context.Context) {
followAssignments(ctx, hub, cred, calls.Call, gate, func(ctx context.Context) {
if err := ix.Recompute(ctx); err != nil {
logger.Printf("writing the modules again after the placement of a build module changed: %v", err)
logger.Printf("writing the modules again after the assignments of a build module changed: %v", err)
}
}, findings, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "indexer_first_views reads nothing and indexer_first_view_again is not heard", logger.Printf), logger.Printf)
tools := newToolchainWatch(ctx, js, toolchainSink{
@@ -649,7 +649,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err := hub.Handle(numberedSubject, versions); err != nil {
return err
}
_ = notHeard.Bind(process.EventsStream, "no package's series moves, no node's manifest-reads-changed and no placement view is heard until the indexer starts again", func() error {
_ = notHeard.Bind(process.EventsStream, "no package's series moves, no node's manifest-reads-changed and no assignments view is heard until the indexer starts again", func() error {
if err := hub.Start(ctx, js, cred); err != nil {
stopped = cmp.Or(stopped, err)
return err
@@ -716,7 +716,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return err
case err := <-sourceErrs:
return err
case err := <-placementErrs:
case err := <-assignmentsErrs:
return err
case err := <-tools.errs:
return err
@@ -9,23 +9,23 @@ import (
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"mesh-delivery-indexer/internal/index"
)
// engineReads is what each counted node's engine states it reads, for the indexer's mesh-wide hold (index.Holds; novox/hq
// ADR 0632 §2, ADR 0624 §1), in the shape the builder keeps it: asked through the node-engine seat's verb manifest-reads
// for every node the placement names, on the indexer's start, on the seat's event manifest-reads-changed for that node and
// for every node the assignments names, on the indexer's start, on the seat's event manifest-reads-changed for that node and
// when a person asks again (indexer_engine_reads, indexer_holds); kept in the indexer's state engine-reads, one key per
// node, so a node that is away keeps its last statement. A node the placement no longer names is forgotten. Until the
// placement is read no node is counted, and every hold stays.
// node, so a node that is away keeps its last statement. A node the assignments no longer names is forgotten. Until the
// assignments is read no node is counted, and every hold stays.
type engineReads struct {
keeper *enginereads.Keeper
gate *buildrun.ToolchainGate
mu sync.Mutex
known bool // the placement is read
known bool // the assignments is read
nodes []string
}
@@ -54,7 +54,7 @@ func (r *engineReads) bind(ctx context.Context, kv jetstream.KeyValue, errs chan
r.ask(nodes, "the state engine-reads is bound")
}
// placed takes the placement in, once it is read and after each change of it: a node it names newly is asked, one it no
// placed takes the assignments in, once it is read and after each change of it: a node it names newly is asked, one it no
// longer names is forgotten, key and all.
func (r *engineReads) placed() {
nodes := r.gate.Nodes()
@@ -64,7 +64,7 @@ func (r *engineReads) placed() {
r.mu.Unlock()
for _, node := range nodes {
if first || !slices.Contains(before, node) {
r.keeper.AskNode(node, "counted: the placement names it")
r.keeper.AskNode(node, "counted: the assignments names it")
}
}
for _, node := range before {
@@ -94,7 +94,7 @@ func (r *engineReads) heard(m jetstream.Msg, logf func(string, ...any)) bool {
}
// counted is index.Indexer.Counted: what each counted node states (nil, the baseline, for one with no statement), and
// whether the placement is read.
// whether the assignments is read.
func (r *engineReads) counted() (map[string][]string, bool) {
r.mu.Lock()
nodes, known := slices.Clone(r.nodes), r.known
@@ -13,15 +13,15 @@ import (
"git.novox.be/novox/mesh-sdk/go/process"
sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/heard"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-indexer/internal/index"
)
// findingsBucket is the indexer's own findings, which the condition keeper raises and clears (ADR 0564 §2): a value too
// large for its views event, and its first view of the assigner's placement unanswered past its bound.
// large for its views event, and its first view of the assigner's assignments unanswered past its bound.
var findingsBucket = mcc.FindingsBucket(index.Module)
// readyOf binds a state's bucket: now for one bound at the start, once it appears for one awaited.
@@ -109,15 +109,15 @@ const graphState = graph.State
// where indexer_first_view_again asks.
var firstViewsBucket = index.Module + "_" + views.StatusState
// placementTool is the assigner's first-view tool of its placement, as the indexer's invokes names it (ADR 0621 §2).
const placementTool = index.AssignerModule + "." + index.PlacementState
// assignmentsTool is the assigner's first-view tool of its assignments, as the indexer's invokes names it (ADR 0621 §2).
const assignmentsTool = index.AssignerModule + "." + index.AssignmentsState
// followPlacement reads the seat mesh-placement's placement through the assigner's first view and then its views
// followAssignments reads the seat mesh-module-assigner's assignments through the assigner's first view and then its views
// family, never its bucket (ADR 0615 §1, DLV-F1r), into gate: each key as it changes, settled once answered, with
// changed told whenever what the gate passes on changed. It waits for the first answer at most views.Bound, so a
// normal start, the two owners coming up together, computes the fingerprints once; past it the indexer goes on, the gate
// passing every report, and raises its condition naming the assigner and placement until the answer clears it.
func followPlacement(ctx context.Context, hub *process.EventHub, cred process.Account, call func(string, any) (json.RawMessage, error),
// passing every report, and raises its condition naming the assigner and assignments until the answer clears it.
func followAssignments(ctx context.Context, hub *process.EventHub, cred process.Account, call func(string, any) (json.RawMessage, error),
gate *buildrun.ToolchainGate, changed func(context.Context), findings, status *process.AwaitedBucket, logf func(string, ...any)) *views.Follower {
finding := func(s views.Status, raised bool) {
w := sdkfindings.Writer(ctx, findings.KV)
@@ -133,11 +133,11 @@ func followPlacement(ctx context.Context, hub *process.EventHub, cred process.Ac
}
}
var settled atomic.Bool // the first view answered: the gate's nodes are the counted ones from now on
f := &views.Follower{Owner: index.AssignerModule, Tool: placementTool, State: index.PlacementState,
Ask: views.AskVia(call, placementTool), Logf: logf,
f := &views.Follower{Owner: index.AssignerModule, Tool: assignmentsTool, State: index.AssignmentsState,
Ask: views.AskVia(call, assignmentsTool), Logf: logf,
Raise: func(s views.Status) { finding(s, true) }, Clear: func(s views.Status) { finding(s, false) },
Changed: func(key string, value json.RawMessage, deleted bool, at time.Time) {
if gate.Placement(key, value, deleted, at) {
if gate.Assignments(key, value, deleted, at) {
changed(ctx)
}
if settled.Load() && gate.OnPlaced != nil {
@@ -160,21 +160,21 @@ func followPlacement(ctx context.Context, hub *process.EventHub, cred process.Ac
}
settled.Store(true)
if gate.OnPlaced != nil {
gate.OnPlaced() // the placement is read: its nodes are the counted ones
gate.OnPlaced() // the assignments is read: its nodes are the counted ones
}
close(answered)
}()
// The assigner's views family, on the module's one hub (the host starts it once every handler is registered); a family the
// hub cannot register is the finding family-not-heard until the indexer starts again (ADR 0615 §5).
_ = notHeardWriter(ctx, findings, logf).Bind(process.EventsStream, "the placement is the first view's alone, asked again by hand, until the indexer starts again", func() error {
return hub.Handle("mesh.mod."+index.AssignerModule+".event."+views.EventPrefix+"*", views.Hear(ctx, map[string]*views.Follower{index.PlacementState: f}, logf))
_ = notHeardWriter(ctx, findings, logf).Bind(process.EventsStream, "the assignments is the first view's alone, asked again by hand, until the indexer starts again", func() error {
return hub.Handle("mesh.mod."+index.AssignerModule+".event."+views.EventPrefix+"*", views.Hear(ctx, map[string]*views.Follower{index.AssignmentsState: f}, logf))
})
wait, cancel := context.WithTimeout(ctx, views.Bound) // a context's bound, no timer (ADR 0437 §8)
defer cancel()
select {
case <-answered:
case <-wait.Done():
logf("the assigner has not answered placement within %s: the indexer goes on, every node's toolchain report counting until it answers", views.Bound)
logf("the assigner has not answered assignments within %s: the indexer goes on, every node's toolchain report counting until it answers", views.Bound)
}
return f
}