Merge branch 'feat/47-G-fix11-b2l' into feat/47-G-join11

# Conflicts:
#	modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildended.go
#	modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go
#	modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go
#	modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go
#	modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go
#	modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go
#	modules/mesh-delivery-assigner/internal/assign/wants_test.go
#	modules/mesh-delivery-assigner/manifest_test.go
#	modules/mesh-delivery-assigner/module.json
This commit is contained in:
2026-11-12 10:36:04 +01:00
51 changed files with 2476 additions and 118 deletions
+20 -7
View File
@@ -41,10 +41,13 @@ const (
// AskKindOutside is the kind of the assigner's ask about a moved module whose failing health check has waited past its bound
// on an outside cause (design 47 G §706): go on, or count it as failed. `outside.<node>.<module>.<fingerprint hex>`.
AskKindOutside = "outside"
// AskKindRevert is the kind of the assigner's ask about a held revert, one with no restore point (design 47 G §710): keep it
// held, or start the reverted build on the live data. `revert.<node>.<module>.<fingerprint hex>`, the reverted build's.
AskKindRevert = "revert"
)
// AskKey is an ask's key read whole: its Kind, and the parts its kind has: a merge's Group and heads Hash, a hold's Module
// and fingerprint FP, a restore's and a backup's Node, Module and FP, a record's Node, Module and the digest Composed of the
// and fingerprint FP, a restore's, a backup's, an outside's and a revert's Node, Module and FP, a record's Node, Module and the digest Composed of the
// composition it would allow, the assignments' Proposal.
type AskKey struct {
Proposal string
@@ -63,11 +66,11 @@ func AskNeedsProof(kind string) (bool, error) {
switch kind {
case meshdeliveryclient.AskKindMerge:
return true, nil
case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments, AskKindOutside:
case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments, AskKindOutside, AskKindRevert:
return false, nil
}
return false, fmt.Errorf("asks: %q is not a kind of ask, %s, %s, %s, %s, %s, %s or %s", kind, meshdeliveryclient.AskKindMerge, AskKindHold,
AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments, AskKindOutside)
return false, fmt.Errorf("asks: %q is not a kind of ask, %s, %s, %s, %s, %s, %s, %s or %s", kind, meshdeliveryclient.AskKindMerge, AskKindHold,
AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments, AskKindOutside, AskKindRevert)
}
// HoldAskKey is the key of the ask about a module's held fingerprint: `hold.<module>.<hex>`.
@@ -119,6 +122,16 @@ func BackupAskKey(node, module, fingerprint string) (string, error) {
return AskKindBackup + "." + key, nil
}
// RevertAskKey is the key of the ask about a held revert of a module on a node to the reverted build's fingerprint:
// `revert.<node>.<module>.<hex>` (design 47 G §710).
func RevertAskKey(node, module, fingerprint string) (string, error) {
key, err := BackupAskKey(node, module, fingerprint)
if err != nil {
return "", err
}
return AskKindRevert + strings.TrimPrefix(key, AskKindBackup), nil
}
// AssignmentsAskKey is the key of the authorising ask about a proposal of assignments: `assignments.<proposal id>` (ADR 0567 §5).
func AssignmentsAskKey(proposal string) (string, error) {
if err := meshmoduleassignerclient.CheckProposalID(proposal); err != nil {
@@ -140,7 +153,7 @@ func RecordAskKey(node, module, composed string) (string, error) {
return AskKindRecord + "." + have + "." + strings.TrimPrefix(composed, "sha256:"), nil
}
// ParseAnyAskKey reads an ask key of any kind (a merge, hold, restore, backup, record or assignments ask); it refuses an unknown
// ParseAnyAskKey reads an ask key of any kind (a merge, hold, restore, backup, record, assignments, outside or revert ask); it refuses an unknown
// kind and a key whose parts do not fit its kind, naming the key.
func ParseAnyAskKey(key string) (AskKey, error) {
kind, rest, _ := strings.Cut(key, ".")
@@ -180,10 +193,10 @@ func ParseAnyAskKey(key string) (AskKey, error) {
}
k.Kind = kind
return k, nil
case AskKindBackup:
case AskKindBackup, AskKindRevert:
node, module, part, isNode, err := nodeengineclient.ParseComposedKey(rest)
if err != nil || isNode {
return refused(fmt.Errorf("a backup ask is backup.<node>.<module>.<fingerprint hex>"))
return refused(fmt.Errorf("a %s ask is %s.<node>.<module>.<fingerprint hex>", kind, kind))
}
fp, err := catalogkey.ParseKeyPart(part)
if err != nil {
@@ -19,6 +19,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
backup, _ := BackupAskKey("n1", "novox.be", fp)
assignments, _ := AssignmentsAskKey("p-20261028t183000-1a2b3c")
record, _ := RecordAskKey("n1", "gitea", digest)
revert, _ := RevertAskKey("n1", "nats", fp)
for key, want := range map[string]struct {
got string
back AskKey
@@ -28,6 +29,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
"backup": {backup, AskKey{Kind: "backup", Node: "n1", Module: "novox.be", FP: fp}},
"assignments": {assignments, AskKey{Kind: "assignments", Proposal: "p-20261028t183000-1a2b3c"}},
"record": {record, AskKey{Kind: "record", Node: "n1", Module: "gitea", Composed: digest}},
"revert": {revert, AskKey{Kind: "revert", Node: "n1", Module: "nats", FP: fp}},
} {
back, err := ParseAnyAskKey(want.got)
if err != nil || back != want.back {
@@ -35,7 +37,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
}
}
for got, want := range map[string]string{hold: "hold.gitea." + hex, restore: "restore.n1.gitea." + hex, backup: "backup.n1.novox=2ebe." + hex,
assignments: "assignments.p-20261028t183000-1a2b3c", record: "record.n1.gitea." + strings.Repeat("cd", 32)} {
assignments: "assignments.p-20261028t183000-1a2b3c", record: "record.n1.gitea." + strings.Repeat("cd", 32), revert: "revert.n1.nats." + hex} {
if got != want {
t.Errorf("key %q, want %q", got, want)
}
@@ -45,7 +47,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
t.Errorf("a merge ask key: %+v, %v", merge, err)
}
for _, bad := range []string{"", "other.x", "hold.gitea", "hold.gitea.src2:" + hex, "restore.n1", "backup.n1.node", "backup.n1.gitea." + hex[:60],
"assignment.AB", "record.n1.gitea." + hex[:10], "record.n1.node." + hex, "merge.g"} {
"assignment.AB", "record.n1.gitea." + hex[:10], "record.n1.node." + hex, "merge.g", "revert.n1.node", "revert.n1.nats." + hex[:60]} {
if _, err := ParseAnyAskKey(bad); err == nil {
t.Errorf("ParseAnyAskKey(%q) reads a wrong key", bad)
}
@@ -53,8 +55,10 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
if proof, err := AskNeedsProof("merge"); err != nil || !proof {
t.Error("a merge ask needs proof")
}
if proof, err := AskNeedsProof("hold"); err != nil || proof {
t.Error("a hold ask needs no proof")
for _, kind := range []string{"hold", "revert"} {
if proof, err := AskNeedsProof(kind); err != nil || proof {
t.Errorf("a %s ask needs no proof", kind)
}
}
if _, err := AskNeedsProof("view"); err == nil {
t.Error("an unknown kind of ask is taken")
@@ -0,0 +1,152 @@
package main
import (
"context"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/acting"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
)
// The waits for an acting part's new version in the steps the assigner holds (design 47 G §266, §268, §276): an entry of the assignment
// graph whose acting edge names composing or placing waits, its want held, until the part's version runs on the edge's node. The wait is
// kept in the state `waits` under `acting.<node>.<module>.<part>`, said as `wait-held` when it starts and `wait-ended` (ran, released,
// replaced) when it ends; `assigner_waits` reads it and `assigner_release` with how `now` lets the entry move with the part's running
// version. A node away starts no wait: its clock starts when the node is present (§268).
// actingOf is the Module.Acting of module: the held acting edges of its entry on a node, those whose wait was not released left out.
func (p *passer) actingOf(module string, released map[string]bool) func(node string) []assign.ActingEdge {
return func(node string) []assign.ActingEdge {
var out []assign.ActingEdge
for _, e := range p.entryEdges(node, module) {
if acting.Held(e.Step) && !released[acting.Key(node, module, e.Module)+"@"+e.Fp] {
out = append(out, assign.ActingEdge{Node: e.Node, Part: e.Module, Fp: e.Fp, Step: e.Step})
}
}
return out
}
}
// entryEdges are the acting edges of the entry of module on node in the revision the pass computed.
func (p *passer) entryEdges(node, module string) []seat.Edge {
g := p.graph
if g == nil {
return nil
}
for _, ref := range g.computed.Revision.Entries {
if ref.Node == node && ref.Module == module {
var out []seat.Edge
for _, e := range g.computed.Entries[ref.Digest].Edges {
if e.Kind == "acting" {
out = append(out, e)
}
}
return out
}
}
return nil
}
// actingReleased is the waits released by hand and not yet run, by `<key>@<part's fingerprint>`.
func (p *passer) actingReleased() map[string]bool {
out := map[string]bool{}
for key, raw := range p.w.read(buckets.Waits) {
if w, err := acting.Decode(raw); err == nil && w.Released && (acting.Is(key) || acting.IsOrder(key)) {
out[key+"@"+w.Fp] = true
}
}
return out
}
// actingWaits keeps the acting waits after the pass's wants: it starts the wait of each entry held for a part's version, ends the
// wait of one that ran, was replaced or was released, and says each.
func (p *passer) actingWaits(ctx context.Context, n assign.Nodes, wanted map[string]map[string]meshwants.Want) {
g := p.graph
if g == nil || p.kvs[buckets.Waits] == nil {
return
}
now := p.clock()
kept := p.w.read(buckets.Waits)
standing := map[string]bool{}
for _, ref := range g.computed.Revision.Entries {
entry := g.computed.Entries[ref.Digest]
if w, ok := wanted[ref.Module][ref.Node]; ok && w.Fp == entry.Fp {
continue // the entry's version is wanted already: nothing moves, so nothing waits
}
for _, e := range entry.Edges {
if e.Kind != "acting" || !acting.Held(e.Step) {
continue
}
key := acting.Key(ref.Node, ref.Module, e.Module)
if n.Runs(assign.ActingEdge{Node: e.Node, Part: e.Module, Fp: e.Fp, Step: e.Step}) {
continue
}
standing[key] = true
if raw := kept[key]; raw != nil {
if w, err := acting.Decode(raw); err == nil && w.Entry == entry.Fp && w.Fp == e.Fp {
p.actingReleaseSaid(ctx, key, w)
continue
}
}
if !n.Online[ref.Node] {
continue // a node away holds only itself, and its clock has not started (§268)
}
w := acting.Wait{Step: e.Step, Node: ref.Node, Module: ref.Module, Part: e.Module, PartNode: e.Node, Fp: e.Fp, Entry: entry.Fp,
Since: now, ClockFrom: now, Bound: acting.BoundOf(e).String()}
if err := p.wantsSay(seat.EventWaitHeld, acting.Body(w, assignment.Module), key+"."+e.Fp+"."+entry.Fp); err != nil {
p.logf("the wait of %s on %s for %s is not said, and is said at the next pass: %v", ref.Module, ref.Node, e.Module, err)
continue
}
p.put(ctx, buckets.Waits, key, func() ([]byte, error) { return acting.Encode(w) })
}
}
for key, raw := range kept {
if !acting.Is(key) || standing[key] {
continue
}
w, err := acting.Decode(raw)
if err != nil {
continue
}
how := acting.HowRan
if w.Released {
how = acting.HowReleased
} else if e, ok := p.edgeOf(w); !ok || !n.Runs(e) {
how = acting.HowReplaced
}
if !w.EndSaid {
body := acting.Body(w, assignment.Module)
body.How = how
if err := p.wantsSay(seat.EventWaitEnded, body, key+"."+w.Fp+"."+w.Entry+"."+how); err != nil {
p.logf("the end of the wait of %s on %s for %s is not said, and is said at the next pass: %v", w.Module, w.Node, w.Part, err)
continue
}
}
p.remove(ctx, buckets.Waits, key)
}
}
// actingReleaseSaid says wait-ended of a wait released by hand, once, and keeps its record so the entry is not held again.
func (p *passer) actingReleaseSaid(ctx context.Context, key string, w acting.Wait) {
if !w.Released || w.EndSaid {
return
}
body := acting.Body(w, assignment.Module)
body.How = acting.HowReleased
body.By = w.By
if err := p.wantsSay(seat.EventWaitEnded, body, key+"."+w.Fp+"."+w.Entry+"."+acting.HowReleased); err != nil {
p.logf("the release of the wait of %s on %s for %s is not said, and is said at the next pass: %v", w.Module, w.Node, w.Part, err)
return
}
w.EndSaid = true
p.put(ctx, buckets.Waits, key, func() ([]byte, error) { return acting.Encode(w) })
}
// edgeOf is the edge a kept wait stands on, as an acting edge.
func (p *passer) edgeOf(w acting.Wait) (assign.ActingEdge, bool) {
return assign.ActingEdge{Node: w.PartNode, Part: w.Part, Fp: w.Fp, Step: w.Step}, true
}
@@ -0,0 +1,118 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/acting"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/buckets"
)
// Design 47 G §266, §268, §276 (AS9): an entry with an acting edge in composing waits for the part's version to run on the edge's node;
// the wait is kept, said as wait-held with its bound and clock-from, read by assigner_waits, released by assigner_release with how
// `now` and ended when the version runs.
func TestAnEntryWaitsForItsActingPartAndTheWaitIsKeptSaidAndEnded(t *testing.T) {
now := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC)
p := newPasser(kvSet{buckets.Waits: &memKV{values: map[string][]byte{}}}, now)
var events []string
var bodies []meshwants.Wait
p.publishWants = func(event string, body any, id string) error {
events = append(events, event)
bodies = append(bodies, body.(meshwants.Wait))
return nil
}
entry := meshwants.AssignmentEntry{Node: "ace", Module: "nats", Fp: fpTest, Edges: []meshwants.Edge{
{Kind: "acting", Node: "ace", Module: "mesh-host", Fp: "src2:new", Seat: "node-engine", Step: "compose", Bound: "15m"},
{Kind: "acting", Node: "ace", Module: "indexer", Fp: "src2:idx", Seat: "mesh-catalog", Step: "take-in"}}}
p.graph = &graphKeeper{}
p.graph.computed.Revision.Entries = []meshwants.NodeEntryRef{{Node: "ace", Module: "nats", Digest: "d1"}}
p.graph.computed.Entries = map[string]meshwants.AssignmentEntry{"d1": entry}
n := assign.Nodes{Online: map[string]bool{"ace": true}, Running: map[string]map[string]nodeengine.Running{"ace": {"mesh-host": {Fingerprint: "src2:old"}}}}
wanted := map[string]map[string]mwc.Want{"nats": {"ace": {Fp: "src2:older"}}}
ctx := context.Background()
if got := p.actingOf("nats", nil)("ace"); len(got) != 1 || got[0].Part != "mesh-host" {
t.Fatalf("the assigner holds composing only: %+v", got)
}
p.actingWaits(ctx, n, wanted)
p.actingWaits(ctx, n, wanted) // said once
if len(events) != 1 || events[0] != meshwants.EventWaitHeld {
t.Fatalf("events %v", events)
}
b := bodies[0]
if b.Step != "compose" || b.Part != "mesh-host" || b.Fp != "src2:new" || b.Bound != "15m0s" || b.ClockFrom == nil || !b.ClockFrom.Equal(now) || b.Owner == "" {
t.Errorf("wait-held %+v", b)
}
key := acting.Key("ace", "nats", "mesh-host")
if _, err := acting.Decode(p.w.read(buckets.Waits)[key]); err != nil {
t.Fatalf("the wait is kept: %v", err)
}
// a release by hand lets the entry move, and ends the wait once
w, _ := acting.Decode(p.w.read(buckets.Waits)[key])
w.Released = true
raw, _ := acting.Encode(w)
p.w.put(buckets.Waits, key, raw)
if got := p.actingOf("nats", p.actingReleased())("ace"); len(got) != 0 {
t.Errorf("a released wait still holds: %+v", got)
}
p.actingWaits(ctx, n, wanted)
p.actingWaits(ctx, n, wanted)
if len(events) != 2 || events[1] != meshwants.EventWaitEnded || bodies[1].How != acting.HowReleased {
t.Fatalf("events %v bodies %+v", events, bodies)
}
// the version runs: the record goes, said nothing more
n.Running["ace"]["mesh-host"] = nodeengine.Running{Fingerprint: "src2:new"}
p.actingWaits(ctx, n, wanted)
if len(p.w.read(buckets.Waits)) != 0 || len(events) != 2 {
t.Errorf("kept %v events %v", p.w.read(buckets.Waits), events)
}
}
func TestAnActingWaitOfANodeAwayStartsNoClockAndAnEntryWantedAlreadyWaitsNot(t *testing.T) {
p := newPasser(kvSet{buckets.Waits: &memKV{values: map[string][]byte{}}}, time.Now())
p.publishWants = func(string, any, string) error { t.Fatal("said"); return nil }
p.graph = &graphKeeper{}
p.graph.computed.Revision.Entries = []meshwants.NodeEntryRef{{Node: "ace", Module: "nats", Digest: "d1"}}
p.graph.computed.Entries = map[string]meshwants.AssignmentEntry{"d1": {Node: "ace", Module: "nats", Fp: "src2:a", Edges: []meshwants.Edge{
{Kind: "acting", Node: "ace", Module: "mesh-host", Fp: "src2:new", Step: "compose"}}}}
n := assign.Nodes{Online: map[string]bool{}}
p.actingWaits(context.Background(), n, map[string]map[string]mwc.Want{})
n.Online["ace"] = true
p.actingWaits(context.Background(), n, map[string]map[string]mwc.Want{"nats": {"ace": {Fp: "src2:a"}}})
if len(p.w.read(buckets.Waits)) != 0 || strings.Contains(p.graph.computed.Entries["d1"].Fp, "x") {
t.Errorf("kept %v", p.w.read(buckets.Waits))
}
}
func TestTheToolsReadAndReleaseAnActingWait(t *testing.T) {
var acts []string
s := newPutTools(memStore{waitsState: &memKV{values: map[string][]byte{}}, buckets.DeliveryWaitsState: &memKV{values: map[string][]byte{}}, buckets.DeliveredAgainState: &memKV{values: map[string][]byte{}}}, func(key string, body any) (json.RawMessage, error) {
acts = append(acts, key)
return json.RawMessage(`{"ok":true,"log":"logged"}`), nil
})
w := acting.Wait{Step: "compose", Node: "ace", Module: "nats", Part: "mesh-host", PartNode: "ace", Fp: "src2:new", Entry: "src2:a", Since: time.Now(), ClockFrom: time.Now(), Bound: "30m0s"}
raw, _ := acting.Encode(w)
s.st.put(waitsState, acting.Key("ace", "nats", "mesh-host"), raw)
got, err := s.doWaits(map[string]any{"module": "nats"})
if list := got.(map[string]any)["waits"].([]any); err != nil || len(list) != 1 || list[0].(actingSaid).BoundEnds == "" {
t.Fatalf("assigner_waits = %+v %v", got, err)
}
if _, err := s.doRelease(map[string]any{"node": "ace", "module": "nats", "how": "now"}, "x"); err == nil {
t.Error("a release with no why is taken")
}
if _, err := s.doRelease(map[string]any{"node": "ace", "module": "nats", "how": "now", "why": "the new engine is known good"}, "shanks.node-tools"); err != nil || len(acts) == 0 {
t.Fatalf("%v %v", err, acts)
}
raw, _, _, _ = s.st.get(waitsState, acting.Key("ace", "nats", "mesh-host"))
if r, _ := acting.Decode(raw); !r.Released || !strings.Contains(r.By, "known good") {
t.Errorf("wait = %+v", r)
}
}
@@ -154,3 +154,17 @@ func hearBuilds(ctx context.Context, js jetstream.JetStream, ends *builtEnds, wa
cc.Stop()
}()
}
// hearTaken registers the hub's taker of the indexer's take-in.taken, each waking the pass (orderwaits.go, §298, A-260); before
// the hub starts.
func hearTaken(hub *process.EventHub, taken *takenSources, wake func(), logf func(string, ...any)) error {
return hub.Handle(takenSubject, func(m jetstream.Msg) {
if err := taken.put(m.Data()); err != nil {
logf("%s is not kept: %v", m.Subject(), err)
}
if err := m.Ack(); err != nil {
logf("acknowledging %s: %v", m.Subject(), err)
}
wake()
})
}
@@ -0,0 +1,82 @@
package main
import (
"context"
"fmt"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/assigngraph"
)
// A store filling is said before it is full (design 47 G §181, in the assigner's part): the assignment graph revisions' object store
// says `filling` of the seat mesh-wants when it passes 80 % of its cap, and `full` when the next revision does not fit: the assigner then keeps
// its last revision and writes no new want, and says `room` when the store is back below 80 % and goes on. Nothing is dropped to make room.
// fillOf is the store's fill now: its bytes and cap, when the bucket says them and has a cap.
func (g *graphKeeper) fillOf(ctx context.Context) (used, capacity int64, ok bool) {
f, is := g.objects.(assigngraph.Filler)
if !is {
return 0, 0, false
}
used, capacity, err := f.Fill(ctx)
return used, capacity, err == nil && capacity > 0
}
func (p *passer) sayFill(event, about string, used, capacity int64, refused string) {
at := p.clock().UTC()
body := meshwants.Fill{Store: assigngraph.RevisionsObjects, Bytes: used, Cap: capacity, Refused: refused, At: &at}
if err := p.wantsSay(event, body, event+"-"+about); err != nil {
p.logf("%s of %s is not said: %v", event, assigngraph.RevisionsObjects, err)
}
}
// fillAtStart judges the store once at the process's first pass, when no event is pending: a store past 80 % says filling (full when at
// its cap), so a fill reached while the process was down is said too.
func (p *passer) fillAtStart(ctx context.Context) {
g := p.graph
used, capacity, ok := g.fillOf(ctx)
if !ok {
return
}
switch {
case used >= capacity:
g.full = true
p.sayFill(meshwants.EventFull, "start", used, capacity, "the next revision")
case used*5 >= capacity*4:
p.sayFill(meshwants.EventFilling, "start", used, capacity, "")
}
}
// fullBefore says the next revision does not fit its store, and keeps nothing: it says `full` once, and `room` once the store is below
// 80 % again. It answers true while the store is full.
func (p *passer) fullBefore(ctx context.Context, c assigngraph.Computed) bool {
g := p.graph
used, capacity, ok := g.fillOf(ctx)
if !ok {
return false
}
if g.full && used*5 < capacity*4 {
g.full = false
p.sayFill(meshwants.EventRoom, fmt.Sprint(used), used, capacity, "")
}
if used+assigngraph.NewBytes(ctx, g.objects, c) <= capacity {
g.full = false
return false
}
if !g.full {
g.full = true
p.sayFill(meshwants.EventFull, fmt.Sprint(used), used, capacity, "the next revision")
}
p.logf("the assignment graph revision is not kept: %s is full (%d of %d bytes), the last revision stays and no want is written until there is room", assigngraph.RevisionsObjects, used, capacity)
return true
}
// fillingAfter says `filling` when keeping a revision took the store past 80 % of its cap.
func (p *passer) fillingAfter(ctx context.Context, before int64) {
g := p.graph
used, capacity, ok := g.fillOf(ctx)
if ok && before*5 < capacity*4 && used*5 >= capacity*4 {
p.sayFill(meshwants.EventFilling, fmt.Sprint(used), used, capacity, "")
}
}
@@ -0,0 +1,83 @@
package main
import (
"context"
"testing"
"time"
mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/assigngraph"
)
// filled is an object bucket with a fill: the bytes it holds count beside what is stored.
type filled struct {
objs map[string][]byte
base int64
cap int64
}
func (f *filled) Put(_ context.Context, name string, data []byte) error {
f.objs[name] = data
return nil
}
func (f *filled) Get(_ context.Context, name string) ([]byte, error) {
if d, ok := f.objs[name]; ok {
return d, nil
}
return nil, assigngraph.ErrNoObject
}
func (f *filled) Names(context.Context) ([]string, error) {
out := []string{}
for n := range f.objs {
out = append(out, n)
}
return out, nil
}
func (f *filled) Fill(context.Context) (int64, int64, error) {
used := f.base
for _, d := range f.objs {
used += int64(len(d))
}
return used, f.cap, nil
}
// Design 47 G §181 (AS13): the assignment graph revisions' store says filling past 80 % of its cap, refuses a revision that does not fit
// with `full` (the last revision stays, no want is written), and says `room` below 80 % again; at the start a store already past 80 % is said.
func TestTheAssignmentGraphStoreSaysFillingFullAndRoom(t *testing.T) {
ctx := context.Background()
p := newPasser(kvSet{}, time.Now())
var events []string
p.publishWants = func(event string, body any, id string) error {
if _, ok := body.(meshwants.Fill); ok {
events = append(events, event)
}
return nil
}
o := &filled{objs: map[string][]byte{}, base: 500, cap: 1000}
p.graph = &graphKeeper{objects: o, halves: func() map[string]mcc.Half { return nil }}
p.keepGraph(ctx) // the first pass judges at the start: 500 of 1000 is below 80 %
if len(events) != 0 {
t.Fatalf("events %v", events)
}
o.base = 700
p.graph.started = false
p.keepGraph(ctx)
if len(events) != 1 || events[0] != meshwants.EventFilling {
t.Fatalf("at the start past 80 %%: %v", events)
}
c := assigngraph.Computed{Revision: meshwants.AssignmentRevision{Entries: []meshwants.NodeEntryRef{}}}
o.base = 990
if !p.fullBefore(ctx, c) || !p.graph.full || events[len(events)-1] != meshwants.EventFull {
t.Fatalf("a revision that does not fit: events %v", events)
}
p.fullBefore(ctx, c)
if n := len(events); n != 2 {
t.Errorf("full is said once: %v", events)
}
o.base = 100
if p.fullBefore(ctx, c) || p.graph.full || events[len(events)-1] != meshwants.EventRoom {
t.Errorf("room: events %v", events)
}
}
@@ -34,8 +34,14 @@ type graphKeeper struct {
source int64
ok bool
said string
// cycled is the cycles last said (assigngraph.Cycles).
cycled string
// full says the next revision did not fit the store, so no want is written (graphfill.go); started says the first pass judged the fill.
full, started bool
// pins removes a revision that ceased to be current once nothing pins it (pins.go); nil keeps every revision.
pins *pinKeeper
// computed is the revision computed by the last pass, with its entries: the edges the wants wait on (actingwaits.go).
computed assigngraph.Computed
}
// keepGraph computes the revision of this pass and keeps it when the pass's inputs differ from the last computed.
@@ -45,15 +51,30 @@ func (p *passer) keepGraph(ctx context.Context) {
return
}
c := assigngraph.Compute(p.graphInputs())
g.computed = c
for _, u := range c.Uncomputed {
if g.said != u {
g.said = u
p.logf("the assignment graph leaves out %s", u)
}
}
if cycles := strings.Join(c.Cycles, "; "); cycles != g.cycled {
g.cycled = cycles
if cycles != "" {
p.logf("the assignment graph holds a cycle among the waits within one step, and its entries wait for each other: %s", cycles)
}
}
if !g.started {
g.started = true
p.fillAtStart(ctx)
}
if g.has && g.ok && assigngraph.Same(g.last, c.Revision) {
return
}
used, _, _ := g.fillOf(ctx)
if p.fullBefore(ctx, c) {
return
}
rev, fresh, err := assigngraph.Keep(ctx, g.objects, c, meshwants.EventAssignmentRevision, p.clock())
if err != nil {
p.logf("the assignment graph revision is not kept, so no want is said as wanted until it is: %v", err)
@@ -69,6 +90,7 @@ func (p *passer) keepGraph(ctx context.Context) {
if !fresh {
return
}
p.fillingAfter(ctx, used)
if g.pins != nil {
g.pins.judge() // the revision before ceased to be current
}
@@ -82,6 +104,11 @@ func (p *passer) keepGraph(ctx context.Context) {
// node's capability reading and the first branch of each registration.
func (p *passer) graphInputs() assigngraph.Inputs {
in := assigngraph.Inputs{Halves: p.graph.halves(), Placements: p.w.assignments(), Capabilities: p.capabilityDigests()}
// The seat definitions read through the seat mesh-catalog's verb seat-definitions say which seat delivers a requirement
// (design 47 G §708).
if r := p.seats.Reader(); r != nil {
in.Definitions = r.Definitions()
}
// With no pipeline ends kept, the wants are the trunk-only form (pass.go): a placement naming no branch runs the trunk's.
first := p.w.trunkName()
in.FirstBranch = func(string) string { return first }
@@ -138,6 +138,8 @@ func fromEntry(e meshcatalog.CatalogEntry) (meshcatalog.ModulesByModuleValue, gr
return v, n, &parsed{module: e.Module, err: "it does not decode: " + err.Error()}
}
n.Serves = graph.ServesOf(man)
// The entry's requires name the optional ones too; the node means those required, not optionally (A-229).
n.Requires = slices.DeleteFunc(slices.Clone(n.Requires), man.IsOptional)
return v, n, &parsed{module: e.Module, manifest: man}
}
@@ -29,6 +29,7 @@ import (
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -576,6 +577,9 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
return p.keepCandidate(ctx, iv.call, ev)
}
iv.candidates.Store(&keep)
// A held revert's running entry and its want's, asked of the seat mesh-catalog's verb entry (design 47 G §710, A-217).
p.entries = meshcatalogclient.NewEntries(iv.h.call)
p.entries.Changed = func(meshcatalogclient.EntryRow) { wake() }
// The pins of the revisions (pins.go, design 47 G §579): the gate's pinned, freed, heads-replaced and pins-stated
// on the hub, registered before it starts.
if kv := kvs[pinsBucket]; kv != nil {
@@ -618,6 +622,9 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
}
hearDelivery(ctx, js, w, wake, findings, logger.Printf) // version-released, version-delivered, machine-away and machine-back
hearBuilds(ctx, js, &w.ends, wake, findings, logger.Printf)
if err := hearTaken(hub, &w.taken, wake, logger.Printf); err != nil {
logger.Printf("the indexer's take-ins are not heard, so an entry held by an order edge waits until the assigner starts again: %v", err)
}
hearReadsChanged(ctx, js, hub, reads, findings, gate.EventsLive, logger.Printf)
hearRunning(ctx, js, w, wake, findings, logger.Printf)
}
@@ -99,3 +99,20 @@ func (b *busObjects) Delete(ctx context.Context, name string) error {
}
return nil
}
// Fill is the bytes the bucket holds and its cap (assigngraph.Filler).
func (b *busObjects) Fill(ctx context.Context) (int64, int64, error) {
o, err := b.open(ctx)
if err != nil {
return 0, 0, err
}
st, err := o.Status(ctx)
if err != nil {
return 0, 0, err
}
info, err := b.js.Stream(ctx, "OBJ_"+st.Bucket())
if err != nil {
return int64(st.Size()), 0, err
}
return int64(st.Size()), info.CachedInfo().Config.MaxBytes, nil
}
@@ -0,0 +1,296 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strconv"
"sync"
"git.novox.be/novox/mesh-contracts/go/seats/meshbroker"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"mesh-delivery-assigner/internal/acting"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assigngraph"
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
)
// The three order edges of a delivery that crosses a form change (design 47 G §298, A-245, A-258, A-259, A-260). Each is an acting wait of the
// assigner, kept in the state `waits` under `order.<node>.<module>.<part>` beside the acting waits, in the step placing, said as wait-held
// when a want is held for it and wait-ended (ran, released, replaced) when it moves; assigner_waits reads them and assigner_release with
// how `now` releases them, as it does an acting wait. The holders of the seats are read from the seat holders the assignments compose
// (assignment.SeatHoldersOf), never named:
// - the node-backup holder's and the bus module's (the holder of the seat mesh-broker) new versions run before a module whose kept data's
// form changes is moved, since the restore point needs both (OrderBackup);
// - at a revert a node's node-engine goes back last on that node, after every module it restores there runs the reverted build, which it
// starts only once it has said `restored` (OrderEngineLast);
// - the reverted assigner and gate are wanted only once the indexer's take-in.taken of the trunk branch names a source revision at least
// the trunk's head, the highest `src` of the trunk branch's module values (OrderTakenIn, A-260).
// takenSubject is the indexer's event take-in.taken on its own stream, as the manifest's consumes names it.
var takenSubject = process.ModuleStreamSubject(buckets.IndexerModule, "take-in.taken")
// takenSources are the newest source revisions a take-in.taken named, by watched branch, as heard (hearBuildsAndReleases).
type takenSources struct {
mu sync.Mutex
own map[string]int64
}
// put takes one take-in.taken in. A hand drop, and a taken with no source revision (one said before G), take nothing in.
func (t *takenSources) put(data []byte) error {
var e meshcatalog.TakeInTaken
if err := json.Unmarshal(data, &e); err != nil {
return fmt.Errorf("a take-in.taken that is none: %w", err)
}
if e.Dropped || e.Source == 0 || e.Branch == "" {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
if t.own == nil {
t.own = map[string]int64{}
}
t.own[e.Branch] = max(t.own[e.Branch], e.Source)
return nil
}
// of is the newest source revision of branch taken in; 0 before one is heard.
func (t *takenSources) of(branch string) int64 {
t.mu.Lock()
defer t.mu.Unlock()
return t.own[branch]
}
// orderEdge is one order edge held on module's want on node: part's version fp must run on partNode (OrderBackup, OrderEngineLast), or the
// trunk's source revision source be taken in (OrderTakenIn).
type orderEdge struct {
node, module, part, partNode, fp, entry, order string
source int64
}
func (e orderEdge) key() string { return acting.OrderKey(e.node, e.module, e.part) }
func (e orderEdge) acting() assign.ActingEdge {
return assign.ActingEdge{Node: e.partNode, Part: e.part, Fp: e.fp, Step: seatdef.StepPlace}
}
// orderRuns says an order edge is met: its part runs the version, or the source revision is taken in.
func (p *passer) orderRuns(n assign.Nodes, e orderEdge) bool {
if e.order == acting.OrderTakenIn {
return p.w.taken.of(p.w.trunkName()) >= e.source
}
return n.Runs(e.acting())
}
// seatHolders are the holders of each seat as the assignments compose them, by seat name.
func (p *passer) holdersBySeat() map[string][]mma.SeatHoldersHoldersItem {
out := map[string][]mma.SeatHoldersHoldersItem{}
for _, sh := range assignment.SeatHoldersOf(p.w.assignments()) {
out[sh.Seat] = append(out[sh.Seat], sh.Holders...)
}
return out
}
// orderEdgesOf computes §298's edges on the wants of module m on each of its nodes, into p.orders. wanted is m's wants by node; trunk the
// trunk's module values. A node that has no running build heard, runs m at its trunk version already, or is wanted that version already
// has none: nothing moves. It answers, by node, whether an edge still stands that cannot be recorded as a wait because the catalog entry
// it reads is not answered yet (asked, and its answer wakes a pass): the want is held meanwhile, never written across a form change unread.
func (p *passer) orderEdgesOf(ctx context.Context, m assign.Module, wanted map[string]mwc.Want, trunk map[string]meshcatalog.ModulesByModuleValue) map[string]bool {
unread := map[string]bool{}
p.orderVisited[m.Name] = true
if m.FP == "" || p.graph == nil || p.entries == nil {
return unread
}
var holders map[string][]mma.SeatHoldersHoldersItem
for _, node := range m.Nodes {
if w, ok := wanted[node]; ok && !w.Removed && w.Fp == m.FP {
continue // the version is wanted already: nothing moves, so nothing waits
}
run, heard := p.w.runningOf(node, m.Name)
if !heard || run.Stopped || run.Fingerprint == "" || run.Fingerprint == m.FP {
continue
}
if holders == nil {
holders = p.holdersBySeat()
}
held := func(seatName string) bool {
for _, h := range holders[seatName] {
if h.Module == m.Name && h.Node == node {
return true
}
}
return false
}
add := func(e orderEdge) {
e.node, e.module, e.entry = node, m.Name, m.FP
p.orders[e.key()] = e
}
// 1. a kept data's form changes: the restore point needs the new node-backup holder and bus module.
changes, known := p.formChange(ctx, m.Name, run.Fingerprint, m.FP)
if !known {
unread[node] = true
}
if len(changes) > 0 {
for seatName, onNode := range map[string]bool{catalogue.BackupSeat: true, meshbroker.Seat: false} {
for _, h := range holders[seatName] {
if h.Module == m.Name || (onNode && h.Node != node) || trunk[h.Module].Fp2 == "" {
continue
}
add(orderEdge{part: h.Module, partNode: h.Node, fp: trunk[h.Module].Fp2, order: acting.OrderBackup})
}
}
}
if !held(nodeengine.Seat) && !held(mma.Seat) && !held(meshdelivery.Seat) {
continue
}
reverted, err := assigngraph.Reverted(ctx, p.graph.objects, node, m.Name, run.Fingerprint, m.FP)
if err != nil {
p.logf("whether the want of %s on %s at %s is a revert is not read: %v", m.Name, node, m.FP, err)
continue
}
if !reverted {
continue
}
// 2. at a revert the node-engine goes back last on its node: after every module that is set back there across a form change runs.
if held(nodeengine.Seat) {
for module, byNode := range p.w.wantsOf() {
// What it is set back to is its trunk version, which the rules write as its want in this pass.
target := trunk[module].Fp2
if w, ok := byNode[node]; module == m.Name || !ok || w.Removed || target == "" {
continue
}
xrun, heard := p.w.runningOf(node, module)
if !heard || xrun.Stopped || xrun.Fingerprint == "" || xrun.Fingerprint == target {
continue
}
if back, err := assigngraph.Reverted(ctx, p.graph.objects, node, module, xrun.Fingerprint, target); err != nil || !back {
continue
}
xchanges, known := p.formChange(ctx, module, xrun.Fingerprint, target)
if !known {
unread[node] = true
}
if len(xchanges) > 0 {
add(orderEdge{part: module, partNode: node, fp: target, order: acting.OrderEngineLast})
}
}
}
// 3. the reverted assigner and gate wait for the indexer to take in the trunk's head.
if held(mma.Seat) || held(meshdelivery.Seat) {
var head int64
for _, v := range p.w.trunkOf(p.w.trunkName()) {
head = max(head, v.Src)
}
if head > 0 {
add(orderEdge{part: buckets.IndexerModule, fp: strconv.FormatInt(head, 10), source: head, order: acting.OrderTakenIn})
}
}
}
return unread
}
// orderActing is the order edges of module on node that still hold its want, as acting edges: those not met, and not released by hand. A
// node whose catalog entry is not read yet holds its want too, on a stand-in edge no part runs.
func (p *passer) orderActing(module string, released map[string]bool, unread map[string]bool, n func() assign.Nodes) func(node string) []assign.ActingEdge {
return func(node string) []assign.ActingEdge {
var out []assign.ActingEdge
nodes := n()
for _, e := range p.orders {
if e.module == module && e.node == node && !released[e.key()+"@"+e.fp] && !p.orderRuns(nodes, e) {
out = append(out, assign.ActingEdge{Node: e.partNode, Part: e.part, Fp: e.fp, Step: seatdef.StepPlace})
}
}
if unread[node] {
out = append(out, assign.ActingEdge{Node: node, Part: "the catalog entry", Step: seatdef.StepPlace})
}
return out
}
}
// orderWaits keeps the order waits after the pass's wants, as actingWaits keeps the acting waits: it starts the wait of each edge not met,
// ends the wait of one that ran, was replaced or was released, and says each.
func (p *passer) orderWaits(ctx context.Context, n assign.Nodes, wanted map[string]map[string]mwc.Want) {
if p.kvs[buckets.Waits] == nil {
return
}
now := p.clock()
kept := p.w.read(buckets.Waits)
trunk := p.w.trunk()
if p.orderSaid == nil {
p.orderSaid = map[string]bool{}
}
standing := map[string]bool{}
for key, e := range p.orders {
if w, ok := wanted[e.module][e.node]; ok && !w.Removed && w.Fp == e.entry {
continue
}
if p.orderRuns(n, e) {
continue
}
standing[key] = true
if raw := kept[key]; raw != nil {
if w, err := acting.Decode(raw); err == nil && w.Entry == e.entry && w.Fp == e.fp {
if !w.Released && !p.orderSaid[key] {
// A wait still held after a restart is said again, as wait-held (A-260).
if err := p.wantsSay(seat.EventWaitHeld, acting.Body(w, assignment.Module), key+"."+e.fp+"."+e.entry+".again"); err != nil {
p.logf("the wait of %s on %s for %s is not said again, and is said at the next pass: %v", e.module, e.node, e.part, err)
} else {
p.orderSaid[key] = true
}
}
p.actingReleaseSaid(ctx, key, w)
continue
}
}
if !n.Online[e.node] {
continue // a node away holds only itself, and its clock has not started (§268)
}
w := acting.Wait{Step: seatdef.StepPlace, Node: e.node, Module: e.module, Part: e.part, PartNode: e.partNode, Fp: e.fp, Entry: e.entry, Order: e.order,
Since: now, ClockFrom: now, Bound: acting.BoundOf(seat.Edge{Step: seatdef.StepPlace}).String()}
if err := p.wantsSay(seat.EventWaitHeld, acting.Body(w, assignment.Module), key+"."+e.fp+"."+e.entry); err != nil {
p.logf("the wait of %s on %s for %s is not said, and is said at the next pass: %v", e.module, e.node, e.part, err)
continue
}
p.put(ctx, buckets.Waits, key, func() ([]byte, error) { return acting.Encode(w) })
p.orderSaid[key] = true
}
for key, raw := range kept {
if !acting.IsOrder(key) || standing[key] {
continue
}
w, err := acting.Decode(raw)
if err != nil {
continue
}
if _, there := trunk[w.Module]; there && !p.orderVisited[w.Module] {
continue // the module was not looked at in this pass (no pipeline passed for it yet): its wait stands as kept
}
how := acting.HowRan
switch e, there := p.orders[key]; {
case w.Released:
how = acting.HowReleased
case there && e.fp != w.Fp, !there && wanted[w.Module][w.Node].Fp != w.Entry:
how = acting.HowReplaced
}
if !w.EndSaid {
body := acting.Body(w, assignment.Module)
body.How = how
if err := p.wantsSay(seat.EventWaitEnded, body, key+"."+w.Fp+"."+w.Entry+"."+how); err != nil {
p.logf("the end of the wait of %s on %s for %s is not said, and is said at the next pass: %v", w.Module, w.Node, w.Part, err)
continue
}
}
p.remove(ctx, buckets.Waits, key)
delete(p.orderSaid, key)
}
}
@@ -0,0 +1,351 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshbroker"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"mesh-delivery-assigner/internal/acting"
"mesh-delivery-assigner/internal/assigngraph"
plc "mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
)
// orderFP is a fingerprint of the form the trunk keeps, made of one letter.
func orderFP(c string) string {
return meshcatalogclient.SourcePrefix2 + strings.Repeat(fmt.Sprintf("%x", c[0]&15), 64)
}
// orderMod is one module of an order test: the fingerprint it runs on ace and the one the trunk holds, the form of its kept data at each
// fingerprint (0: it keeps none), the seats it holds and the source revision of its trunk value.
type orderMod struct {
name string
run, trunk string
forms map[string]int
holds []string
src int64
}
type orderFixture struct {
t *testing.T
p *passer
wants *memKV
events []string
bodies []meshwants.Wait
answers chan struct{}
}
// newOrderFixture is a pass on node ace, which composes its own modules, with each module running its fingerprint healthy, built and
// granted at the trunk's, an older want, the catalog's entries answered and each held seat as the assignments say it. history lists the
// fingerprints of each module by assignment graph revision, oldest first (the objects Reverted reads).
func newOrderFixture(t *testing.T, mods []orderMod, history ...map[string]string) *orderFixture {
t.Helper()
ctx := context.Background()
f := &orderFixture{t: t, wants: &memKV{values: map[string][]byte{}}, answers: make(chan struct{}, 64)}
f.p = newPasser(kvSet{buckets.Wants: f.wants, buckets.Asks: &memKV{values: map[string][]byte{}}, buckets.Waits: &memKV{values: map[string][]byte{}}}, time.Now())
p := f.p
p.logf = t.Logf
p.say = func(oc.Condition) error { return nil }
p.publishWants = func(event string, body any, id string) error {
f.events = append(f.events, event)
f.bodies = append(f.bodies, body.(meshwants.Wait))
return nil
}
digest := "sha256:" + strings.Repeat("c", 64)
pl := mma.Assignments{Node: "ace"}
forms := map[string]int{} // by fingerprint: each module's own
modules := []string{}
for _, m := range mods {
modules = append(modules, m.name)
pl.Modules = append(pl.Modules, mma.PlacedModule{Module: m.name})
for _, seat := range m.holds {
scope := "node"
if seat != catalogue.BackupSeat && seat != nodeengine.Seat {
scope = "mesh"
}
pl.Holds = append(pl.Holds, mma.Holding{Claim: seat, Module: m.name, Scope: scope})
}
for fp, form := range m.forms {
forms[fp] = form
}
mdigest := fmt.Sprintf("%064x", len(modules))
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: m.trunk, Manifest: mdigest, Repository: "novox/" + m.name, Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath, Src: m.src})
putModuleRaw(p.w, trunkBranch, m.name, mod)
p.w.manifests[mdigest] = parsed{module: m.name, manifest: catalogue.Manifest{Module: m.name}}
endDone(t, p.w, m.name, m.trunk, mdigest)
gkey, _ := grantsstate.PartGrantKey(m.name, m.trunk)
grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{})
p.w.put(buckets.Grants, gkey, grant)
f.runs(m.name, m.run)
wkey, _ := mwc.WantKey("ace", m.name)
old, _ := mwc.EncodeWant(mwc.Want{Fp: m.run, Self: true})
p.w.put(buckets.Wants, wkey, old)
f.wants.values[wkey] = old
}
composes(t, p.w, "ace", digest)
values, err := mmac.AssignmentsValues(pl)
if err != nil {
t.Fatal(err)
}
for key, raw := range values {
p.w.put(plc.AssignmentsBucket, key, raw)
}
p.w.heardPresence("ace", true, time.Now())
objects := memObjects{}
for i, at := range history {
half := meshcatalogclient.Half{Revision: meshcatalog.CatalogRevision{Branch: trunkBranch, Kind: "trunk", Revision: int64(i + 1), Source: int64(i + 1)},
Entries: map[string]meshcatalog.CatalogEntry{}}
for _, module := range modules {
digest := fmt.Sprintf("sha256:%064x", (i+1)*1000+len(half.Entries))
half.Revision.Entries = append(half.Revision.Entries, meshcatalog.EntryRef{Module: module, Digest: digest})
half.Entries[module] = meshcatalog.CatalogEntry{Module: module, Fp: at[module]}
}
placed := mma.Assignments{Node: "ace"}
for _, module := range modules {
placed.Modules = append(placed.Modules, mma.PlacedModule{Module: module})
}
in := assigngraph.Inputs{Halves: map[string]meshcatalogclient.Half{trunkBranch: half}, FirstBranch: func(string) string { return trunkBranch },
Placements: map[string]mma.Assignments{"ace": placed}}
if _, _, err := assigngraph.Keep(ctx, objects, assigngraph.Compute(in), "test", time.Now()); err != nil {
t.Fatal(err)
}
}
p.graph = &graphKeeper{objects: objects}
p.entries = meshcatalogclient.NewEntries(func(_ string, body any) (json.RawMessage, error) {
in := body.(meshcatalog.EntryInput)
entry := meshcatalog.CatalogEntry{Module: "m", Fp: in.Fingerprint, Manifest: map[string]any{"module": "m"}}
if form := forms[in.Fingerprint]; form > 0 {
entry.Manifest = map[string]any{"module": "m", "state": []any{map[string]any{"name": "sessions", "form": form}}}
}
return json.Marshal(meshcatalog.EntryAnswer{Entry: entry})
})
p.entries.Changed = func(meshcatalogclient.EntryRow) { f.answers <- struct{}{} }
return f
}
// runs says module runs fp healthy on ace, heard as the node-engine seat's running and in the have.
func (f *orderFixture) runs(module, fp string) {
have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: "sha256:" + strings.Repeat("c", 64), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
f.p.w.put(buckets.Have, "ace."+module, have)
f.p.w.running["ace."+module] = nodeengine.Running{Node: "ace", Module: module, Fingerprint: fp}
}
// pass makes a pass, and after it every catalog entry it asked is answered.
func (f *orderFixture) pass() {
f.t.Helper()
f.p.wants(context.Background())
asked := 0
for _, row := range f.p.entries.Rows() {
if row.State == meshcatalogclient.EntryAsked {
asked++
}
}
for range asked {
select {
case <-f.answers:
case <-time.After(10 * time.Second): // a bound on a failure only: the answers come at once
f.t.Fatal("an entry asked was not answered")
}
}
}
// wantAt is the fingerprint module is wanted at on ace.
func (f *orderFixture) wantAt(module string) string {
f.t.Helper()
key, _ := mwc.WantKey("ace", module)
w, err := mwc.DecodeWant(f.wants.values[key])
if err != nil {
f.t.Fatal(err)
}
return w.Fp
}
func (f *orderFixture) waits() []string {
var out []string
for key := range f.p.w.read(buckets.Waits) {
if acting.IsOrder(key) {
out = append(out, key)
}
}
return out
}
// Design 47 G §298 first edge, A-258, A-259: a module whose kept data's form changes is not moved until the new version of the node-backup
// holder and of the holder of the seat mesh-broker run; the wait is kept under `order.` in the state waits, said as wait-held, and ends
// ran (wait-ended) when the holders run; a module whose kept data keeps its form is moved at once.
func TestAModuleWhoseKeptDataChangesFormWaitsForTheNewBackupHolderAndTheBusModule(t *testing.T) {
f := newOrderFixture(t, []orderMod{
{name: "web", run: orderFP("a"), trunk: orderFP("b"), forms: map[string]int{orderFP("a"): 1, orderFP("b"): 2}},
{name: "plain", run: orderFP("c"), trunk: orderFP("d"), forms: map[string]int{orderFP("c"): 1, orderFP("d"): 1}},
{name: "backup", run: orderFP("e"), trunk: orderFP("f"), holds: []string{catalogue.BackupSeat}},
{name: "bus", run: orderFP("g"), trunk: orderFP("h"), holds: []string{meshbroker.Seat}},
})
f.pass() // the entries are asked: no want crosses a form change unread
if got := f.wantAt("web"); got != orderFP("a") {
t.Fatalf("web is wanted at %s before the entries are read", got)
}
f.pass()
if got := f.wantAt("web"); got != orderFP("a") {
t.Fatalf("web moved across a form change at %s while the new node-backup holder and bus module do not run", got)
}
if got := f.wantAt("plain"); got != orderFP("d") {
t.Errorf("a module whose kept data keeps its form is wanted at %s, not moved", got)
}
if len(f.waits()) != 2 || len(f.events) != 2 || f.events[0] != meshwants.EventWaitHeld || f.bodies[0].Step != seatdef.StepPlace || f.bodies[0].Module != "web" {
t.Fatalf("waits %v events %v bodies %+v: want two wait-held in the step place, `order.ace.web.backup` and `order.ace.web.bus`", f.waits(), f.events, f.bodies)
}
if _, err := acting.Decode(f.p.w.read(buckets.Waits)[acting.OrderKey("ace", "web", "backup")]); err != nil {
t.Fatalf("the wait is not kept under its key: %v", err)
}
f.pass()
if len(f.events) != 2 {
t.Fatalf("a pass that changed nothing said %v", f.events)
}
f.runs("backup", orderFP("f"))
f.pass()
if got := f.wantAt("web"); got != orderFP("a") || len(f.events) != 3 || f.events[2] != meshwants.EventWaitEnded || f.bodies[2].How != acting.HowRan {
t.Fatalf("with the bus module still old: web at %s, events %v", got, f.events)
}
f.runs("bus", orderFP("h"))
f.pass()
f.pass()
if got := f.wantAt("web"); got != orderFP("b") || len(f.waits()) != 0 || len(f.events) != 4 {
t.Fatalf("with both running: web at %s, waits %v, events %v", got, f.waits(), f.events)
}
}
// §298 first edge, the hand: assigner_waits reads an order wait and assigner_release with how `now` lets the module move.
func TestAnOrderWaitIsReadAndReleasedByTheExistingTools(t *testing.T) {
f := newOrderFixture(t, []orderMod{
{name: "web", run: orderFP("a"), trunk: orderFP("b"), forms: map[string]int{orderFP("a"): 1, orderFP("b"): 2}},
{name: "backup", run: orderFP("e"), trunk: orderFP("f"), holds: []string{catalogue.BackupSeat}},
})
f.pass()
f.pass()
var acts []string
s := newPutTools(memStore{waitsState: f.p.kvs[buckets.Waits].(*memKV), buckets.DeliveryWaitsState: &memKV{values: map[string][]byte{}}, buckets.DeliveredAgainState: &memKV{values: map[string][]byte{}}}, func(key string, body any) (json.RawMessage, error) {
acts = append(acts, key)
return json.RawMessage(`{"ok":true,"log":"logged"}`), nil
})
got, err := s.doWaits(map[string]any{"module": "web"})
if list := got.(map[string]any)["waits"].([]any); err != nil || len(list) != 1 || list[0].(actingSaid).Key != "order.ace.web.backup" {
t.Fatalf("assigner_waits = %+v %v", got, err)
}
if _, err := s.doRelease(map[string]any{"node": "ace", "module": "web", "how": "now", "why": "the restore point is taken by hand"}, "shanks.node-tools"); err != nil || len(acts) == 0 {
t.Fatalf("%v %v", err, acts)
}
f.p.w.put(buckets.Waits, "order.ace.web.backup", f.p.kvs[buckets.Waits].(*memKV).values["order.ace.web.backup"])
f.pass()
f.pass()
if got := f.wantAt("web"); got != orderFP("b") || f.bodies[len(f.bodies)-1].How != acting.HowReleased {
t.Fatalf("after the release: web at %s, last %+v", got, f.bodies[len(f.bodies)-1])
}
}
// §298 second edge, A-259: at a revert the node-engine goes back last on its node, after every module that is set back there across a
// form change runs the reverted build.
func TestAtARevertTheNodeEngineGoesBackLast(t *testing.T) {
old, now := map[string]string{"web": orderFP("a"), "engine": orderFP("c")}, map[string]string{"web": orderFP("b"), "engine": orderFP("d")}
f := newOrderFixture(t, []orderMod{
{name: "web", run: orderFP("b"), trunk: orderFP("a"), forms: map[string]int{orderFP("a"): 1, orderFP("b"): 2}},
{name: "engine", run: orderFP("d"), trunk: orderFP("c"), holds: []string{nodeengine.Seat}},
}, old, now)
f.pass()
f.pass()
if f.wantAt("web") != orderFP("a") {
t.Fatalf("web is wanted at %s: the module set back is not held", f.wantAt("web"))
}
if got := f.wantAt("engine"); got != orderFP("d") {
t.Fatalf("the node-engine is set back to %s before web runs its reverted build", got)
}
if w := f.waits(); len(w) != 1 || w[0] != "order.ace.engine.web" || f.bodies[len(f.bodies)-1].Module != "engine" {
t.Fatalf("waits %v bodies %+v", w, f.bodies)
}
f.runs("web", orderFP("a"))
f.pass()
f.pass()
if got := f.wantAt("engine"); got != orderFP("c") || len(f.waits()) != 0 {
t.Fatalf("with web back: the node-engine is wanted at %s, waits %v", got, f.waits())
}
}
// §298 third edge, A-259, A-260: the reverted assigner and gate are wanted only once the indexer's take-in.taken of the trunk branch names a
// source revision at least the trunk's head (the highest src of the trunk's module values).
func TestTheRevertedAssignerAndGateWaitForTheIndexerToTakeInTheTrunksHead(t *testing.T) {
old, now := map[string]string{"asg": orderFP("a"), "gate": orderFP("c"), "idx": orderFP("e")}, map[string]string{"asg": orderFP("b"), "gate": orderFP("d"), "idx": orderFP("f")}
f := newOrderFixture(t, []orderMod{
{name: "asg", run: orderFP("b"), trunk: orderFP("a"), holds: []string{mma.Seat}, src: 6},
{name: "gate", run: orderFP("d"), trunk: orderFP("c"), holds: []string{meshdelivery.Seat}, src: 7},
{name: "idx", run: orderFP("e"), trunk: orderFP("e"), src: 7},
}, old, now)
f.pass()
f.pass()
if f.wantAt("asg") != orderFP("b") || f.wantAt("gate") != orderFP("d") {
t.Fatalf("the reverted assigner (%s) and gate (%s) are wanted before the head is taken in", f.wantAt("asg"), f.wantAt("gate"))
}
if len(f.waits()) != 2 || f.bodies[0].Part != "mesh-delivery-indexer" {
t.Fatalf("waits %v bodies %+v", f.waits(), f.bodies)
}
taken := func(branch string, source int64) {
raw, _ := json.Marshal(meshcatalog.TakeInTaken{Kind: "taken", Key: "m.1", Branch: branch, Source: source})
if err := f.p.w.taken.put(raw); err != nil {
t.Fatal(err)
}
}
taken("beta", 9) // another branch, and
taken(trunkBranch, 6)
f.pass()
if f.wantAt("asg") != orderFP("b") {
t.Fatal("a source revision before the trunk's head was taken in, and the assigner was wanted")
}
taken(trunkBranch, 7)
f.pass()
f.pass()
if f.wantAt("asg") != orderFP("a") || f.wantAt("gate") != orderFP("c") || len(f.waits()) != 0 {
t.Fatalf("after the head is taken in: asg %s gate %s waits %v", f.wantAt("asg"), f.wantAt("gate"), f.waits())
}
}
// A-260: an order wait still held after a restart is said again as wait-held, by a pass that remembers nothing, and ends at the next
// take-in.taken that names the trunk's head.
func TestAnOrderWaitHeldAfterARestartIsSaidAgain(t *testing.T) {
old, now := map[string]string{"asg": orderFP("a")}, map[string]string{"asg": orderFP("b")}
f := newOrderFixture(t, []orderMod{{name: "asg", run: orderFP("b"), trunk: orderFP("a"), holds: []string{mma.Seat}, src: 7}}, old, now)
f.pass()
f.pass()
if len(f.events) != 1 {
t.Fatalf("events %v", f.events)
}
f.p.orderSaid = nil // a process started again
f.pass()
if len(f.events) != 2 || f.events[1] != meshwants.EventWaitHeld || len(f.waits()) != 1 {
t.Fatalf("events %v waits %v", f.events, f.waits())
}
raw, _ := json.Marshal(meshcatalog.TakeInTaken{Kind: "taken", Key: "m.1", Branch: trunkBranch, Source: 8})
if err := f.p.w.taken.put(raw); err != nil {
t.Fatal(err)
}
f.pass()
f.pass()
if f.wantAt("asg") != orderFP("a") || len(f.waits()) != 0 || f.events[len(f.events)-1] != meshwants.EventWaitEnded {
t.Fatalf("asg at %s, waits %v, events %v", f.wantAt("asg"), f.waits(), f.events)
}
}
@@ -267,10 +267,10 @@ func TestTheToolsReadAndReleaseAnOutsideWait(t *testing.T) {
s.st.put(waitsState, k, v)
}
got, err := s.doWaits(map[string]any{"module": "nats"})
if err != nil || len(got.(map[string]any)["waits"].([]waitSaid)) != 1 {
if err != nil || len(got.(map[string]any)["waits"].([]any)) != 1 {
t.Fatalf("assigner_waits = %+v %v", got, err)
}
if w := got.(map[string]any)["waits"].([]waitSaid)[0]; w.On != "unreachable:hub" || w.BoundEnds == "" {
if w := got.(map[string]any)["waits"].([]any)[0].(waitSaid); w.On != "unreachable:hub" || w.BoundEnds == "" {
t.Errorf("wait = %+v", w)
}
if _, err := s.doRelease(map[string]any{"node": "ace", "module": "nats", "how": "sideways", "why": "x"}, "shanks.node-tools"); err == nil {
@@ -14,6 +14,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/keys"
"mesh-delivery-assigner/internal/acting"
"mesh-delivery-assigner/internal/buckets"
"mesh-delivery-assigner/internal/outside"
)
@@ -36,6 +37,13 @@ type waitSaid struct {
BoundEnds string `json:"bound-ends,omitempty"`
}
// actingSaid is one wait for an acting part's new version as assigner_waits answers it (actingwaits.go).
type actingSaid struct {
Key string `json:"key"`
acting.Wait
BoundEnds string `json:"bound-ends,omitempty"`
}
// doWaits is the tool assigner_waits.
func (s *putTools) doWaits(args map[string]any) (any, error) {
keys, err := s.st.keys(waitsState)
@@ -43,12 +51,22 @@ func (s *putTools) doWaits(args map[string]any) (any, error) {
return nil, fmt.Errorf("the waits cannot be listed: %w", err)
}
sort.Strings(keys)
out := []waitSaid{}
out := []any{}
for _, key := range keys {
raw, _, found, err := s.st.get(waitsState, key)
if err != nil || !found {
continue
}
if acting.Is(key) || acting.IsOrder(key) {
if a, err := acting.Decode(raw); err == nil && (arg(args, "node") == "" || a.Node == arg(args, "node")) && (arg(args, "module") == "" || a.Module == arg(args, "module")) {
said := actingSaid{Key: key, Wait: a}
if d, err := time.ParseDuration(a.Bound); err == nil && !a.Released {
said.BoundEnds = a.ClockFrom.Add(d).UTC().Format("2006-01-02T15:04:05Z")
}
out = append(out, said)
}
continue
}
w, err := outside.Decode(raw)
if err != nil || (arg(args, "node") != "" && w.Node != arg(args, "node")) || (arg(args, "module") != "" && w.Module != arg(args, "module")) {
continue
@@ -172,6 +190,9 @@ func (s *putTools) doReleaseDelivery(args map[string]any, by string) (any, error
// module, which the assigner's process takes at its next pass.
func (s *putTools) doReleaseWait(args map[string]any, by string) (any, error) {
node, module, how, why := arg(args, "node"), arg(args, "module"), arg(args, "how"), arg(args, "why")
if how == acting.HowNow {
return s.doReleaseActing(node, module, why, by)
}
if !slices.Contains([]string{outside.HowGoOn, outside.HowFailed, outside.HowRejudge}, how) {
return nil, fmt.Errorf("how is %s, %s or %s, not %q", outside.HowGoOn, outside.HowFailed, outside.HowRejudge, how)
}
@@ -217,9 +238,61 @@ func (s *putTools) doReleaseWait(args map[string]any, by string) (any, error) {
return map[string]any{"released": key, "how": how, "by": by, "hand-act": taken.Log}, nil
}
// doReleaseActing is assigner_release with how `now`: the entry of module on node moves with its acting parts' running versions. The
// hand act is recorded first; the release is written on each standing wait of the entry, and the process says wait-ended at its next pass.
func (s *putTools) doReleaseActing(node, module, why, by string) (any, error) {
if why == "" {
return nil, errors.New("release says why: it is kept in the hand-act log")
}
keys, err := s.st.keys(waitsState)
if err != nil {
return nil, fmt.Errorf("the waits cannot be listed: %w", err)
}
type one struct {
key string
w acting.Wait
}
var held []one
prefix, orderPrefix := acting.Prefix+node+"."+module+".", acting.OrderPrefix+node+"."+module+"."
for _, k := range keys {
if !strings.HasPrefix(k, prefix) && !strings.HasPrefix(k, orderPrefix) {
continue
}
raw, _, found, err := s.st.get(waitsState, k)
if err != nil || !found {
continue
}
if w, err := acting.Decode(raw); err == nil && !w.Released {
held = append(held, one{k, w})
}
}
if len(held) == 0 {
return nil, fmt.Errorf("%s holds no wait of %s for an acting part: assigner_waits lists them", node, module)
}
rec := meshhandactsclient.Recorder{Ask: s.ask}
taken, err := rec.Record(meshhandactsclient.Request{What: fmt.Sprintf("assigner_release %s %s %s", node, module, acting.HowNow), Why: why,
Cause: "acting-wait", Verb: "mesh-wants.assigner_release"})
if err != nil {
return nil, fmt.Errorf("the hand act could not be recorded, so nothing was released: %w", err)
}
released := []string{}
for _, h := range held {
h.w.Released, h.w.By, h.w.Why = true, by+": "+why, why
data, err := acting.Encode(h.w)
if err != nil {
return nil, err
}
if err := s.st.put(waitsState, h.key, data); err != nil {
return nil, fmt.Errorf("the release of %s on %s is not written: %w", module, node, err)
}
released = append(released, h.key)
}
return map[string]any{"released": released, "how": acting.HowNow, "by": by, "hand-act": taken.Log}, nil
}
// waitsTool is assigner_waits as the runtime serves it.
func (s *putTools) waitsTool() stdio.Tool {
return stdio.Tool{Name: toolWaits, Description: "Every wait of the delivery on an outside cause and where it stands: a moved module's failing health check on a " +
return stdio.Tool{Name: toolWaits, Description: "Every wait of the delivery and where it stands: an entry waiting for an acting part's new version in composing or placing (§266, §268), with its part, version, step, clock-from and bound; and a moved module's failing health check on a " +
"machine whose open conditions name a cause outside the delivery (design 47 G §706), with its module, machine, fingerprint, the " +
"condition's key, what it stands `on`, its clock-from, bound, its ask and its state; a settle shows as settling with its end. Narrowed by " +
"machine or module. Read-only.",
@@ -14,6 +14,7 @@ import (
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
@@ -103,6 +104,14 @@ type passer struct {
publishWants publisher
// graph keeps the assignment graph revision the wants follow (graphkept.go, design 47 G §292, A-67); nil: none is kept.
graph *graphKeeper
// orders are §298's order edges computed in this pass, by their wait's key, and orderVisited the modules they were computed for (orderwaits.go).
orders map[string]orderEdge
orderVisited map[string]bool
// orderSaid are the order waits whose wait-held this process said.
orderSaid map[string]bool
// entries are the catalog entries by fingerprint asked of the seat mesh-catalog's verb `entry` (design 47 G §614, A-217): a
// held revert's running entry and its want's (revert.go); nil: no revert is asked about.
entries *meshcatalogclient.Entries
}
// wantsSay says one event of the seat mesh-wants; with no bus to say it on, nothing is said.
@@ -207,10 +216,12 @@ func (p *passer) pass(ctx context.Context) {
p.keepGraph(ctx)
p.judgeDelivered()
p.deliveredAgain(ctx)
p.wants(ctx)
if p.graph == nil || !p.graph.full { // a full assignment graph store keeps the last revision and writes no new want (design 47 G §181)
p.wants(ctx)
}
p.puts(ctx)
p.nodeWants(ctx)
p.backups(ctx)
p.backups(ctx, p.reverts(ctx))
}
// nodeWants writes each placed node's own want, `want.<node>.node`: the node composes its own (ADR 0547 §2, ADR 0571 §6).
@@ -286,6 +297,10 @@ func (p *passer) wants(ctx context.Context) {
defer func() { p.sayNeither(neither, stale, left) }()
// Where grants are read from, once for the pass: the grants part's (issue 774, ADR 0571 clause 9).
grants := p.w.grantsBucket()
released := p.actingReleased()
p.orders, p.orderVisited = map[string]orderEdge{}, map[string]bool{}
defer func() { p.actingWaits(ctx, viewOf(p.w.trunkName()).nodes, wanted) }()
defer func() { p.orderWaits(ctx, viewOf(p.w.trunkName()).nodes, wanted) }()
for name := range names {
groups := p.groups(name, nodesOf[name], assignedBranches[name])
for gi, g := range groups {
@@ -356,6 +371,9 @@ func (p *passer) wants(ctx context.Context) {
m.MachineHeld = p.outside.Held
nodes = p.outsideReview(ctx, name, fp, m.Nodes, nodes, manifests[name].ClaimsSeat(mcseat.Seat), p.movedTo(wanted, nodes))
}
unread := p.orderEdgesOf(ctx, m, wantedOf[name], trunk)
actingOf, orderOf := p.actingOf(name, released), p.orderActing(name, released, unread, func() assign.Nodes { return nodes })
m.Acting = func(node string) []assign.ActingEdge { return append(actingOf(node), orderOf(node)...) }
out := assign.Wants(m, nodes, wantedOf[name], last)
for _, node := range m.Nodes {
for _, prov := range m.MissingProvisions(node) {
@@ -744,7 +762,7 @@ func (p *passer) lift(ctx context.Context, module, fp string) {
// staleAsk says why the hold, restore or record ask at key (read as the SDK reads it) is about something the trunk has left
// behind, or "" when it is not: a module no longer on the trunk, a fingerprint that is no longer the module's trunk fingerprint,
// or, for a record ask, a composition the module's node no longer waits on. A backup ask is stale when the module's want on its
// or, for a record ask, a composition the module's node no longer waits on. A backup ask, and a revert ask (design 47 G §710), is stale when the module's want on its
// node names another fingerprint or is removed (the move it is about was superseded; issue 1966 note 165); a want not written
// yet, or at the same fingerprint in another composition, leaves it (backups asks that again under the next number). An ask of
// another kind is never stale here. A module of the trunk whose fingerprint is not read yet, and a have not read yet, are not stale: nothing known is
@@ -755,7 +773,7 @@ func (p *passer) staleAsk(key string, trunk map[string]meshcatalog.ModulesByModu
return ""
}
switch k.Kind {
case asks.AskKindBackup:
case asks.AskKindBackup, asks.AskKindRevert:
wkey, err := mwc.WantKey(k.Node, k.Module)
if err != nil {
return ""
@@ -876,6 +894,10 @@ func (p *passer) staleAsks(ctx context.Context, trunk map[string]meshcatalog.Mod
}
delete(p.said, assign.BackupCondition(k.Node, k.Module, k.FP, "", false).Key)
}
// A revert ask's finding goes with it: a newer want replaced the held revert (design 47 G §710, §363).
if kerr == nil && k.Kind == asks.AskKindRevert {
p.revertFinding(assign.RevertHeldFinding(k.Node, k.Module, "", "", ""), false)
}
p.logf("the ask %s is about a fingerprint or composition the trunk has left behind and is taken away: %s", key, why)
p.remove(ctx, buckets.Asks, key)
// The held record stays: it is the memory that this fingerprint failed (ADR 0408 §4), and a revert returns the trunk to
@@ -995,7 +1017,9 @@ func (p *passer) recordAsk(ctx context.Context, module, fp string, need assign.R
// before the `@`). Each answer is another want value (the asking's number is in the want's words), so a backup that fails again
// after "back up again" and is answered "again" again changes the key and the node retries. That the backup failed again is seen
// in a new have value after the answer was written.
func (p *passer) backups(ctx context.Context) {
//
// A have that holds a revert (reverts, which answers its `<node>.<module>` in reverting) is left to the revert ask.
func (p *passer) backups(ctx context.Context, reverting map[string]bool) {
wanted := p.w.wantsOf()
for key, value := range p.w.read(buckets.Have) {
node, module, isNode, err := nodeengineclient.ParseHaveKey(key)
@@ -1003,7 +1027,7 @@ func (p *passer) backups(ctx context.Context) {
continue
}
have, err := nodeengineclient.DecodeHave(value)
if err != nil || have.Phase != nodeengineclient.PhaseHeld {
if err != nil || have.Phase != nodeengineclient.PhaseHeld || reverting[node+"."+module] {
continue
}
want, ok := wanted[module][node]
@@ -334,8 +334,9 @@ func TestTheCatalogHalfIsTakenInPerBranch(t *testing.T) {
woken := 0
h := &halves{w: w, wake: func() { woken++ }, logf: t.Logf}
entry := func(fp string) json.RawMessage {
raw, _ := json.Marshal(meshcatalog.CatalogEntry{Module: "web", Fp: fp, Repository: "novox/web", Commit: "c1", Manifest: map[string]any{"module": "web", "version": "1"},
Invokes: []string{"seat:x.v"}})
raw, _ := json.Marshal(meshcatalog.CatalogEntry{Module: "web", Fp: fp, Repository: "novox/web", Commit: "c1",
Manifest: map[string]any{"module": "web", "version": "1", "requires": []any{"database", map[string]any{"name": "cache", "optional": true}}},
Invokes: []string{"seat:x.v"}, Requires: []string{"cache", "database"}})
return raw
}
h.changed("main", "web", entry("src2:1"), false)
@@ -347,7 +348,8 @@ func TestTheCatalogHalfIsTakenInPerBranch(t *testing.T) {
if got := w.manifest(v.Manifest); got.err != "" || got.manifest.Module != "web" {
t.Errorf("the entry's manifest is not read by its digest: %+v", got)
}
if n := w.graphOf("main")["web"]; n.FP != "src2:1" || !slices.Equal(n.Invokes, []string{"seat:x.v"}) {
// The entry's requires name the optional cache too; the graph node means those required, not optionally (A-229).
if n := w.graphOf("main")["web"]; n.FP != "src2:1" || !slices.Equal(n.Invokes, []string{"seat:x.v"}) || !slices.Equal(n.Requires, []string{"database"}) {
t.Errorf("graph node %+v", n)
}
h.changed("beta", "web", nil, true)
@@ -0,0 +1,177 @@
package main
import (
"context"
"strconv"
"strings"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assigngraph"
"mesh-delivery-assigner/internal/buckets"
)
// revertReasons are the reasons a node's have gives when it holds a revert (design 47 G §300, §567).
var revertReasons = []string{"restore point missing", "restore point damaged"}
// revertReason is the reason of a have's words when they say a held revert, or "".
func revertReason(words string) string {
for _, r := range revertReasons {
if strings.HasPrefix(words, r) {
return r
}
}
return ""
}
// reverts sees to each revert a node holds with no restore point (design 47 G §300, §710): the have's phase held with the
// reason `restore point missing` or `restore point damaged`, for a want the assignment graph tells a revert (assigngraph.Reverted,
// A-217) across a kept data's form (assigngraph.FormChanges, the running entry and the want's read through the catalog seat's verb
// `entry`). It asks the operator hold or live, raises the finding revert-held, and on live writes the want again with `revert`
// live; hold writes nothing. The finding is cleared once the reverted build runs. It answers the `<node>.<module>` of each have
// that holds a revert, which backups leaves to it.
func (p *passer) reverts(ctx context.Context) map[string]bool {
taken := map[string]bool{}
wanted := p.w.wantsOf()
for key, value := range p.w.read(buckets.Have) {
node, module, isNode, err := nodeengineclient.ParseHaveKey(key)
if err != nil || isNode {
continue
}
have, err := nodeengineclient.DecodeHave(value)
if err != nil {
continue
}
want, ok := wanted[module][node]
if !ok || want.Removed {
continue
}
akey, err := asks.RevertAskKey(node, module, want.Fp)
if err != nil {
continue
}
reason := revertReason(have.Detail)
if have.Phase != nodeengineclient.PhaseHeld || reason == "" {
if have.Fp == want.Fp && p.w.has(buckets.Asks, akey) {
p.revertFinding(assign.RevertHeldFinding(node, module, "", "", ""), false) // the reverted build runs
}
continue
}
taken[node+"."+module] = true
kept, form, ok := p.revertCrosses(ctx, node, module, want.Fp)
if !ok {
continue
}
current, currentAsk, there := assign.DecodeAsk(akey, p.w.read(buckets.Asks)[akey])
if there != nil {
now := p.clock()
a := assign.RevertAsk(akey, node, module, want.Fp, kept, form, have.Detail, 0, now)
p.put(ctx, buckets.Asks, akey, func() ([]byte, error) { return assign.EncodeAsk(akey, a) })
p.revertFinding(assign.RevertHeldFinding(node, module, kept, form, reason), true)
continue
}
p.revertFinding(assign.RevertHeldFinding(node, module, kept, form, reason), true)
answer, answered := p.w.answer(akey)
if router, _, _ := strings.Cut(answer.Warrant, "@"); !answered || router != currentAsk.ID || answer.Option != assign.OptionRevertLive {
continue // not answered, or answered hold: the hold stands and nothing is written
}
words := assign.RevertAnswerWords(answer.Option, current.Asking)
if want.Revert != mwc.RevertLive || want.Words != words {
want.Revert, want.Words = mwc.RevertLive, words
p.putWant(ctx, node, module, want)
}
}
return taken
}
// revertCrosses reads whether the want of module on node at wantFp is a revert across a kept data's form: the kept data's names
// and the want's forms of them, said as the ask and the finding name them. ok is false while it is not known (the entries not
// answered yet, a pass with the assignment graph not kept) or when the want is no revert across a form.
func (p *passer) revertCrosses(ctx context.Context, node, module, wantFp string) (kept, form string, ok bool) {
if p.graph == nil || p.entries == nil {
return "", "", false
}
run, heard := p.w.runningOf(node, module)
if !heard || run.Stopped || run.Fingerprint == "" {
return "", "", false
}
reverted, err := assigngraph.Reverted(ctx, p.graph.objects, node, module, run.Fingerprint, wantFp)
if err != nil {
p.logf("whether the want of %s on %s at %s is a revert is not read: %v", module, node, wantFp, err)
return "", "", false
}
if !reverted {
return "", "", false
}
changes, known := p.formChange(ctx, module, run.Fingerprint, wantFp)
if !known {
return "", "", false // asked: its answer wakes a pass
}
var names, forms []string
for _, c := range changes {
f := 0
if c.Applied != nil {
f = c.Applied.Form
}
names, forms = append(names, c.Name), append(forms, strconv.Itoa(f))
}
if len(names) == 0 {
return "", "", false
}
return strings.Join(names, ", "), strings.Join(forms, ", "), true
}
// revertFinding writes the finding revert-held, or clears it, once per process each way.
func (p *passer) revertFinding(o assign.Observed, raised bool) {
key := views.FindingKey(o.Scope, assign.KeyID(o.ID), o.Kind)
said := key + ".raised"
if !raised {
said = key + ".cleared"
}
if p.said[said] {
return
}
var err error
switch {
case raised && p.findings.Put != nil:
err = p.findings.Put(o.Finding(p.clock()))
case !raised && p.findings.Delete != nil:
err = p.findings.Delete(key)
default:
return
}
if err != nil {
p.logf("the finding %s is not written: %v", key, err)
return
}
p.said[said] = true
delete(p.said, map[bool]string{true: key + ".cleared", false: key + ".raised"}[raised])
}
// formChange reads the kept data whose form differs between module's entry at runFp and at wantFp (design 47 G §295 to §298, A-217), as
// the catalog seat's verb `entry` answers the two entries; known is false while either is asked, and woken by its answer.
func (p *passer) formChange(ctx context.Context, module, runFp, wantFp string) (changes []catalogue.FormChange, known bool) {
if p.entries == nil {
return nil, false
}
running, rs := p.entries.Need(ctx, runFp)
applied, as := p.entries.Need(ctx, wantFp)
if rs == mcc.EntryAsked || as == mcc.EntryAsked {
return nil, false
}
if rs != mcc.EntryAnswered || as != mcc.EntryAnswered {
return nil, true // the catalog holds no such entry: no kept data's form is read from it, so none changes
}
changes, err := assigngraph.FormChanges(running, applied)
if err != nil {
p.logf("the forms of %s's kept data between %s and %s are not read: %v", module, runFp, wantFp, err)
return nil, false
}
return changes, true
}
@@ -0,0 +1,136 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
mdc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assigngraph"
"mesh-delivery-assigner/internal/buckets"
)
// Design 47 G §300, §710, A-217: a node holding a revert with no restore point gets the revert ask, keyed by the reverted build,
// and the finding revert-held, never a backup ask; hold writes no want; live writes the want with `revert` live and its words;
// the finding clears once the reverted build runs; a newer want takes the ask away and clears the finding.
func TestAHeldRevertIsAskedAndALiveAnswerIsWrittenIntoTheWant(t *testing.T) {
ctx := context.Background()
at := time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC)
fpOld := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64)
asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}}
p := newPasser(kvSet{buckets.Asks: asksKV, buckets.Wants: wantsKV}, at)
p.logf = t.Logf
findings := map[string][]byte{}
p.findings = assign.Findings{Put: putInto(findings), Delete: func(k string) error { delete(findings, k); return nil }}
// Two assignment graph revisions: web on ace at fpOld, then at fpTest, which it runs.
objects := memObjects{}
for i, fp := range []string{fpOld, fpTest} {
half := meshcatalogclient.Half{Revision: meshcatalog.CatalogRevision{Branch: trunkBranch, Kind: "trunk", Revision: int64(i + 1), Source: int64(i + 1),
Entries: []meshcatalog.EntryRef{{Module: "web", Digest: "sha256:" + strings.Repeat(string(rune('c'+i)), 64)}}},
Entries: map[string]meshcatalog.CatalogEntry{"web": {Module: "web", Fp: fp}}}
in := assigngraph.Inputs{Halves: map[string]meshcatalogclient.Half{trunkBranch: half}, FirstBranch: func(string) string { return trunkBranch },
Placements: map[string]meshmoduleassigner.Assignments{"ace": {Node: "ace", Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}}}}
if _, _, err := assigngraph.Keep(ctx, objects, assigngraph.Compute(in), "test", at); err != nil {
t.Fatal(err)
}
}
p.graph = &graphKeeper{objects: objects}
// The entries by fingerprint, as the catalog seat's verb entry answers them: the kept data sessions at form 1, then form 2.
entry := func(fp string, form int) meshcatalog.CatalogEntry {
return meshcatalog.CatalogEntry{Module: "web", Fp: fp, Manifest: map[string]any{"module": "web",
"state": []any{map[string]any{"name": "sessions", "form": form}}}}
}
answered := make(chan struct{}, 4)
p.entries = meshcatalogclient.NewEntries(func(_ string, body any) (json.RawMessage, error) {
in := body.(meshcatalog.EntryInput)
return json.Marshal(meshcatalog.EntryAnswer{Entry: map[string]meshcatalog.CatalogEntry{fpOld: entry(fpOld, 1), fpTest: entry(fpTest, 2)}[in.Fingerprint]})
})
p.entries.Changed = func(meshcatalogclient.EntryRow) { answered <- struct{}{} }
p.w.running["ace.web"] = nodeengine.Running{Node: "ace", Module: "web", Fingerprint: fpTest}
wkey, _ := mwc.WantKey("ace", "web")
want := mwc.Want{Fp: fpOld, Self: true}
raw, _ := mwc.EncodeWant(want)
p.w.put(buckets.Wants, wkey, raw)
hkey, _ := nodeengineclient.HaveKey("ace", "web")
have := func(fp, phase, detail string) {
raw, err := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Phase: phase, Detail: detail, Health: nodeengineclient.HealthOK})
if err != nil {
t.Fatal(err)
}
p.w.put(buckets.Have, hkey, raw)
}
have(fpTest, nodeengineclient.PhaseHeld, "restore point missing: web's sessions has none of form 1 taken before "+fpOld)
pass := func() { p.backups(ctx, p.reverts(ctx)) }
pass() // the entries are asked; nothing is asked of the operator until they answer
for range 2 {
select {
case <-answered:
case <-time.After(10 * time.Second): // a bound on a failure only: the answers come at once
t.Fatalf("the entries were not asked: asks %v", asksKV.values)
}
}
pass()
rkey, _ := asks.RevertAskKey("ace", "web", fpOld)
bkey, _ := asks.BackupAskKey("ace", "web", fpOld)
if !p.w.has(buckets.Asks, rkey) || p.w.has(buckets.Asks, bkey) {
t.Fatalf("asks %v: want the revert ask %s and no backup ask", asksKV.values, rkey)
}
_, ask, err := assign.DecodeAsk(rkey, p.w.read(buckets.Asks)[rkey])
if err != nil || !strings.Contains(ask.Explanation, "sessions") || !strings.Contains(ask.Explanation, "form 1") {
t.Fatalf("the revert ask = %+v, %v", ask, err)
}
fkey := "delivery.ace.web.revert-held"
if _, raised := findings[fkey]; !raised {
t.Fatalf("findings %v: want %s", findings, fkey)
}
answer := func(option string) {
v, _ := mdc.EncodeAnswer(mdc.Answer{Option: option, Warrant: ask.ID + "@2026-11-12T10:05:00Z", Words: "the operator"})
p.w.put(buckets.Answers, rkey, v)
}
puts := wantsKV.puts
answer(assign.OptionRevertHold)
pass()
if wantsKV.puts != puts {
t.Fatal("an answer hold wrote a want")
}
answer(assign.OptionRevertLive)
pass()
got, err := mwc.DecodeWant(p.w.read(buckets.Wants)[wkey])
if err != nil || got.Fp != fpOld || got.Revert != mwc.RevertLive || got.Words != `the operator answered "live" to the revert ask (asking 1)` {
t.Fatalf("the want after live = %+v, %v", got, err)
}
have(fpOld, nodeengineclient.PhaseRunning, "") // the reverted build runs: the finding clears
pass()
if _, raised := findings[fkey]; raised {
t.Fatal("the finding stands while the reverted build runs")
}
// A newer want replaces the held revert: the ask goes and the finding with it.
have(fpTest, nodeengineclient.PhaseHeld, "restore point damaged: the point does not read")
pass()
if _, raised := findings[fkey]; !raised {
t.Fatal("the finding is not raised again for a hold again")
}
raw, _ = mwc.EncodeWant(mwc.Want{Fp: fpTest, Self: true})
p.w.put(buckets.Wants, wkey, raw)
p.staleAsks(ctx, map[string]meshcatalog.ModulesByModuleValue{"web": {Fp2: fpTest}})
if _, raised := findings[fkey]; raised || p.w.has(buckets.Asks, rkey) {
t.Fatalf("a newer want left the ask (%v) or the finding (%v)", p.w.has(buckets.Asks, rkey), raised)
}
}
@@ -348,7 +348,7 @@ func (s *putTools) tools() []stdio.Tool {
"rules write the machine's want again from their next pass. Nothing changes on the machine until they move it.",
Input: schema(map[string]string{"node": "the machine", "module": "the module", "why": "why, kept with the put",
"how": "with how, the wait of the delivery on an outside cause (assigner_waits) is released, not a put: go-on (the module counts healthy there), " +
"failed (the failing check is the delivery's) or rejudge (judge it again now); written in the hand-act log first",
"failed (the failing check is the delivery's), rejudge (judge it again now), or now (the entry waiting for an acting part's new version moves with the running one); written in the hand-act log first",
"step": "delivery: the module's wait for its dependency's delivery (assigner_waits) is released, its want written now; no machine is named; written in the hand-act log first"},
[]string{"module", "why"}),
Run: func(a map[string]any) (any, error) { return s.doRelease(a, stdio.CallerOf(a)) }},
@@ -47,6 +47,8 @@ type world struct {
delivered deliveredVersions
// edges are, by watched branch and module, the edges into the module's artifacts its catalog entry names (§374's kinds).
edges map[string]map[string][]meshcatalog.Edge
// taken is the newest source revision of each watched branch the indexer's take-in.taken named, as heard (orderwaits.go, §298, A-260).
taken takenSources
// assignments is the seat's state assignments assembled node by node from its keys in either form (mesh-sdk
// mmac.Keys; research 072 row 37): the one reader of its keys in the assigner.
assignmentKeys *mmac.Keys
+1
View File
@@ -31,6 +31,7 @@ require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-000101
require (
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/meshbroker v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
@@ -0,0 +1,140 @@
// Package acting is the assigner's wait for an acting part's new version (design 47 G §266, §268, §276): an entry of the assignment
// graph whose edge of kind acting names a step the assigner holds (composing, placing) waits until the acting part's version runs
// on the node of the edge, with the bound of the rule that gave the edge, or the step's default. The package holds the rules and
// the kept record; the assigner's process reads what runs, says the events and keeps the records in its state `waits`.
package acting
import (
"encoding/json"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// HowNow is the `how` of assigner_release for a wait for an acting part: the entry is wanted now, with the version running.
const HowNow = "now"
// The ways a wait ends, as `wait-ended` says them.
const (
HowRan = "ran"
HowReleased = "released"
HowReplaced = "replaced"
)
// Prefix opens the key of an acting wait in the state, apart from the outside waits' `<node>.<module>.<fingerprint hex>`.
const Prefix = "acting."
// OrderPrefix opens the key of an order wait (design 47 G §298, A-259): a want held for the order of a delivery that crosses a form change,
// kept beside the acting waits in the state `waits`, as `order.<node>.<module>.<part>`.
const OrderPrefix = "order."
// The three order edges of §298, as a kept wait's Order names them.
const (
// OrderBackup: the new versions of the node-backup holder and of the holder of the bus module's seat run before a module whose kept
// data's form changes is moved.
OrderBackup = "backup-and-broker-first"
// OrderEngineLast: at a revert a node's node-engine goes back last on that node.
OrderEngineLast = "node-engine-last"
// OrderTakenIn: the reverted assigner and gate are wanted only once the reverted indexer took in the trunk's head.
OrderTakenIn = "taken-in-first"
)
// Held says whether the assigner holds the entries of step: composing and placing (§276's table).
func Held(step string) bool { return step == seatdef.StepCompose || step == seatdef.StepPlace }
// Wait is one wait as kept: Module's entry on Node waits for Part's version Fp to run on PartNode, in Step.
type Wait struct {
V int `json:"v"`
Step string `json:"step"`
Node string `json:"node"`
Module string `json:"module"`
Part string `json:"part"`
PartNode string `json:"part-node"`
Fp string `json:"fp"`
Entry string `json:"entry"`
Since time.Time `json:"since"`
ClockFrom time.Time `json:"clock-from"`
Bound string `json:"bound"`
// Released is a release by hand: the entry moves on with the part's old version; EndSaid says wait-ended was said for it.
// Order is which of §298's edges the wait stands on; empty for an acting wait.
Order string `json:"order,omitempty"`
Released bool `json:"released,omitempty"`
EndSaid bool `json:"end-said,omitempty"`
By string `json:"by,omitempty"`
Why string `json:"why,omitempty"`
}
// Key is the key of a wait in the state.
func Key(node, module, part string) string { return Prefix + node + "." + module + "." + part }
// OrderKey is the key of an order wait in the state.
func OrderKey(node, module, part string) string {
return OrderPrefix + node + "." + module + "." + part
}
// IsOrder says a kept value is an order wait's record.
func IsOrder(key string) bool { return strings.HasPrefix(key, OrderPrefix) }
// Is says a kept value is an acting wait's record.
func Is(key string) bool { return strings.HasPrefix(key, Prefix) }
// Encode is w as JSON.
func Encode(w Wait) ([]byte, error) { w.V = 1; return json.Marshal(w) }
// Decode reads a kept wait.
func Decode(raw []byte) (Wait, error) {
var w Wait
if err := json.Unmarshal(raw, &w); err != nil {
return w, err
}
if w.V != 1 || w.Step == "" || w.Node == "" || w.Module == "" || w.Part == "" {
return w, fmt.Errorf("acting: %q is not a kept acting wait", raw)
}
return w, nil
}
// BoundOf is the bound of the edge's wait: its rule's, else its step's default (seatdef.ActingRule.Wait).
func BoundOf(e meshwants.Edge) time.Duration {
return seatdef.ActingRule{Step: e.Step, Bound: e.Bound}.Wait()
}
// Pending are the acting edges of the assigner's steps whose part's version does not run yet: runs says the edge's part runs
// its version on the edge's node.
func Pending(edges []meshwants.Edge, runs func(e meshwants.Edge) bool) []meshwants.Edge {
var out []meshwants.Edge
for _, e := range edges {
if e.Kind == "acting" && Held(e.Step) && !runs(e) {
out = append(out, e)
}
}
return out
}
// Body is the wait as the seat mesh-wants says it.
func Body(w Wait, owner string) meshwants.Wait {
since, from := w.Since.UTC(), w.ClockFrom.UTC()
return meshwants.Wait{Step: w.Step, Owner: owner, Node: w.Node, Module: w.Module, Part: w.Part, Fp: w.Fp, Since: since, ClockFrom: &from, Bound: w.Bound,
Words: words(w)}
}
func words(w Wait) string {
switch w.Order {
case OrderBackup:
return fmt.Sprintf("%s on %s moves its kept data across a form change: it waits in %s for %s at %s to run on %s, which the restore point needs", w.Module, w.Node, w.Step, w.Part, short(w.Fp), w.PartNode)
case OrderEngineLast:
return fmt.Sprintf("%s on %s goes back last on its node: it waits in %s for %s at %s to run on %s, restored", w.Module, w.Node, w.Step, w.Part, short(w.Fp), w.PartNode)
case OrderTakenIn:
return fmt.Sprintf("%s on %s goes back only once the indexer took in the trunk's head: it waits in %s for source revision %s to be taken in", w.Module, w.Node, w.Step, w.Fp)
}
return fmt.Sprintf("%s on %s waits in %s for %s at %s to run on %s", w.Module, w.Node, w.Step, w.Part, short(w.Fp), w.PartNode)
}
func short(fp string) string {
if len(fp) > 19 {
return fp[:19]
}
return fp
}
@@ -200,6 +200,54 @@ const (
OptionBackupSkip = mwc.BackupSkip
)
// The options of the revert ask (design 47 G §710): keep the revert held, or start the reverted build on the live data.
const (
OptionRevertHold = "hold"
OptionRevertLive = mwc.RevertLive
)
// KindRevertHeld is the finding of a held revert (design 47 G §710), scope delivery, id `<node>.<module>`.
const KindRevertHeld = "revert-held"
// RevertAsk is the ask about a revert of module on node to the build fp that the node holds with no restore point (design 47 G
// §710, ADR 0539 §16): keep it held, or start the reverted build on the live data as it is. kept names the kept data whose form
// the revert crosses, form its form, and words are the node's reason in its own words, from its have; asking is the number of
// the asking, 0 the first, as the backup ask's.
func RevertAsk(key, node, module, fp, kept, form, words string, asking int, now time.Time) asks.Ask {
return assignerAsk(asking, oc.Ask{
ID: meshdeliveryclient.AskingID(key, asking),
Headline: fmt.Sprintf("The revert of %s on %s has no restore point", module, node),
Explanation: fmt.Sprintf("The revert of %s on %s to %s takes its kept data %s back across form %s, and the node holds it: %s. "+
"%s keeps running its newer build until you answer. Started on the live data, the older build reads data written in the newer form.",
module, node, short(fp), kept, form, words, module),
Options: []oc.Option{
{ID: OptionRevertHold, Label: "Keep it held", Does: "the node keeps running the newer build, and the revert waits", Level: occ.Acknowledge},
{ID: OptionRevertLive, Label: "Start on the live data", Does: "the node starts the older build on the live data as it is, with nothing restored", Level: occ.Acknowledge},
},
Who: occ.Operator,
Expires: occ.FormatTime(now.Add(AskLasts)),
OnExpiry: "the module keeps running its newer build",
About: key,
})
}
// RevertAnswerWords are the words a want written again on the operator's answer to the revert ask carries (design 47 G §710).
func RevertAnswerWords(option string, asking int) string {
return fmt.Sprintf("the operator answered %q to the revert ask (asking %d)", option, asking+1)
}
// RevertHeldFinding is the finding `revert-held` of a revert the node holds with no restore point (design 47 G §710): scope
// delivery, id `<node>.<module>`, a warning naming the reason, the kept data and its form, and needing nothing, so the
// operator's one message is the ask.
func RevertHeldFinding(node, module, kept, form, reason string) Observed {
return Observed{Scope: "delivery", ID: node + "." + module, Kind: KindRevertHeld, Machine: node, Severity: "warning",
Summary: fmt.Sprintf("The revert of %s on %s is held: %s (%s, form %s); the ask about it waits for your answer", module, node, reason, kept, form),
Headline: fmt.Sprintf("The revert of %s on %s is held", module, node),
Explanation: fmt.Sprintf("The node holds the revert of %s: %s (%s, form %s). %s keeps running its newer build until the ask is answered.",
module, reason, kept, form, module),
Needs: "Nothing for you to do.", Source: "assigner." + KindRevertHeld}
}
// RecordAsk is the ask about a move of a module of policy record on node to the composition with the digest, judged by the
// controller to recreate a container, to move data, or not to be judgeable (ADR 0539 §14). The want names the composition only
// after the answer yes. The way the move was judged is in the ask. asking is the writing's number (AskingSeconds), so a key taken
@@ -42,6 +42,7 @@ func anAskEndedAtTheChannelIsWrittenAgain(t *testing.T) {
holdKey, _ := asks.HoldAskKey("nats", fNew)
restoreKey, _ := asks.RestoreAskKey("novox", "nats", fNew)
backupKey, _ := asks.BackupAskKey("novox", "nats", fNew)
revertKey, _ := asks.RevertAskKey("novox", "nats", fNew)
recordKey, _ := asks.RecordAskKey("novox", "nats", digest)
assignmentsKey, _ := asks.AssignmentsAskKey("p-20261028t183000-1a2b3c")
need := RecordNeed{Node: "novox", Digest: digest, Judgement: JudgedMovesData}
@@ -64,6 +65,9 @@ func anAskEndedAtTheChannelIsWrittenAgain(t *testing.T) {
{name: "hold", key: holdKey, a: ptr(HoldAsk(holdKey, "nats", fNew, "novox", "w", AskingSeconds(asked), asked)), outcome: occ.OutcomeExpired},
{name: "restore", key: restoreKey, a: ptr(RestoreAsk(restoreKey, "novox", "nats", fNew, "w", AskingSeconds(asked), asked)), outcome: occ.OutcomeCancelled},
{name: "backup", key: backupKey, a: ptr(BackupAsk(backupKey, "novox", "nats", fNew, digest, "w", 0, asked)), outcome: occ.OutcomeCancelled},
{name: "revert", key: revertKey, a: ptr(assignerAsk(AskingSeconds(asked), oc.Ask{ID: meshdeliveryclient.AskingID(revertKey, AskingSeconds(asked)),
Headline: "h", Explanation: "e", Who: occ.Operator, Expires: occ.FormatTime(asked.Add(AskLasts)), OnExpiry: "o",
Options: []oc.Option{{ID: "hold", Label: "Hold", Does: "d", Level: occ.Acknowledge}}})), outcome: occ.OutcomeExpired},
{name: "record", key: recordKey, a: &record, outcome: occ.OutcomeCancelled},
{name: "record of another put", key: recordKey, a: &record, outcome: occ.OutcomeExpired, put: &other},
{name: "record a put asked", key: recordKey, a: &byPut, outcome: occ.OutcomeExpired, put: &put, why: "put"},
@@ -333,7 +337,12 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) {
}
dgst := "sha256:" + strings.Repeat("b", 64)
b := BackupAsk(bkey, "ace", "mail", fNew, dgst, "no space left", 0, now)
for key, a := range map[string]asks.Ask{rkey: r, bkey: b} {
vkey, err := asks.RevertAskKey("ace", "mail", fNew)
if err != nil {
t.Fatal(err)
}
v := RevertAsk(vkey, "ace", "mail", fNew, "records", "3", "restore point missing: none of form 3", 0, now)
for key, a := range map[string]asks.Ask{rkey: r, bkey: b, vkey: v} {
if err := occ.Check(mustAsk(t, a), now); err != nil {
t.Errorf("%s: the router refuses the ask: %v", key, err)
}
@@ -363,6 +372,22 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) {
if BackupAnswerWords("again", 0) == BackupAnswerWords("again", 1) {
t.Errorf("two answers of the same option are one want value")
}
// design 47 G §710: the revert ask names the build, the kept data, its form and the node's words, and offers hold and live;
// its finding is delivery.<node>.<module>.revert-held, a warning that needs nothing, the ask being the one message.
if va := mustAsk(t, v); va.Headline != "The revert of mail on ace has no restore point" || va.Options[0].ID != OptionRevertHold ||
va.Options[1].ID != mwc.RevertLive || len(va.Options) != 2 || va.OnExpiry != "the module keeps running its newer build" ||
!strings.Contains(va.Explanation, "records") || !strings.Contains(va.Explanation, "form 3") || !strings.Contains(va.Explanation, short(fNew)) ||
!strings.Contains(va.Explanation, "Started on the live data, the older build reads data written in the newer form.") {
t.Errorf("the revert ask = %+v", va)
}
if f := RevertHeldFinding("ace", "mail", "records", "3", "restore point missing").Finding(now); views.FindingKey(f.Scope, f.ID, f.Kind) != "delivery.ace.mail.revert-held" ||
f.Severity != "warning" || f.Needs != "Nothing for you to do." ||
f.Summary != "The revert of mail on ace is held: restore point missing (records, form 3); the ask about it waits for your answer" {
t.Errorf("the revert finding = %+v", f)
}
if RevertAnswerWords(OptionRevertLive, 1) != `the operator answered "live" to the revert ask (asking 2)` {
t.Errorf("the words of a live answer: %q", RevertAnswerWords(OptionRevertLive, 1))
}
c := BackupCondition("ace", "mail", fNew, "no space left", true)
if _, err := occ.EncodeCondition(c); err != nil {
t.Errorf("the backup condition is refused: %v", err)
@@ -18,7 +18,7 @@ import (
)
// AskLasts is how long an ask of the assigner's rules stands at the operator channel before it lapses unanswered: the hold,
// restore, record and backup asks.
// restore, record, backup, outside and revert asks.
const AskLasts = 7 * 24 * time.Hour
// writtenAgainOnEnd is, per kind of the assigner's asks, whether an asking that ended at the channel without an answer while
@@ -31,6 +31,7 @@ var writtenAgainOnEnd = map[string]string{
asks.AskKindBackup: "",
asks.AskKindOutside: "", // raised again at once while the wait stands (design 47 G §706); the wait's end takes the key away
asks.AskKindRecord: "",
asks.AskKindRevert: "", // raised again at once while the hold stands (design 47 G §710); one answered hold is not asked again
asks.AskKindAssignments: "a proposal's authorising ask that lapsed ends the proposal (ADR 0567 §5); the verb again asks it again (§11)",
}
@@ -101,6 +101,36 @@ type Module struct {
// MachineHeld says whether the node's own network condition is open, which holds the machine as a whole: no want moves to it
// until that clears (design 47 G §706 *M's own network*). Nil: none is held.
MachineHeld func(node string) bool
// Acting are the acting edges of the assignment graph entry of this module on node that the assigner holds (composing, placing)
// and whose wait was not released by hand (design 47 G §266, §268): the want moves only once each acting part's version runs
// on the edge's node, as Nodes.Running says it. Nil: none.
Acting func(node string) []ActingEdge
}
// ActingEdge is one acting edge: Part's version Fp acts in Step on Node, before the entry moves.
type ActingEdge struct{ Node, Part, Fp, Step string }
// Runs says the part's version of the edge runs on its node, as the node-engine seat's `running` says it.
func (n Nodes) Runs(e ActingEdge) bool {
if n.Running != nil {
r, ok := n.Running[e.Node][e.Part]
return ok && !r.Stopped && r.Fingerprint == e.Fp
}
h, ok := n.Haves[e.Node][e.Part]
return ok && healthy(h, e.Fp)
}
// actingWaiting says why the module's want on node waits for an acting part's new version (§266): the first edge not run.
func (m Module) actingWaiting(node string, n Nodes) (string, bool) {
if m.Acting == nil {
return "", false
}
for _, e := range m.Acting(node) {
if !n.Runs(e) {
return fmt.Sprintf("%s on %s waits in %s for %s at %s to run on %s", m.Name, node, e.Step, e.Part, short(e.Fp), e.Node), true
}
}
return "", false
}
// WantParts is the capability a node-engine of G states (ADR 0614 §4, design 47 G §525): it reads a want's `parts` and `kept-on`.
@@ -219,6 +249,9 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) {
if _, ok := m.composition(node, m.FP); !ok {
return false, mwc.RuleComposed, fmt.Sprintf("%s has named no composition of its own for %s at %s yet", node, m.Name, short(m.FP))
}
if words, waiting := m.actingWaiting(node, n); waiting {
return false, mwc.RuleComposed, words
}
if words, waiting := m.provisionWaiting(node); waiting {
return false, mwc.RuleProvisions, words
}
@@ -1678,3 +1678,28 @@ func TestRuleOneSaysHowTheBuildStands(t *testing.T) {
}
}
}
// Design 47 G §266, §268 (AS9): an entry whose acting edge names a step the assigner holds waits, its want held, until the acting part's
// version runs on the edge's node, as the node-engine seat's `running` says it; then the want moves.
func TestAWantWaitsForItsActingPartsNewVersion(t *testing.T) {
edge := ActingEdge{Node: "g14", Part: "mesh-host", Fp: fNew, Step: "compose"}
run := func(fp string, stopped bool) Nodes {
_, n := ready()
n.Running = map[string]map[string]nodeengine.Running{"g14": {"mesh-host": {Fingerprint: fp, Stopped: stopped}}}
return n
}
m, _ := ready()
m.Acting = func(string) []ActingEdge { return []ActingEdge{edge} }
m.Nodes = []string{"g14"}
out := Wants(m, run(fOld, false), wantedOld(), nil)
if out.Wants["g14"].Fp == fNew || out.Rules["g14"].Rule != mwc.RuleComposed || !strings.Contains(out.Rules["g14"].Words, "waits in compose for mesh-host") {
t.Fatalf("the old version runs: want %+v rule %+v", out.Wants["g14"], out.Rules["g14"])
}
if out := Wants(m, run(fNew, false), wantedOld(), nil); out.Wants["g14"].Fp != fNew {
t.Errorf("the new version runs: want %+v", out.Wants["g14"])
}
m.Acting = func(string) []ActingEdge { return nil } // a released wait is no edge
if out := Wants(m, run(fOld, false), wantedOld(), nil); out.Wants["g14"].Fp != fNew {
t.Errorf("released: want %+v", out.Wants["g14"])
}
}
@@ -17,6 +17,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"mesh-delivery-assigner/internal/assignment"
)
@@ -24,6 +25,7 @@ import (
// The kinds of an edge of the assignment graph (ADR 0656; the seat's Edge).
const (
EdgeProvisioning = "provisioning"
EdgeContribution = "contribution"
EdgeActing = "acting"
)
@@ -38,6 +40,9 @@ type Inputs struct {
FirstBranch func(module string) string
// Capabilities is each node's capability reading, by its digest.
Capabilities []meshwants.AssignmentRevisionCapabilitiesItem
// Definitions are the seat definitions read whole through the seat mesh-catalog's verb seat-definitions, by seat: of
// them, those an entry's catalog graph revision names in its definitions say which seat delivers a requirement (§708).
Definitions map[string]seatdef.Definition
}
// Computed is one assignment graph revision as computed, before it is numbered and kept.
@@ -48,6 +53,8 @@ type Computed struct {
// Uncomputed is each placed module whose entry could not be computed, as "<node>/<module>: why", sorted: it is said,
// never left out in silence.
Uncomputed []string
// Cycles are the cycles among the waits within one step (Cycles, design 47 G §272): said, never left in silence.
Cycles []string
}
// placedAt is one placed module with what its entry is computed from.
@@ -58,8 +65,12 @@ type placedAt struct {
entry meshwants.AssignmentEntry
langs map[string]bool
acts []actingRule
// provides is what its catalog entry provides; open its requirements its placement neither binds nor its node pins.
provides, open []string
// provides is what its catalog entry provides; contributes the requirements it carries contributed values to (§709).
provides, contributes []string
// delivering is, by requirement, the seat that delivers it among the definitions its revision names (§708).
delivering map[string]seatdef.Definition
// optional says a requirement is one it runs without (ADR 0330 §3), by the entry's manifest (A-229).
optional func(string) bool
}
type actingRule struct{ seat, step, on, bound string }
@@ -118,49 +129,42 @@ func Compute(in Inputs) Computed {
for _, r := range ce.Acts {
p.acts = append(p.acts, actingRule{seat: r.Seat, step: r.Step, on: r.On, bound: r.Bound})
}
p.provides = ce.Provides
for _, r := range ce.Requires {
prov, ok := m.Bindings[r]
if !ok {
prov, ok = in.Placements[node].Pins[r]
}
if ok {
p.entry.Edges = append(p.entry.Edges, meshwants.Edge{Kind: EdgeProvisioning, Node: prov.Node, Module: prov.Module, Provision: r})
} else {
p.open = append(p.open, r)
p.provides, p.contributes = ce.Provides, ce.Contributes
p.delivering = map[string]seatdef.Definition{}
for _, d := range half.Revision.Definitions {
if def, ok := in.Definitions[d.Seat]; ok && def.Delivers != "" {
if _, had := p.delivering[def.Delivers]; !had {
p.delivering[def.Delivers] = def
}
}
}
p.optional = func(string) bool { return false }
if man, err := manifestOf(ce); err == nil {
p.optional = man.IsOptional
}
placed = append(placed, p)
branches[branch] = true
}
}
// A requirement neither bound nor pinned points, by design 27's order (design 47 G §357), to the provider on the
// consumer's node, else to the only provider; otherwise the entry is held, naming the candidates, and nothing is guessed.
// Each requirement points, by design 27's order (design 47 G §357, §708), to the named seat's holder, else a pin, else
// the provider on the consumer's node, else the only provider; otherwise the entry is held, naming the candidates, and
// nothing is guessed. A requirement contributes names gets a contribution edge to the same provider (§709).
holders := assignment.SeatHoldersOf(in.Placements)
for _, p := range placed {
for _, r := range p.open {
var here, all []*placedAt
for _, q := range placed {
if q != p && slices.Contains(q.provides, r) {
all = append(all, q)
if q.node == p.node {
here = append(here, q)
}
}
}
ce := in.Halves[p.branch].Entries[p.placed.Module]
for _, r := range ce.Requires {
prov, held := resolve(p, r, placed, holders, in.Placements[p.node].Pins)
switch {
case len(here) == 1:
p.entry.Edges = append(p.entry.Edges, meshwants.Edge{Kind: EdgeProvisioning, Node: here[0].node, Module: here[0].placed.Module, Provision: r})
case len(here) == 0 && len(all) == 1:
p.entry.Edges = append(p.entry.Edges, meshwants.Edge{Kind: EdgeProvisioning, Node: all[0].node, Module: all[0].placed.Module, Provision: r})
default:
h := meshwants.Held{Provision: r}
for _, q := range all {
h.Candidates = append(h.Candidates, meshwants.Edge{Kind: EdgeProvisioning, Node: q.node, Module: q.placed.Module, Fp: q.entry.Fp, Provision: r})
case held != nil:
p.entry.Held = append(p.entry.Held, *held)
case prov.Module != "":
p.entry.Edges = append(p.entry.Edges, meshwants.Edge{Kind: EdgeProvisioning, Node: prov.Node, Module: prov.Module, Provision: r})
if slices.Contains(p.contributes, r) {
p.entry.Edges = append(p.entry.Edges, meshwants.Edge{Kind: EdgeContribution, Node: prov.Node, Module: prov.Module, Provision: r})
}
sortEdges(h.Candidates)
p.entry.Held = append(p.entry.Held, h)
}
}
slices.SortFunc(p.entry.Held, func(a, b meshwants.Held) int { return strings.Compare(a.Requirement, b.Requirement) })
}
at := map[string]*placedAt{}
for _, p := range placed {
@@ -212,6 +216,7 @@ func Compute(in Inputs) Computed {
rev.Entries = append(rev.Entries, meshwants.NodeEntryRef{Node: p.node, Module: p.placed.Module, Digest: d})
}
out.Revision = rev
out.Cycles = Cycles(slices.Collect(maps.Values(out.Entries)))
slices.Sort(out.Uncomputed)
return out
}
@@ -238,3 +243,57 @@ func Same(a, b meshwants.AssignmentRevision) bool {
return slices.Equal(a.Entries, b.Entries) && slices.Equal(a.Catalogs, b.Catalogs) && a.Placements == b.Placements &&
slices.Equal(a.Capabilities, b.Capabilities)
}
// resolve is the provider requirement r of p points to by §708's order, or the hold that says why none is: none and no
// hold for an optional requirement nothing provides (ADR 0330 §3).
func resolve(p *placedAt, r string, placed []*placedAt, holders map[string]mma.SeatHolders, pins map[string]mma.Provider) (mma.Provider, *meshwants.Held) {
// 1. The named seat's holder, from the placements' holds as the state seat-holders keeps them; a delivering seat no
// placement holds holds the entry, and no other step is tried.
if def, ok := p.delivering[r]; ok {
node := ""
if def.Scope == seatdef.ScopeNode {
node = p.node
}
key := def.Seat
if node != "" {
key += "." + node
}
if sh := holders[key]; len(sh.Holders) > 0 {
return mma.Provider{Node: sh.Holders[0].Node, Module: sh.Holders[0].Module}, nil
}
return mma.Provider{}, &meshwants.Held{Requirement: r, Seat: def.Seat, Candidates: []meshwants.HeldCandidate{}}
}
// 2. A pin: the placed module's bindings, else its node's pins.
if prov, ok := p.placed.Bindings[r]; ok {
return prov, nil
}
if prov, ok := pins[r]; ok {
return prov, nil
}
// 3. The provider on the node; 4. the only provider.
var here, all []*placedAt
for _, q := range placed {
if q != p && slices.Contains(q.provides, r) {
all = append(all, q)
if q.node == p.node {
here = append(here, q)
}
}
}
switch {
case len(here) == 1:
return mma.Provider{Node: here[0].node, Module: here[0].placed.Module}, nil
case len(here) == 0 && len(all) == 1:
return mma.Provider{Node: all[0].node, Module: all[0].placed.Module}, nil
case len(all) == 0 && p.optional(r):
return mma.Provider{}, nil
}
h := &meshwants.Held{Requirement: r, Candidates: []meshwants.HeldCandidate{}}
for _, q := range all {
h.Candidates = append(h.Candidates, meshwants.HeldCandidate{Node: q.node, Module: q.placed.Module})
}
slices.SortFunc(h.Candidates, func(a, b meshwants.HeldCandidate) int {
return strings.Compare(a.Node+"/"+a.Module, b.Node+"/"+b.Module)
})
return mma.Provider{}, h
}
@@ -0,0 +1,73 @@
package assigngraph
import (
"fmt"
"maps"
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
)
// Cycles are the cycles among the waits within one step (design 47 G §272, ADR 0656): an entry waits for its acting parts in the step an
// edge names, so two acting parts acting on each other in one step wait for each other for ever. Each is `<step>: a -> b -> a`, by
// node and module, sorted. Edges of two steps never close a cycle together: the indexer taking in the checker and the checker checking
// the indexer are none, and a holder's own inputs are no edge here (the graph gives an acting edge only from a part to the entries it
// acts on, never to what the part is built from).
func Cycles(entries []meshwants.AssignmentEntry) []string {
type key struct{ step, at string }
next := map[key][]string{}
steps := map[string]bool{}
for _, e := range entries {
for _, edge := range e.Edges {
if edge.Kind != EdgeActing {
continue
}
k := key{edge.Step, e.Node + "/" + e.Module}
next[k] = append(next[k], edge.Node+"/"+edge.Module)
steps[edge.Step] = true
}
}
var out []string
for _, step := range slices.Sorted(maps.Keys(steps)) {
const (
open = iota + 1
done
)
state := map[string]int{}
var path []string
var walk func(at string)
walk = func(at string) {
state[at] = open
path = append(path, at)
for _, to := range next[key{step, at}] {
switch state[to] {
case 0:
walk(to)
case open:
from := slices.Index(path, to)
cycle := slices.Clone(path[from:])
// start at the smallest member so one cycle is said once, whichever member is met first
lowest := slices.Index(cycle, slices.Min(cycle))
cycle = append(cycle[lowest:], cycle[:lowest]...)
out = append(out, fmt.Sprintf("%s: %s -> %s", step, strings.Join(cycle, " -> "), cycle[0]))
}
}
path = path[:len(path)-1]
state[at] = done
}
starts := map[string]bool{}
for k := range next {
if k.step == step {
starts[k.at] = true
}
}
for _, at := range slices.Sorted(maps.Keys(starts)) {
if state[at] == 0 {
walk(at)
}
}
}
slices.Sort(out)
return slices.Compact(out)
}
@@ -0,0 +1,36 @@
package assigngraph
import (
"slices"
"testing"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
)
// Design 47 G §272 (AS10): a cycle among the waits within one step is found; an acting part acting on itself's other module, two parts
// acting on each other in different steps, and a chain are none.
func TestACycleAmongTheWaitsWithinOneStepIsFound(t *testing.T) {
act := func(node, module, step string) meshwants.Edge {
return meshwants.Edge{Kind: EdgeActing, Node: node, Module: module, Step: step}
}
entry := func(module string, edges ...meshwants.Edge) meshwants.AssignmentEntry {
return meshwants.AssignmentEntry{Node: "ace", Module: module, Edges: edges}
}
for _, c := range []struct {
name string
entries []meshwants.AssignmentEntry
want []string
}{
{"two parts on each other in one step", []meshwants.AssignmentEntry{entry("a", act("ace", "b", "compose")), entry("b", act("ace", "a", "compose"))},
[]string{"compose: ace/a -> ace/b -> ace/a"}},
{"three in a ring, said once", []meshwants.AssignmentEntry{entry("c", act("ace", "a", "build")), entry("a", act("ace", "b", "build")), entry("b", act("ace", "c", "build"))},
[]string{"build: ace/a -> ace/b -> ace/c -> ace/a"}},
{"the indexer taking in the checker, the checker checking the indexer", []meshwants.AssignmentEntry{entry("checker", act("ace", "indexer", "take-in")), entry("indexer", act("ace", "checker", "check"))}, nil},
{"a chain", []meshwants.AssignmentEntry{entry("a", act("ace", "b", "compose")), entry("b", act("ace", "c", "compose")), entry("c")}, nil},
{"provisioning edges are no wait of a step", []meshwants.AssignmentEntry{entry("a", meshwants.Edge{Kind: EdgeProvisioning, Node: "ace", Module: "b"}), entry("b", meshwants.Edge{Kind: EdgeProvisioning, Node: "ace", Module: "a"})}, nil},
} {
if got := Cycles(c.entries); !slices.Equal(got, c.want) {
t.Errorf("%s: cycles %v, want %v", c.name, got, c.want)
}
}
}
@@ -13,6 +13,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
type memObjects map[string][]byte
@@ -98,7 +99,7 @@ func TestTheAssignmentGraphIsComputedFromItsInputsAndKept(t *testing.T) {
t.Errorf("web on laptop's edges: %+v", e)
}
// Its secrets no placed module provides: held on both nodes, naming no candidate.
if h := webA.Held; len(h) != 1 || h[0].Provision != "secrets" || len(h[0].Candidates) != 0 {
if h := webA.Held; len(h) != 1 || h[0].Requirement != "secrets" || len(h[0].Candidates) != 0 {
t.Errorf("web on anchor is held on %+v", h)
}
if e := byNode["anchor/postgres"].Edges; len(e) != 1 || e[0].Module != "node-engine" {
@@ -154,26 +155,48 @@ func TestTheAssignmentGraphIsComputedFromItsInputsAndKept(t *testing.T) {
}
}
// Design 47 G §357 and A-167: a requirement neither bound nor pinned points to the provider on the consumer's node, else
// to the only provider; otherwise the entry is held, naming the candidates, and nothing is guessed.
// Design 47 G §357, §708, §709 and A-167, A-229: a requirement points to the named seat's holder, else a pin, else the
// provider on the consumer's node, else the only provider; otherwise the entry is held, naming the candidates (and the
// unheld seat), and nothing is guessed. An optional requirement nothing provides is left absent; a requirement the
// manifest's contributes names gets a contribution edge to the same provider.
func TestARequirementPointsByTheOrderOrIsHeld(t *testing.T) {
h := half("main", 3,
meshcatalog.CatalogEntry{Module: "kv", Fp: "src2:k1", Provides: []string{"cache"}},
meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w1", Requires: []string{"cache"}})
for _, c := range []struct {
name string
kv []string // the nodes kv is placed on
pin bool // web's node pins kv on laptop
edge string // the node of the provisioning edge, "" for none
held string // the candidates' nodes of the hold, "-" for no hold
name string
kv []string // the nodes kv is placed on
pin bool // web's node pins kv on laptop
seat string // the seat delivering cache: "" none, "held" held by kv on server, "unheld" held by none
optional bool // web requires cache optionally
contributes bool // web's manifest contributes to cache
edge string // the node of the provisioning edge, "" for none
held string // the candidates' nodes of the hold, "-" for no hold
heldSeat string // the seat the hold names
}{
{name: "the provider on the consumer's node", kv: []string{"anchor", "laptop"}, edge: "anchor", held: "-"},
{name: "the only provider", kv: []string{"laptop"}, edge: "laptop", held: "-"},
{name: "a pin before the node's own", kv: []string{"anchor", "laptop"}, pin: true, edge: "laptop", held: "-"},
{name: "two providers elsewhere: held", kv: []string{"laptop", "server"}, held: "laptop,server"},
{name: "none: held naming none", held: ""},
{name: "the named seat's holder before the provider on the node", kv: []string{"anchor", "server"}, seat: "held", edge: "server", held: "-"},
{name: "a delivering seat no placement holds: held naming it", kv: []string{"anchor"}, seat: "unheld", held: "", heldSeat: "cache-seat"},
{name: "an optional requirement nothing provides: absent", optional: true, held: "-"},
{name: "an optional requirement with two providers elsewhere: held", optional: true, kv: []string{"laptop", "server"}, held: "laptop,server"},
{name: "a contributed requirement: a contribution edge too", kv: []string{"laptop"}, contributes: true, edge: "laptop", held: "-"},
} {
t.Run(c.name, func(t *testing.T) {
web := meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w1", Requires: []string{"cache"},
Manifest: map[string]any{"module": "web", "version": "1", "requires": []any{"cache"}}}
if c.optional {
web.Manifest["requires"] = []any{map[string]any{"name": "cache", "optional": true}}
}
if c.contributes {
web.Contributes = []string{"cache"}
}
h := half("main", 3, meshcatalog.CatalogEntry{Module: "kv", Fp: "src2:k1", Provides: []string{"cache"}}, web)
defs := map[string]seatdef.Definition{}
if c.seat != "" {
h.Revision.Definitions = []meshcatalog.CatalogRevisionDefinitionsItem{{Seat: "cache-seat", Digest: "sha256:d"}}
defs["cache-seat"] = seatdef.Definition{Seat: "cache-seat", Scope: seatdef.ScopeMesh, Delivers: "cache"}
}
pl := map[string]mma.Assignments{"anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}}}}
if c.pin {
pl["anchor"] = mma.Assignments{Node: "anchor", Modules: pl["anchor"].Modules, Pins: map[string]mma.Provider{"cache": {Node: "laptop", Module: "kv"}}}
@@ -183,29 +206,41 @@ func TestARequirementPointsByTheOrderOrIsHeld(t *testing.T) {
a.Node, a.Modules = n, append(a.Modules, mma.PlacedModule{Module: "kv"})
pl[n] = a
}
out := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": h}, Placements: pl, FirstBranch: func(string) string { return "main" }})
var web meshwants.AssignmentEntry
if c.seat == "held" {
a := pl["server"]
a.Holds = append(a.Holds, mma.Holding{Claim: "cache-seat", Module: "kv"})
pl["server"] = a
}
out := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": h}, Placements: pl, FirstBranch: func(string) string { return "main" },
Definitions: defs})
var got meshwants.AssignmentEntry
for _, ref := range out.Revision.Entries {
if ref.Node == "anchor" && ref.Module == "web" {
web = out.Entries[ref.Digest]
got = out.Entries[ref.Digest]
}
}
edge := ""
for _, e := range web.Edges {
edge, contribution := "", ""
for _, e := range got.Edges {
if e.Kind == EdgeProvisioning && e.Provision == "cache" {
edge = e.Node
}
if e.Kind == EdgeContribution && e.Provision == "cache" {
contribution = e.Node + "/" + e.Module
}
}
held := "-"
if len(web.Held) == 1 && web.Held[0].Provision == "cache" {
held, heldSeat := "-", ""
if len(got.Held) == 1 && got.Held[0].Requirement == "cache" {
var nodes []string
for _, cand := range web.Held[0].Candidates {
for _, cand := range got.Held[0].Candidates {
nodes = append(nodes, cand.Node)
}
held = strings.Join(nodes, ",")
held, heldSeat = strings.Join(nodes, ","), got.Held[0].Seat
}
if edge != c.edge || held != c.held || len(web.Held) > 1 {
t.Errorf("web points to %q, held on %q (%+v); want %q, %q", edge, held, web.Held, c.edge, c.held)
if edge != c.edge || held != c.held || heldSeat != c.heldSeat || len(got.Held) > 1 {
t.Errorf("web points to %q, held on %q seat %q (%+v); want %q, %q, %q", edge, held, heldSeat, got.Held, c.edge, c.held, c.heldSeat)
}
if want := map[bool]string{true: c.edge + "/kv"}[c.contributes]; contribution != want {
t.Errorf("web's contribution edge points to %q, want %q", contribution, want)
}
})
}
@@ -157,3 +157,29 @@ func Event(rev meshwants.AssignmentRevision) meshwants.AssignmentRevisionEvent {
return meshwants.AssignmentRevisionEvent{V: rev.V, Revision: rev.Revision, Kind: rev.Kind, Cause: rev.Cause, Group: rev.Group,
Heads: rev.Heads, Catalogs: rev.Catalogs, At: rev.At}
}
// Filler is an object bucket that says its fill: the bytes used and its cap in bytes (0: no cap known).
type Filler interface {
Fill(ctx context.Context) (used, capacity int64, err error)
}
// NewBytes is what keeping c next would add to the bucket: each entry object not held yet and the list (design 47 G §181).
func NewBytes(ctx context.Context, o Objects, c Computed) int64 {
names, err := o.Names(ctx)
if err != nil {
return 0
}
held := make(map[string]bool, len(names))
for _, n := range names {
held[n] = true
}
var size int64
for _, ref := range c.Revision.Entries {
if e, ok := c.Entries[ref.Digest]; ok && !held[EntryObject(ref.Digest)] {
data, _ := json.Marshal(e)
size += int64(len(data))
}
}
list, _ := json.Marshal(c.Revision)
return size + int64(len(list))
}
@@ -0,0 +1,81 @@
package assigngraph
import (
"context"
"encoding/json"
"fmt"
"slices"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Reverted says a want of module on node at wantFp is a revert (design 47 G §298, §300, A-217): the module had wantFp in an earlier
// assignment graph revision than the one it now runs from, by the revisions' own numbers. The revision it runs from is the first kept
// that lists runFp for the module on the node; a want is a revert when it was listed before that, and never listed since.
func Reverted(ctx context.Context, o Objects, node, module, runFp, wantFp string) (bool, error) {
if runFp == "" || wantFp == "" || runFp == wantFp {
return false, nil
}
names, err := o.Names(ctx)
if err != nil {
return false, fmt.Errorf("the objects %s: %w", RevisionsObjects, err)
}
var numbers []int64
for _, n := range names {
if r, ok := trunkOf(n); ok {
numbers = append(numbers, r)
}
}
slices.Sort(numbers)
var ranFrom, lastWanted int64
for _, r := range numbers {
rev, err := ReadRevision(ctx, o, r)
if err != nil {
return false, err
}
for _, ref := range rev.Entries {
if ref.Node != node || ref.Module != module {
continue
}
e, err := ReadEntry(ctx, o, ref.Digest)
if err != nil {
return false, err
}
switch e.Fp {
case runFp:
if ranFrom == 0 {
ranFrom = r
}
case wantFp:
lastWanted = r
}
}
}
return ranFrom != 0 && lastWanted != 0 && lastWanted < ranFrom, nil
}
// FormChanges are the kept data whose `form` differs between the running entry's manifest and the want's (design 47 G §295 to §298,
// A-217): the entry's manifest whole is read as a manifest and compared by catalogue.FormChanges, so a kept data's form is read from
// the entry's kept declarations (catalogue.KeptDeclarations), never from a field of the want.
func FormChanges(running, applied meshcatalog.CatalogEntry) ([]catalogue.FormChange, error) {
before, err := manifestOf(running)
if err != nil {
return nil, fmt.Errorf("the running manifest of %s: %w", running.Module, err)
}
after, err := manifestOf(applied)
if err != nil {
return nil, fmt.Errorf("the manifest of %s: %w", applied.Module, err)
}
return catalogue.FormChanges(before, after), nil
}
func manifestOf(e meshcatalog.CatalogEntry) (catalogue.Manifest, error) {
var m catalogue.Manifest
raw, err := json.Marshal(e.Manifest)
if err != nil {
return m, err
}
err = json.Unmarshal(raw, &m)
return m, err
}
@@ -0,0 +1,49 @@
package assigngraph
import (
"context"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
)
// Design 47 G §298, §300, A-217 (AS17): a want is a revert when the module had its fingerprint in an earlier assignment graph revision than
// the one it now runs from; a kept data whose form changed is read from the entries' manifests.
func TestAWantGoingBackToAnEarlierFingerprintIsARevert(t *testing.T) {
ctx := context.Background()
o := memObjects{}
at := time.Now()
for _, fp := range []string{"src2:a", "src2:b", "src2:c"} {
in := Inputs{Halves: map[string]meshcatalogclient.Half{"main": half("main", 1, meshcatalog.CatalogEntry{Module: "web", Fp: fp})},
Placements: map[string]mma.Assignments{"ace": {Node: "ace", Modules: []mma.PlacedModule{{Module: "web"}}}}, FirstBranch: func(string) string { return "main" }}
if _, _, err := Keep(ctx, o, Compute(in), "test", at); err != nil {
t.Fatal(err)
}
}
for _, c := range []struct {
run, want string
revert bool
}{{"src2:c", "src2:a", true}, {"src2:c", "src2:b", true}, {"src2:a", "src2:c", false}, {"src2:b", "src2:b", false}, {"src2:c", "src2:zz", false}} {
if got, err := Reverted(ctx, o, "ace", "web", c.run, c.want); err != nil || got != c.revert {
t.Errorf("runs %s, wanted %s: revert %v %v, want %v", c.run, c.want, got, err, c.revert)
}
}
}
func TestAKeptDatasFormIsReadFromTheEntrysManifest(t *testing.T) {
entry := func(form int) meshcatalog.CatalogEntry {
return meshcatalog.CatalogEntry{Module: "web", Manifest: map[string]any{"module": "web",
"state": []any{map[string]any{"name": "sessions", "form": form}}}}
}
same, err := FormChanges(entry(1), entry(1))
if err != nil || len(same) != 0 {
t.Fatalf("same form: %v %v", same, err)
}
got, err := FormChanges(entry(1), entry(2))
if err != nil || len(got) != 1 || got[0].Name != "sessions" {
t.Errorf("changed form: %+v %v", got, err)
}
}
@@ -106,7 +106,9 @@ func OwnNetwork(conds []Cond) map[string]bool {
// what ended it for the rest of that fingerprint on that node: `go-on` (counted healthy by the operator's word) or `failed`
// (the delivery's failure), so a check that goes on failing is not waited on again.
type Wait struct {
V int `json:"v"`
V int `json:"v"`
// Step is set only on a record of an acting wait (internal/acting), which this package never reads as its own.
Step string `json:"step,omitempty"`
Node string `json:"node"`
Module string `json:"module"`
FP string `json:"fp"`
@@ -141,7 +143,7 @@ func Decode(raw []byte) (Wait, error) {
if err := json.Unmarshal(raw, &w); err != nil {
return w, err
}
if w.V != 1 || w.Node == "" || w.Module == "" {
if w.V != 1 || w.Step != "" || w.Node == "" || w.Module == "" {
return w, fmt.Errorf("outside: %q is not a kept outside wait", raw)
}
return w, nil
@@ -134,12 +134,12 @@ func TestManifest(t *testing.T) {
// ADR 0618 §4: and it hears each node's engine say its reads changed; ADR 0636 §4 (issue 1966): and the seat list's two events;
// issue 1966 note 151: and the teller's ask-ended, its subject the one the assigner hears; §376, §389: its own version-delivered and
// the watchman's machine-away and machine-back, read whole at its start.
if !slices.Equal(m.Consumes, []string{"mesh-conditions.findings-asked", "mesh-conditions.condition-raised", "mesh-conditions.condition-changed", "mesh-conditions.condition-cleared", "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "mesh-catalog.version-released", "mesh-wants.version-delivered", "mesh-watchdogs.machine-away", "mesh-watchdogs.machine-back", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", builds.BuilderModule + "." + builds.BuildsStreamEvents, buckets.TellerModule + "." + tellerevents.AskEnded, buckets.TellerModule + ".views." + buckets.AnswersState, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed", "node-engine.running", "mesh-delivery.pinned", "mesh-delivery.freed", "mesh-delivery.heads-replaced", "mesh-delivery.pins-stated"}) {
if !slices.Equal(m.Consumes, []string{"mesh-conditions.findings-asked", "mesh-conditions.condition-raised", "mesh-conditions.condition-changed", "mesh-conditions.condition-cleared", "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "mesh-catalog.version-released", "mesh-wants.version-delivered", "mesh-watchdogs.machine-away", "mesh-watchdogs.machine-back", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", builds.BuilderModule + "." + builds.BuildsStreamEvents, buckets.IndexerModule + ".take-in.taken", buckets.TellerModule + "." + tellerevents.AskEnded, buckets.TellerModule + ".views." + buckets.AnswersState, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed", "node-engine.running", "mesh-delivery.pinned", "mesh-delivery.freed", "mesh-delivery.heads-replaced", "mesh-delivery.pins-stated"}) {
t.Errorf("consumes = %v, want the catalog half's revisions (design 47 G §195), the teller's ask-ended, the engines' reads changed and what they run", m.Consumes)
}
if want := []string{"mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
buckets.TellerModule + "." + buckets.AnswersReadTool, // A-175, A-178: the teller's answers' first view
"seat:mesh-catalog.registrations", "seat:mesh-catalog.revision", "seat:mesh-catalog.seat-definitions", // ADR 0636 §4: the seat list
buckets.TellerModule + "." + buckets.AnswersReadTool, // A-175, A-178: the teller's answers' first view
"seat:mesh-catalog.entry", "seat:mesh-catalog.registrations", "seat:mesh-catalog.revision", "seat:mesh-catalog.seat-definitions", // ADR 0636 §4: the seat list
"seat:mesh-conditions.conditions", // design 47 G §706: the conditions read at each judging of an outside wait
"seat:mesh-delivery.source", // §507: the branches the gate keeps a source revision of, before the first registrations
"seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", // ADR 0565 §9, §11: the seat alone
@@ -148,6 +148,10 @@ func TestManifest(t *testing.T) {
"seat:operator-channel.asks"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { // ADR 0668 §4: the ended asks at bind
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
// design 47 G §558 (AS20): the assigner relies on the revision's `none-yet`, so its invokes name it in the long form.
if f := m.InvokeFields["seat:mesh-catalog.revision"]; !slices.Equal(f, []string{"none-yet"}) {
t.Errorf("the fields of the revision invoke = %v", f)
}
// design 47 G §633, §706, A-112: the bounds of the wait on an outside cause are `config` keys, each filling a file the
// manifest declares, which the process reads at its start; the environment names only the files.
var raw struct {
+4 -2
View File
@@ -1,6 +1,6 @@
{
"module": "mesh-delivery-assigner",
"version": "0.1",
"version": "0.3",
"slug": "assigner",
"runs-as": "mesh-delivery-assigner",
"claims": [
@@ -53,6 +53,7 @@
"mesh-catalog.seat-definition-changed",
"mesh-catalog.seat-definition-removed",
"mesh-delivery-builder.build.ended.**",
"mesh-delivery-indexer.take-in.taken",
"mesh-delivery-teller.ask-ended",
"mesh-delivery-teller.views.answers",
"mesh-pipeline-runner.pipeline-ended",
@@ -68,8 +69,9 @@
"mesh-delivery-indexer.manifests",
"mesh-delivery-indexer.modules",
"mesh-delivery-teller.teller_read_answers",
"seat:mesh-catalog.entry",
"seat:mesh-catalog.registrations",
"seat:mesh-catalog.revision",
{"verb": "seat:mesh-catalog.revision", "fields": ["none-yet"]},
"seat:mesh-catalog.seat-definitions",
"seat:mesh-conditions.conditions",
"seat:mesh-delivery.source",
@@ -51,6 +51,10 @@ const (
BackupSkip = seat.WantsByWantNodeModuleValueBackupSkip
)
// RevertLive is the operator's answer to a held revert: the node starts the reverted build on the live data as it is, with
// nothing restored (design 47 G §710).
const RevertLive = seat.WantsByWantNodeModuleValueRevertLive
// NodeWord is the last part of a node's own key.
const NodeWord = "node"
@@ -217,6 +221,9 @@ func checkWant(w Want) error {
default:
return fmt.Errorf("wants: the backup answer %q is not %s or %s", w.Backup, BackupAgain, BackupSkip)
}
if w.Revert != "" && w.Revert != RevertLive {
return fmt.Errorf("wants: the revert answer %q is not %s", w.Revert, RevertLive)
}
if w.Newest == "" && w.Rule == 0 {
return nil
}
@@ -40,6 +40,7 @@ func TestWantsAreWhatTheEarlierCodecWrote(t *testing.T) {
"composed": {Want{Fp: a, Composed: c, Words: "w"}, `{"v":1,"fp":"` + a + `","composed":"` + c + `","words":"w"}`},
"held back": {Want{Fp: a, Self: true, Newest: b, Rule: RuleGrant, Backup: BackupAgain, Words: "held"}, `{"v":1,"fp":"` + a + `","newest":"` + b + `","rule":6,"backup":"again","words":"held","self":true}`},
"put": {Want{Fp: a, Self: true, Put: true, Hold: true, Yes: d, Release: "r1"}, `{"v":1,"fp":"` + a + `","put":true,"hold":true,"yes":"` + d + `","release":"r1","self":true}`},
"revert": {Want{Fp: a, Self: true, Revert: RevertLive, Words: "live"}, `{"v":1,"fp":"` + a + `","revert":"live","words":"live","self":true}`},
"removed": {Want{Removed: true, Words: "gone"}, `{"v":1,"removed":true,"words":"gone"}`},
"released": {Want{Fp: a, Composed: c, Version: "0.1.15", Parts: map[string]string{"mesh-sdk:go": d}}, `{"v":1,"fp":"` + a + `","composed":"` + c + `","version":"0.1.15","parts":{"mesh-sdk:go":"` + d + `"}}`},
"kept": {Want{Fp: a, Composed: c, Parts: map[string]string{"x:image": d}, KeptOn: "n1"}, `{"v":1,"fp":"` + a + `","composed":"` + c + `","parts":{"x:image":"` + d + `"},"kept-on":"n1"}`},
@@ -67,6 +68,7 @@ func TestWantsAreWhatTheEarlierCodecWrote(t *testing.T) {
"removed names a rule": {Removed: true, Rule: 1},
"release without yes": {Fp: a, Self: true, Release: "r"},
"backup answer unknown": {Fp: a, Self: true, Backup: "later"},
"revert answer unknown": {Fp: a, Self: true, Revert: "hold"},
"newest equals fp": {Fp: a, Self: true, Newest: a, Rule: RuleStep},
"rule 11": {Fp: a, Self: true, Newest: b, Rule: 11},
"newest without a rule": {Fp: a, Self: true, Newest: b},
@@ -732,6 +732,8 @@ type CatalogEntry struct {
Bases []string `json:"bases,omitempty"`
// the merge commit it was last changed at
Commit string `json:"commit"`
// the requirements the manifest's contributes object names as keys, sorted, its local names not carried; each is also in requires (design 47 G section 709)
Contributes []string `json:"contributes,omitempty"`
// the edges into this module's artifacts, each from the artifact that comes first; the build and the delivery order by these alone
Edges []Edge `json:"edges,omitempty"`
// its fingerprint (src2: and 64 hex digits); absent when it has none
@@ -564,9 +564,9 @@ type Edge struct {
Kind string `json:"kind"`
Module string `json:"module"`
Node string `json:"node"`
// for provisioning, the provision
// for provisioning and contribution, the requirement
Provision string `json:"provision,omitempty"`
// for contribution, the seat it contributes to; for acting, the seat whose rule gave the edge
// for acting only, the seat whose rule gave the edge
Seat string `json:"seat,omitempty"`
// for acting, the step: take-in, validate, check, build, compose, grant or place
Step string `json:"step,omitempty"`
@@ -584,11 +584,20 @@ type Fill struct {
Store string `json:"store"`
}
// Held is one requirement an entry is held on, for want of a provider.
// Held is one requirement an entry is held on, for want of a provider (design 47 G section 708): the assigner writes no want from a held entry, so the node keeps what it runs.
type Held struct {
// a provisioning edge to each placed provider of it, none chosen; none when no placed module provides it
Candidates []Edge `json:"candidates,omitempty"`
Provision string `json:"provision"`
// each placed module that provides it, sorted; empty when none does
Candidates []HeldCandidate `json:"candidates,omitempty"`
// the requirement no step of the order resolves
Requirement string `json:"requirement"`
// the seat that delivers the requirement and that no placement holds; only where the named seat's step held it
Seat string `json:"seat,omitempty"`
}
// HeldCandidate is one placed provider of a held requirement.
type HeldCandidate struct {
Module string `json:"module"`
Node string `json:"node"`
}
// NodeEntryRef is a type of the contract.
@@ -912,6 +921,8 @@ type WantsByWantNodeModuleValue struct {
Put bool `json:"put,omitempty"`
Release string `json:"release,omitempty"`
Removed bool `json:"removed,omitempty"`
// live: the operator's answer to a held revert, to start the reverted build on the live data as it is, with nothing restored (design 47 G section 710)
Revert string `json:"revert,omitempty"`
// the rule that holds the node back, 1 to 10
Rule int64 `json:"rule,omitempty"`
Self bool `json:"self,omitempty"`
@@ -928,6 +939,11 @@ const (
WantsByWantNodeModuleValueBackupSkip = "skip"
)
// The values of WantsByWantNodeModuleValue's revert.
const (
WantsByWantNodeModuleValueRevertLive = "live"
)
// WantsByWantNodeNodeValue is one value of the state wants under the key want.<node>.node.
type WantsByWantNodeNodeValue struct {
Composed string `json:"composed"`
@@ -1936,6 +1936,13 @@
"type": "string"
}
},
"contributes": {
"type": "array",
"items": {
"type": "string"
},
"description": "the requirements the manifest's contributes object names as keys, sorted, its local names not carried; each is also in requires (design 47 G section 709)"
},
"awaits": {
"type": "array",
"items": {
@@ -515,6 +515,13 @@
"skip"
]
},
"revert": {
"type": "string",
"description": "live: the operator's answer to a held revert, to start the reverted build on the live data as it is, with nothing restored (design 47 G section 710)",
"enum": [
"live"
]
},
"words": {
"type": "string"
},
@@ -773,11 +780,11 @@
},
"provision": {
"type": "string",
"description": "for provisioning, the provision"
"description": "for provisioning and contribution, the requirement"
},
"seat": {
"type": "string",
"description": "for contribution, the seat it contributes to; for acting, the seat whose rule gave the edge"
"description": "for acting only, the seat whose rule gave the edge"
},
"step": {
"type": "string",
@@ -844,21 +851,42 @@
},
"Held": {
"type": "object",
"description": "one requirement an entry is held on, for want of a provider",
"description": "one requirement an entry is held on, for want of a provider (design 47 G section 708): the assigner writes no want from a held entry, so the node keeps what it runs",
"properties": {
"provision": {
"type": "string"
"requirement": {
"type": "string",
"description": "the requirement no step of the order resolves"
},
"seat": {
"type": "string",
"description": "the seat that delivers the requirement and that no placement holds; only where the named seat's step held it"
},
"candidates": {
"type": "array",
"description": "a provisioning edge to each placed provider of it, none chosen; none when no placed module provides it",
"description": "each placed module that provides it, sorted; empty when none does",
"items": {
"$ref": "#/defs/Edge"
"$ref": "#/defs/HeldCandidate"
}
}
},
"required": [
"provision"
"requirement"
]
},
"HeldCandidate": {
"type": "object",
"description": "one placed provider of a held requirement",
"properties": {
"node": {
"type": "string"
},
"module": {
"type": "string"
}
},
"required": [
"node",
"module"
]
},
"NodeEntryRef": {
@@ -243,6 +243,8 @@ export interface CatalogEntry {
"bases"?: string[];
/** the merge commit it was last changed at */
"commit": string;
/** the requirements the manifest's contributes object names as keys, sorted, its local names not carried; each is also in requires (design 47 G section 709) */
"contributes"?: string[];
/** the edges into this module's artifacts, each from the artifact that comes first; the build and the delivery order by these alone */
"edges"?: Edge[];
/** its fingerprint (src2: and 64 hex digits); absent when it has none */
@@ -232,9 +232,9 @@ export interface Edge {
"kind": string;
"module": string;
"node": string;
/** for provisioning, the provision */
/** for provisioning and contribution, the requirement */
"provision"?: string;
/** for contribution, the seat it contributes to; for acting, the seat whose rule gave the edge */
/** for acting only, the seat whose rule gave the edge */
"seat"?: string;
/** for acting, the step: take-in, validate, check, build, compose, grant or place */
"step"?: string;
@@ -252,11 +252,20 @@ export interface Fill {
"store": string;
}
/** one requirement an entry is held on, for want of a provider */
/** one requirement an entry is held on, for want of a provider (design 47 G section 708): the assigner writes no want from a held entry, so the node keeps what it runs */
export interface Held {
/** a provisioning edge to each placed provider of it, none chosen; none when no placed module provides it */
"candidates"?: Edge[];
"provision": string;
/** each placed module that provides it, sorted; empty when none does */
"candidates"?: HeldCandidate[];
/** the requirement no step of the order resolves */
"requirement": string;
/** the seat that delivers the requirement and that no placement holds; only where the named seat's step held it */
"seat"?: string;
}
/** one placed provider of a held requirement */
export interface HeldCandidate {
"module": string;
"node": string;
}
export interface NodeEntryRef {
@@ -530,6 +539,8 @@ export interface WantsByWantNodeModuleValue {
"put"?: boolean;
"release"?: string;
"removed"?: boolean;
/** live: the operator's answer to a held revert, to start the reverted build on the live data as it is, with nothing restored (design 47 G section 710) */
"revert"?: "live";
/** the rule that holds the node back, 1 to 10 */
"rule"?: number;
"self"?: boolean;
@@ -51,7 +51,14 @@ func CatalogEntryOf(module string, cur meshcatalog.ModulesByModuleValue, m catal
e := meshcatalog.CatalogEntry{V: CatalogEntryLayout, Module: module, Repository: cur.Repository, Path: cur.Path,
Commit: cur.Commit, Manifest: manifestWhole(raw), ManifestDigest: EntryManifestDigest(cur.Manifest), Verdict: cur.Verdict, Fp: n.FP, None: n.None, Bases: n.Bases,
Packages: maps.Clone(cur.Packages), Provides: n.Provides, Requires: n.Requires, Awaits: n.Awaits, Invokes: n.Invokes,
Serves: n.Serves, LeftOut: n.LeftOut, Main: MainOf(m)}
Serves: n.Serves, LeftOut: n.LeftOut, Main: MainOf(m), Contributes: contributesOf(m)}
// requires names every requirement as catalogue.Manifest.Wants lists them, optional ones and those only contributes names
// included, since contributing to a requirement is requiring it (design 47 G §709, A-229).
for _, r := range m.Wants() {
if !slices.Contains(e.Requires, r) {
e.Requires = append(slices.Clone(e.Requires), r)
}
}
if n.FP == "" && n.None == "" {
// A module the recompute computed no node for (its manifest cannot be read) keeps what its value holds.
e.Fp, e.None = cur.Fp2, cur.None2
@@ -153,3 +160,19 @@ func manifestWhole(raw string) map[string]any {
}
return whole
}
// contributesOf is the entry's field contributes (design 47 G §709): the requirements the manifest's contributes object names
// as keys, sorted, its local names not carried; none when it names none.
func contributesOf(m catalogue.Manifest) []string {
var out []string
for r := range m.Contributes {
out = append(out, r)
}
for r := range m.ContributesMany {
if !slices.Contains(out, r) {
out = append(out, r)
}
}
slices.Sort(out)
return out
}
@@ -86,3 +86,27 @@ func TestAnImageArtifactsPackageNameIsItsModulesAndItsOwn(t *testing.T) {
t.Errorf("two modules' image artifacts of one name carry %v, want two package names", names)
}
}
// Design 47 G §709 and A-229: a manifest whose contributes names route gives an entry whose contributes and requires both
// name it; requires names an optional requirement too, as catalogue.Manifest.Wants lists them, beside the node's own.
func TestAnEntryNamesWhatItContributesToAndEveryRequirement(t *testing.T) {
raw := `{"module":"web","version":"1","requires":["database",{"name":"cache","optional":true}],"contributes":{"route":{"host":"web"}}}`
m, err := catalogue.Decode([]byte(raw))
if err != nil {
t.Fatal(err)
}
e := CatalogEntryOf("web", meshcatalog.ModulesByModuleValue{}, m, raw, graph.Node{Requires: []string{"database", graph.BusState}})
if !reflect.DeepEqual(e.Contributes, []string{"route"}) {
t.Errorf("contributes = %v, want [route]", e.Contributes)
}
if want := []string{"database", graph.BusState, "cache", "route"}; !reflect.DeepEqual(e.Requires, want) {
t.Errorf("requires = %v, want %v", e.Requires, want)
}
// Read back as the module graph's node, the optional requirement is told apart by the entry's manifest (A-229).
if n := graph.NodeOf(e); !reflect.DeepEqual(n.Requires, []string{"database", graph.BusState, "route"}) {
t.Errorf("the node's requires = %v, want the optional cache left out", n.Requires)
}
if e := CatalogEntryOf("kv", meshcatalog.ModulesByModuleValue{}, catalogue.Manifest{}, "", graph.Node{}); e.Contributes != nil {
t.Errorf("an entry of a manifest contributing nothing carries contributes %v", e.Contributes)
}
}
@@ -59,7 +59,9 @@ func stepAsk() StepAsk {
// The ask the three build seats share is one neutral form: it round-trips, and every ill-formed ask is refused,
// naming what is wrong (the rules of the old delivery.StepAsk, now the shared runner's).
func TestAskCheckAndRoundTrip(t *testing.T) {
for _, a := range []StepAsk{goodBuild(), goodCheck(), runAsk(), stepAsk()} {
headless := runAsk() // a run whose program reads only its input names no head (design 47 G §712, A-238)
headless.Run.Heads = nil
for _, a := range []StepAsk{goodBuild(), goodCheck(), runAsk(), headless, stepAsk()} {
raw, err := EncodeStepAsk(a)
if err != nil {
t.Fatal(err)
@@ -106,7 +108,6 @@ func TestAskCheckAndRoundTrip(t *testing.T) {
{"run at a short commit", func(a *StepAsk) { a.Run.Program.Commit = "0123456" }, "no full commit"},
{"run outside the repository", func(a *StepAsk) { a.Run.Program.Dir = "../x" }, "no clean directory"},
{"run of no package", func(a *StepAsk) { a.Run.Program.Package = "" }, "no package"},
{"run over no head", func(a *StepAsk) { a.Run.Heads = nil }, "no head"},
{"run over a head twice", func(a *StepAsk) { a.Run.Heads = append(a.Run.Heads, a.Run.Heads[0]) }, "twice"},
{"run over a branch", func(a *StepAsk) { a.Run.Heads[1].Commit = "main" }, "novox/mesh-sdk at no full commit"},
{"input on a state", func(a *StepAsk) { a.Run.Input.Verb = "mesh-delivery-checker.inputs" }, "no seat:<seat>.<verb>"},
+3 -4
View File
@@ -31,7 +31,9 @@ type RunJob struct {
// Program is where the program is built: the repository and full commit fetched, the Go module's directory in it and
// the program's import path. The ask's Packages are the mesh package versions it builds against (ADR 0573 §2, §4).
Program RunProgram `json:"program"`
// Heads are the commits the program reads, each laid read-only at its repository's path; a repository at most once.
// Heads are the commits the program reads, each laid read-only at its repository's path; a repository at most once. None
// is a program that reads only its input (the indexer's manifest validator, design 47 G §712, A-238): the directory
// RunHeadsEnv names is then empty.
Heads []RunHead `json:"heads"`
// Input names the program's input, kept by the asker by its digest and served on a verb of its seat.
Input RunInput `json:"input"`
@@ -150,9 +152,6 @@ func (r RunJob) check(id string) error {
if p.Package == "" || strings.ContainsAny(p.Package, " \t\n") {
return fmt.Errorf("buildstep: the run ask %s names no package to build (%q)", id, p.Package)
}
if len(r.Heads) == 0 {
return fmt.Errorf("buildstep: the run ask %s names no head", id)
}
seen := map[string]bool{}
for _, h := range r.Heads {
if err := checkRunRepository(id, "a head of", h.Repository); err != nil {
+17 -1
View File
@@ -1,10 +1,12 @@
package graph
import (
"encoding/json"
"maps"
"slices"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// NodeOf is the node of the module graph a catalog graph entry carries, as the indexer took it in (design 47, *Two
@@ -12,7 +14,7 @@ import (
// catalog half reads the module graph from its entries, never from the holder's state.
func NodeOf(e meshcatalog.CatalogEntry) Node {
n := Node{V: Version, FP: e.Fp, None: e.None, Repository: e.Repository, Path: e.Path, Bases: e.Bases,
Provides: e.Provides, Requires: e.Requires, Awaits: e.Awaits, Invokes: e.Invokes, Serves: e.Serves, LeftOut: e.LeftOut}
Provides: e.Provides, Requires: required(e), Awaits: e.Awaits, Invokes: e.Invokes, Serves: e.Serves, LeftOut: e.LeftOut}
if len(e.Packages) > 0 {
n.Packages = slices.Sorted(maps.Keys(e.Packages))
}
@@ -24,3 +26,17 @@ func NodeOf(e meshcatalog.CatalogEntry) Node {
}
return n
}
// required are the entry's requires a node means, those it requires not optionally: an entry's requires name the optional
// ones too, told apart by the entry's manifest (design 47 G §709, A-229).
func required(e meshcatalog.CatalogEntry) []string {
if len(e.Manifest) == 0 || len(e.Requires) == 0 {
return e.Requires
}
var m catalogue.Manifest
raw, err := json.Marshal(e.Manifest)
if err != nil || json.Unmarshal(raw, &m) != nil {
return e.Requires
}
return slices.DeleteFunc(slices.Clone(e.Requires), m.IsOptional)
}