From 9af3540d2897282f4a6fee7a44834a4423eecfdc Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 22:56:06 +0100 Subject: [PATCH] A-316, A-317 (assigner): the passer writes each candidate with the version and parts of the modules it adds; the propose verb writes the proposal alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §379, §421, A-316, A-317 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../mesh-delivery-assigner/candidateadded.go | 42 +++++++++++++++++ .../candidateadded_test.go | 46 +++++++++++++++++++ .../cmd/mesh-delivery-assigner/pass.go | 2 + .../cmd/mesh-delivery-assigner/seat.go | 22 ++++++++- .../cmd/mesh-delivery-assigner/seat_tools.go | 24 +++------- .../mesh-delivery-assigner/seat_tools_test.go | 4 +- 6 files changed, 119 insertions(+), 21 deletions(-) create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/candidateadded.go create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/candidateadded_test.go diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/candidateadded.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/candidateadded.go new file mode 100644 index 00000000..5dc20219 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/candidateadded.go @@ -0,0 +1,42 @@ +package main + +import ( + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" + plc "mesh-delivery-assigner/internal/assignment" + "mesh-delivery-assigner/internal/buckets" +) + +// addedOf is the `added` of node's candidate (design 47 G A-316, §379, §421): by module, each module the candidate adds to node +// (laid over current) with no want there, the version and parts the want it would write carries, taken from the same code that +// writes a want's (namedBuild, the want's build at the module's trunk fingerprint). A module whose build names none is not listed. +func (p *passer) addedOf(node string, laid *mma.Assignments, current map[string]mma.Assignments) map[string]mma.AddedModule { + if laid == nil { + return nil + } + trunk := p.w.trunk() + var out map[string]mma.AddedModule + for _, module := range plc.ChangeOf(current[node], *laid).Added { + if key, err := mwc.WantKey(node, module); err != nil || p.w.has(buckets.Wants(), key) { + continue + } + fp := trunk[module].Fp2 + if fp == "" { + continue + } + k, ok := p.namedBuild(module, fp)(fp) + if !ok { + continue + } + if out == nil { + out = map[string]mma.AddedModule{} + } + out[module] = mma.AddedModule{Version: k.Version, Parts: k.Parts} + } + return out +} + +// candidateOf is the candidate of proposal id for node: the assignments laid over it, and what it adds (addedOf). +func (p *passer) candidateOf(id, node string, laid *mma.Assignments, current map[string]mma.Assignments) mma.CandidatesByProposalNodeValue { + return mma.CandidatesByProposalNodeValue{Proposal: id, Assignments: laid, Added: p.addedOf(node, laid, current)} +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/candidateadded_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/candidateadded_test.go new file mode 100644 index 00000000..e33950b3 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/candidateadded_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" + + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/graph" + "mesh-delivery-assigner/internal/buckets" +) + +// Design 47 G A-316: the candidate of a node carries, for each module it adds there without a want, the version and parts the +// want it would write carries; a module with a want there, or a build that names none, is not listed. +func TestACandidateThatAddsAModuleCarriesItsParts(t *testing.T) { + w := newWorld() + for _, m := range []string{"mail", "web", "db"} { + w.putModule("main", m, meshcatalog.ModulesByModuleValue{Fp2: "fp-" + m}, graph.Node{}, nil) + } + w.first = "main" + rel, _ := json.Marshal(meshcatalog.VersionReleasedEvent{Module: "mail", Source: 7, Version: "1.2.3", Artifacts: []meshcatalog.VersionArtifact{{Artifact: "image", Digest: "sha256:9"}}}) + if err := w.released.put(rel); err != nil { + t.Fatal(err) + } + end, _ := json.Marshal(builds.BuildEnded{Module: "db", Fingerprint: "fp-db", Made: map[string]string{"program": "sha256:3"}, KeptOn: "g14"}) + if err := w.ends.put(end); err != nil { + t.Fatal(err) + } + w.put(buckets.Wants(), "want.ace.web", []byte(`{}`)) + p := &passer{w: w, graph: &graphKeeper{ok: true, source: 7}} + laid := &mma.Assignments{Node: "ace", Modules: []mma.PlacedModule{{Module: "mail"}, {Module: "web"}, {Module: "db"}, {Module: "old"}}} + current := map[string]mma.Assignments{"ace": {Node: "ace", Modules: []mma.PlacedModule{{Module: "old"}}}} + c := p.candidateOf("p1", "ace", laid, current) + want := map[string]mma.AddedModule{ + "mail": {Version: "1.2.3", Parts: map[string]string{"mail:image": "sha256:9"}}, + "db": {Parts: map[string]string{"db:program": "sha256:3"}}, + } + if c.Proposal != "p1" || c.Assignments != laid || !reflect.DeepEqual(c.Added, want) { + t.Errorf("candidate = %+v, added want %+v", c, want) + } + if got := p.candidateOf("p1", "ace", nil, current); got.Added != nil { + t.Errorf("a candidate taking the key away adds %+v", got.Added) + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index fc67fab7..b9a59b6e 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -94,6 +94,8 @@ type passer struct { // saidWaits is the last said of the seat's wait for its states (sayWaits). saidWaits string intended map[string]map[string]map[string]bool + // laidHere are the proposals whose candidates this process wrote (design 47 G A-317), by id. + laidHere map[string]bool // reads keeps what each node's engine states it reads, asked through the node-engine seat's verb (ADR 0618 §3); nil reads // each node's record, as the rules did before the verb. online is, by node, whether the last pass saw it online. reads *enginereads.Keeper diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go index 6b12bc1d..958ef63d 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go @@ -310,6 +310,13 @@ func (p *passer) stepProposal(ctx context.Context, prop mma.Proposal, rev uint64 } cands, candRevs, written := p.candidatesOf(prop.ID) if len(prop.Must) == 0 { + for _, node := range plc.Nodes(prop) { + if _, ok := cands[node]; !ok && !p.laidHere[prop.ID] { + // The verb writes the proposal alone: its candidates have one writer, this process (design 47 G A-317). + p.lay(ctx, prop, rev, current, revs, prop.Words, true) + return + } + } p.mustAnswer(ctx, prop, rev, current, cands) return } @@ -437,6 +444,12 @@ func (p *passer) mustAnswer(ctx context.Context, prop mma.Proposal, rev uint64, // layAgain lays a proposal over the keys as they stand now (§5: a key moved): its candidates are written again, the // nodes that must answer found again, and it is judged and asked again; a proposal that no longer lays ends. func (p *passer) layAgain(ctx context.Context, prop mma.Proposal, rev uint64, current map[string]mma.Assignments, revs map[string]uint64, why string) { + p.lay(ctx, prop, rev, current, revs, why, false) +} + +// lay lays a proposal over the keys as they stand now and writes its candidates, each with what it adds (A-316); first says it is the +// proposal's first laying, the verb having written the proposal alone (A-317). +func (p *passer) lay(ctx context.Context, prop mma.Proposal, rev uint64, current map[string]mma.Assignments, revs map[string]uint64, why string, first bool) { held, recorded := p.lays(ctx) again, laid, findings := assign.Propose(assign.ProposeIn{ID: prop.ID, By: prop.By, Why: prop.Why, At: prop.At, Changes: prop.Changes, Current: current, Revisions: revs, Held: held, Recorded: recorded, SeatListRead: p.seats.Heard()}) @@ -451,12 +464,19 @@ func (p *passer) layAgain(ctx context.Context, prop mma.Proposal, rev uint64, cu } p.deleteCandidates(ctx, prop.ID) p.deletePulses(ctx, prop.ID) + if p.laidHere == nil { + p.laidHere = map[string]bool{} + } + p.laidHere[prop.ID] = true for _, node := range plc.Nodes(again) { key, _ := plc.CandidateKey(prop.ID, node) - c := mma.CandidatesByProposalNodeValue{Proposal: prop.ID, Assignments: laid[node]} + c := p.candidateOf(prop.ID, node, laid[node], current) p.put(ctx, plc.CandidatesBucket(), key, func() ([]byte, error) { return plc.EncodeCandidate(c) }) } again.Written, again.Words = ptr(p.clock().UTC()), why+": laid again over the keys as they stand, to be judged and asked again" + if first { + again.Words = why + } p.writeProposal(ctx, again, rev) } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go index 3b524998..0673524f 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go @@ -179,23 +179,11 @@ func (s *seatTools) proposal(id string) (mma.Proposal, error) { return plc.DecodeProposal(raw) } -// write writes a proposal laid by assign.Propose: each candidate, then the proposal, whose write the assigner's process -// takes as the event to compose and judge it. -func (s *seatTools) write(p mma.Proposal, laid map[string]*mma.Assignments) error { +// write writes a proposal laid by assign.Propose, whose write the assigner's process takes as the event to lay it: it writes the +// candidates, each with what it adds (design 47 G A-317, A-316); the verb writes none, so no node-engine hears a candidate before +// its parts. +func (s *seatTools) write(p mma.Proposal) error { p.Written = ptr(s.now().UTC()) - for _, node := range plc.Nodes(p) { - key, err := plc.CandidateKey(p.ID, node) - if err != nil { - return err - } - raw, err := plc.EncodeCandidate(mma.CandidatesByProposalNodeValue{Proposal: p.ID, Assignments: laid[node]}) - if err != nil { - return err - } - if err := s.st.put(mma.StateCandidates, key, raw); err != nil { - return fmt.Errorf("the candidate %s is not written: %w", key, err) - } - } raw, err := plc.EncodeProposal(p) if err != nil { return err @@ -265,7 +253,7 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) { } enrollers := plc.EnrollersOf(current) now := s.now() - p, laid, findings := assign.Propose(assign.ProposeIn{ID: plc.NewProposalID(now), By: by, Why: arg(args, "why"), At: now, + p, _, findings := assign.Propose(assign.ProposeIn{ID: plc.NewProposalID(now), By: by, Why: arg(args, "why"), At: now, Changes: changes, Current: current, Revisions: revs, Held: held, Recorded: recorded, Warrant: warrant, SeatListRead: s.seatList.Heard()}) if refusal := plc.AddNodeRefusal(p, moduleOfCaller(by, enrollers), enrollers); refusal != "" { findings = append(findings, refusal) @@ -273,7 +261,7 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) { if len(findings) > 0 { return nil, fmt.Errorf("the proposal is refused: %s", strings.Join(findings, "; ")) } - if err := s.write(p, laid); err != nil { + if err := s.write(p); err != nil { return nil, err } return proposed{Proposal: p, Follow: followProposal(p.ID)}, nil diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go index e7912602..f1a25eaf 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go @@ -127,7 +127,7 @@ func TestTheSeatsVerbs(t *testing.T) { t.Fatal(err) } move := got.(proposed).Proposal - if move.By != "operator@laptop" || move.Base["anchor"] != int64(len(st[mma.StateAssignments].values["anchor.node"])) || move.Base["laptop"] != 0 || len(st[mma.StateCandidates].values) != 2 { + if move.By != "operator@laptop" || move.Base["anchor"] != int64(len(st[mma.StateAssignments].values["anchor.node"])) || move.Base["laptop"] != 0 || len(st[mma.StateCandidates].values) != 0 { t.Fatalf("propose = %+v", move) } @@ -143,7 +143,7 @@ func TestTheSeatsVerbs(t *testing.T) { t.Error("a node with no key was answered") } ps, err := s.doProposals(map[string]any{"id": move.ID}) - if list := ps.(map[string]any)["proposals"].([]openProposal); err != nil || len(list) != 1 || len(list[0].Candidates) != 2 { + if list := ps.(map[string]any)["proposals"].([]openProposal); err != nil || len(list) != 1 || len(list[0].Candidates) != 0 { t.Errorf("proposals = %+v, %v", ps, err) } else { reads[mma.StateProposals], reads[mma.StateCandidates] = true, true