Give a catalogue pull request's placement a candidate state and each node an answer in its own have, so a placement change is checked by the nodes it places before it merges (issue 1126)

This commit is contained in:
2026-10-27 02:07:47 +01:00
parent 8e81d87610
commit 7e99c354ac
5 changed files with 287 additions and 7 deletions
+35 -5
View File
@@ -402,6 +402,14 @@ type HaveNode struct {
// Record is what the machine observes of itself, typed, for every node and composing part to read (issue 1126,
// ADR 0555 §3): the node's record lives in its own have key, so it needs no bucket, writer or grant of its own.
Record *NodeRecord `json:"record,omitempty"`
// Sealed is, on a node composing its own, each value its own composition carries sealed to the node's key, as
// applied (Have.Sealed for the node's own, issue 1126): a value the vault seals again after a restart moves no
// composition, so a restart applies nothing again.
Sealed []string `json:"sealed,omitempty"`
// Candidates is the node's answer to each placement candidate naming it, by group (PlacementCandidate): written by
// its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a
// candidate.
Candidates map[string]CandidateAnswer `json:"candidates,omitempty"`
}
// Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman
@@ -497,17 +505,38 @@ func DecodeHave(data []byte) (Have, error) {
return h, nil
}
// EncodeHaveNode is n as JSON at Version.
func (n HaveNode) check() error {
for group, a := range n.Candidates {
if group == "" {
return fmt.Errorf("delivery: a node's candidate answer names no group")
}
if err := a.check(); err != nil {
return fmt.Errorf("delivery: the answer to %s's candidate: %w", group, err)
}
}
return nil
}
// EncodeHaveNode is n as JSON at Version; it refuses a candidate answer CandidateAnswer's rules refuse.
func EncodeHaveNode(n HaveNode) ([]byte, error) {
if err := n.check(); err != nil {
return nil, err
}
n.V = Version
return json.Marshal(n)
}
// DecodeHaveNode reads a node's facts; it refuses an unknown version (ErrUnknownVersion).
// DecodeHaveNode reads a node's facts; it refuses an unknown version (ErrUnknownVersion) and a candidate answer that
// is not one.
func DecodeHaveNode(data []byte) (HaveNode, error) {
var n HaveNode
err := decode(data, &n)
return n, err
if err := decode(data, &n); err != nil {
return n, err
}
if err := n.check(); err != nil {
return HaveNode{}, err
}
return n, nil
}
// EncodePresence is p as JSON at Version.
@@ -560,7 +589,8 @@ func SameHave(a, b Have) bool {
func SameHaveNode(a, b HaveNode) bool {
return a.V == b.V && a.Composed == b.Composed && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) &&
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) &&
(a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record))
(a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record)) &&
jsonEqual(a.Sealed, b.Sealed) && jsonEqual(a.Candidates, b.Candidates)
}
// SamePresence says whether two presences are the same value.
+8
View File
@@ -86,6 +86,14 @@ func TestForgePullCarriesManifestsAndLocks(t *testing.T) {
if _, err := EncodeForge(f); err == nil || !strings.Contains(err.Error(), `"c"`) {
t.Errorf("locks outside Dirs: %v", err)
}
// A pull message of the catalogue carries its head's placement files whole (placement candidates, issue 1126).
f = Forge{Kind: ForgePull, Repository: CatalogueRepository, Number: 2, Head: "h", Action: PullUpdated,
Placements: map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`}, PlacementsRead: true}
if data, err := EncodeForge(f); err != nil {
t.Errorf("a catalogue pull with placement files: %v", err)
} else if got, err := DecodeForge(data); err != nil || !SameForge(got, withV(f)) {
t.Errorf("a catalogue pull's placement files changed on the way: %+v, %v", got, err)
}
for name, f := range map[string]Forge{
"refused manifests": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Manifests: map[string]string{"a": "{}"}},
"refused locks": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Locks: map[string]map[string]string{"a": {"go.sum": "h"}}},
+164
View File
@@ -0,0 +1,164 @@
package delivery
import (
"encoding/json"
"fmt"
"strings"
)
// Placement candidates (issue 1126; ADR 0549 §4: a placement change passes the composition check before any node sees
// it). A catalogue pull request that changes a placement file carries every placement file at its head on its pull
// message (Forge.Placements). The indexer writes, for each node whose file at the head differs from the trunk's, the
// key `<group>.<node>` of its state `placement-candidates`, and deletes it when the pull request merges or closes. It is
// a state of its own, since the readers of `placement` refuse any key that is not `placement.<node>`.
//
// The node it names composes in shadow with the candidate (its own sealed values, the boxes it already runs) and
// compares the result with what it runs; it applies nothing. Its answer rides in its own have key, `<node>.node`
// (HaveNode.Candidates, by group), which that node alone writes; the assigner's check of the group reads every placed
// node's answer at the head and fails on any difference, refusal or silence. Event-driven both ways: the candidate key's
// write is the node's event, the `<node>.node` change the check's.
const (
// PlacementCandidatesState is the indexer's state of placement candidates, and PlacementCandidatesBucket its bucket.
PlacementCandidatesState = "placement-candidates"
PlacementCandidatesBucket = IndexerModule + "_" + PlacementCandidatesState
// PlacementCandidatesHistory is the revisions the bucket keeps per key.
PlacementCandidatesHistory = 5
)
// PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate placement in one group:
// `<group>.<node>`, the group escaped as CandidatesKey escapes it, so a node watches `*.<node>` and a group's keys are
// `<group>.*`.
func PlacementCandidateKey(group, node string) (string, error) {
g, err := groupKey(group)
if err != nil {
return "", err
}
if err := checkName("node", node); err != nil {
return "", err
}
return g + "." + node, nil
}
// ParsePlacementCandidateKey is the inverse of PlacementCandidateKey; it refuses any other key, naming it.
func ParsePlacementCandidateKey(key string) (group, node string, err error) {
g, node, ok := strings.Cut(key, ".")
if !ok || checkName("node", node) != nil {
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, <group>.<node>", key)
}
if group, err = parseGroupKey(g); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key: %w", key, err)
}
return group, node, nil
}
// PlacementCandidate is the value of a candidate key: the group, the catalogue pull request and the head its placement
// files were read at, and the node's placement at that head; nil Placement says the head removes the node's file.
type PlacementCandidate struct {
V int `json:"v"`
Group string `json:"group"`
Pull Head `json:"pull"`
Placement *Placement `json:"placement,omitempty"`
}
func (c PlacementCandidate) check() error {
if c.Group == "" {
return fmt.Errorf("delivery: a placement candidate names no group")
}
if err := checkRepository(c.Pull.Repository); err != nil {
return err
}
if c.Pull.Number <= 0 || c.Pull.Head == "" {
return fmt.Errorf("delivery: a placement candidate needs its pull request's number and head")
}
if c.Placement != nil {
return c.Placement.check()
}
return nil
}
// EncodePlacementCandidate is c as JSON at Version; it refuses what DecodePlacementCandidate refuses.
func EncodePlacementCandidate(c PlacementCandidate) ([]byte, error) {
if err := c.check(); err != nil {
return nil, err
}
c.V = Version
if c.Placement != nil {
p := *c.Placement
p.V = Version
c.Placement = &p
}
return json.Marshal(c)
}
// DecodePlacementCandidate reads a candidate; it refuses an unknown version, no group, a pull request without number or
// head, and a placement DecodePlacement refuses.
func DecodePlacementCandidate(data []byte) (PlacementCandidate, error) {
var c PlacementCandidate
if err := decode(data, &c); err != nil {
return PlacementCandidate{}, err
}
if err := c.check(); err != nil {
return PlacementCandidate{}, err
}
return c, nil
}
// The verdicts of a node's answer to a candidate.
const (
// CandidateSame: every composition the node composes with the candidate is the one it runs, and it runs no other.
CandidateSame = "same"
// CandidateDiffers: some composition differs, is composed and not run, or is run and not composed (Differs).
CandidateDiffers = "differs"
// CandidateRefused: the node cannot compose with the candidate (Refused says why: an unbound requirement, a stand-in,
// a manifest it cannot read).
CandidateRefused = "refused"
)
// CandidateAnswer is a node's answer to its candidate of one group, in HaveNode.Candidates: the head and the key
// revision it answered, its verdict, the modules composed the same, those that differ, or why it could not compose.
// The node's own composition is named "node" (HaveNodeWord) in Same and Differs.
type CandidateAnswer struct {
Head string `json:"head"`
Revision uint64 `json:"revision"`
Verdict string `json:"verdict"`
Same []string `json:"same,omitempty"`
Differs []ModuleDiffers `json:"differs,omitempty"`
Refused string `json:"refused,omitempty"`
}
// ModuleDiffers is one module whose composition with the candidate is not the one the node runs: the first resource
// that differs by its identity (declaration.Resource's Identity), or empty when the whole module is composed and not run
// or run and not composed, and why in words.
type ModuleDiffers struct {
Module string `json:"module"`
Resource string `json:"resource,omitempty"`
Why string `json:"why"`
}
func (a CandidateAnswer) check() error {
if a.Head == "" {
return fmt.Errorf("delivery: a candidate answer names no head")
}
switch a.Verdict {
case CandidateSame:
if len(a.Differs) > 0 || a.Refused != "" {
return fmt.Errorf("delivery: a candidate answered same names a difference or a refusal")
}
case CandidateDiffers:
if len(a.Differs) == 0 || a.Refused != "" {
return fmt.Errorf("delivery: a candidate answered differs names no difference, or a refusal")
}
case CandidateRefused:
if a.Refused == "" || len(a.Same)+len(a.Differs) > 0 {
return fmt.Errorf("delivery: a candidate answered refused says no reason, or names modules")
}
default:
return fmt.Errorf("delivery: the candidate verdict %q is not same, differs or refused", a.Verdict)
}
for _, d := range a.Differs {
if d.Module == "" || d.Why == "" {
return fmt.Errorf("delivery: a candidate difference names no module or no why")
}
}
return nil
}
+74
View File
@@ -179,4 +179,78 @@ func TestPlacement(t *testing.T) {
}
}
})
// Issue 1126: a catalogue pull request's placement, one candidate key per node, and each node's answer in its have.
t.Run("candidates", func(t *testing.T) {
key, err := PlacementCandidateKey("feat/1.2", "g14")
if err != nil || key != "feat/1=2e2.g14" {
t.Fatalf("PlacementCandidateKey = %q, %v", key, err)
}
if g, n, err := ParsePlacementCandidateKey(key); err != nil || g != "feat/1.2" || n != "g14" {
t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", g, n, err)
}
for _, bad := range []string{"g14", ".g14", "feat.g.14", "feat.", "fe=t.g14"} {
if _, _, err := ParsePlacementCandidateKey(bad); err == nil {
t.Errorf("ParsePlacementCandidateKey(%q) took it", bad)
}
}
if PlacementCandidatesBucket != "mesh-delivery-indexer_placement-candidates" {
t.Errorf("bucket %q", PlacementCandidatesBucket)
}
pull := Head{Repository: CatalogueRepository, Number: 7, Head: "h1"}
for name, c := range map[string]PlacementCandidate{
"placed": {Group: "feat/x", Pull: pull, Placement: &g14},
"removed": {Group: "feat/x", Pull: pull},
} {
raw, err := EncodePlacementCandidate(c)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
back, err := DecodePlacementCandidate(raw)
if err != nil || back.Group != c.Group || back.Pull != pull || (back.Placement == nil) != (c.Placement == nil) ||
(c.Placement != nil && !SamePlacement(*back.Placement, g14)) {
t.Errorf("%s: round trip %+v, %v", name, back, err)
}
}
for name, c := range map[string]PlacementCandidate{
"no group": {Pull: pull},
"no head": {Group: "g", Pull: Head{Repository: CatalogueRepository, Number: 7}},
"bad place": {Group: "g", Pull: pull, Placement: &Placement{Node: "a.b"}},
"no repo form": {Group: "g", Pull: Head{Repository: "mesh-catalog", Number: 7, Head: "h"}},
} {
if _, err := EncodePlacementCandidate(c); err == nil {
t.Errorf("%s: taken", name)
}
}
answers := map[string]CandidateAnswer{
"same": {Head: "h1", Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}},
"differs": {Head: "h1", Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}},
"refused": {Head: "h1", Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"},
}
n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers}
raw, err := EncodeHaveNode(n)
if err != nil {
t.Fatal(err)
}
back, err := DecodeHaveNode(raw)
if err != nil || !SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers}) {
t.Fatalf("a node's answers changed on the way: %+v, %v", back, err)
}
if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed}) {
t.Error("a node's have without its answers counts as the same")
}
for name, a := range map[string]CandidateAnswer{
"no head": {Verdict: CandidateSame},
"same with a diff": {Head: "h", Verdict: CandidateSame, Differs: answers["differs"].Differs},
"differs with none": {Head: "h", Verdict: CandidateDiffers},
"refused no reason": {Head: "h", Verdict: CandidateRefused},
"unknown verdict": {Head: "h", Verdict: "maybe"},
"diff with no module": {Head: "h", Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}},
} {
if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil {
t.Errorf("%s: taken", name)
}
}
})
}
+6 -2
View File
@@ -540,6 +540,8 @@ type Forge struct {
// Placements are, on a merged message of the catalogue repository, every placement file of the merge commit, the
// bytes exact, by node (PlacementFile; novox/hq ADR 0549 §2), and PlacementsRead says they were read, so a catalogue
// whose last placement file was removed is told from a message that read none (older writers, another repository).
// On a pull message of the catalogue (CatalogueRepository) they are every placement file at the pull request's head,
// read whole or not at all, which the indexer writes as placement candidates (PlacementCandidate).
Placements map[string]string `json:"placements,omitempty"`
PlacementsRead bool `json:"placements-read,omitempty"`
}
@@ -554,8 +556,10 @@ func (f Forge) check() error {
if len(f.Open) > 0 {
return fmt.Errorf("delivery: only an open message names open pull requests")
}
if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged {
return fmt.Errorf("delivery: only a merged message carries placement files")
// A pull message of the catalogue carries its head's placement files, so its change of placement is composed and
// answered by the nodes it places before it merges (placement candidates, issue 1126; ADR 0549 §4).
if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged && (f.Kind != ForgePull || f.Repository != CatalogueRepository) {
return fmt.Errorf("delivery: only a merged message, or a pull message of the catalogue, carries placement files")
}
if len(f.Placements) > 0 && !f.PlacementsRead {
return fmt.Errorf("delivery: a message with placement files must say it read them (placements-read)")