diff --git a/modules/mesh-delivery-assigner/asks/asks.go b/modules/mesh-delivery-assigner/asks/asks.go index 4542253a..bc29ad14 100644 --- a/modules/mesh-delivery-assigner/asks/asks.go +++ b/modules/mesh-delivery-assigner/asks/asks.go @@ -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...`. 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...`, 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..`. @@ -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...` (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.` (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...")) + return refused(fmt.Errorf("a %s ask is %s...", kind, kind)) } fp, err := catalogkey.ParseKeyPart(part) if err != nil { diff --git a/modules/mesh-delivery-assigner/asks/asks_test.go b/modules/mesh-delivery-assigner/asks/asks_test.go index c522584c..d97babc9 100644 --- a/modules/mesh-delivery-assigner/asks/asks_test.go +++ b/modules/mesh-delivery-assigner/asks/asks_test.go @@ -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") diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go new file mode 100644 index 00000000..4b7f7095 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go @@ -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...`, 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 `@`. +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 +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits_test.go new file mode 100644 index 00000000..3ee22779 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits_test.go @@ -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) + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildended.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildended.go index 8d7cbb3b..e95fc285 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildended.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildended.go @@ -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() + }) +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphfill.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphfill.go new file mode 100644 index 00000000..a3b79267 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphfill.go @@ -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, "") + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphfill_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphfill_test.go new file mode 100644 index 00000000..af05f547 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphfill_test.go @@ -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) + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go index 7a6c49c7..cfb67dd1 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go @@ -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 } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go index 7d07b518..558657ac 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go @@ -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} } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go index 6d410c29..e43fc077 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -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) } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/objects.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/objects.go index 73540150..e1833237 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/objects.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/objects.go @@ -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 +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/orderwaits.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/orderwaits.go new file mode 100644 index 00000000..4a883737 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/orderwaits.go @@ -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...` 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) + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/orderwaits_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/orderwaits_test.go new file mode 100644 index 00000000..36017dca --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/orderwaits_test.go @@ -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) + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/outside_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/outside_test.go index 8438cdb3..4fbac555 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/outside_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/outside_test.go @@ -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 { diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/outside_tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/outside_tools.go index e915e82a..3140d230 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/outside_tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/outside_tools.go @@ -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.", diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index 99e5985d..db2d1d79 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -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`: 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 `.` 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] diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines_test.go index 1a1d594a..8c7e9449 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines_test.go @@ -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) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert.go new file mode 100644 index 00000000..5b61ea2d --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert.go @@ -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 `.` 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 +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert_test.go new file mode 100644 index 00000000..87660323 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert_test.go @@ -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) + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go index 1911af8f..c69d9ede 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go @@ -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)) }}, diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go index 4f65c6d4..4599e74a 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -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 diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 39fb4f5d..5addb9ec 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -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 diff --git a/modules/mesh-delivery-assigner/internal/acting/acting.go b/modules/mesh-delivery-assigner/internal/acting/acting.go new file mode 100644 index 00000000..abc217c1 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/acting/acting.go @@ -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' `..`. +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...`. +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 +} diff --git a/modules/mesh-delivery-assigner/internal/assign/asks.go b/modules/mesh-delivery-assigner/internal/assign/asks.go index 59e1c8fd..aee5b660 100644 --- a/modules/mesh-delivery-assigner/internal/assign/asks.go +++ b/modules/mesh-delivery-assigner/internal/assign/asks.go @@ -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 `.`. +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 `.`, 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 diff --git a/modules/mesh-delivery-assigner/internal/assign/asks_test.go b/modules/mesh-delivery-assigner/internal/assign/asks_test.go index 3e23b1db..ab9a26b9 100644 --- a/modules/mesh-delivery-assigner/internal/assign/asks_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/asks_test.go @@ -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...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) diff --git a/modules/mesh-delivery-assigner/internal/assign/ended.go b/modules/mesh-delivery-assigner/internal/assign/ended.go index 98985d51..cc05a714 100644 --- a/modules/mesh-delivery-assigner/internal/assign/ended.go +++ b/modules/mesh-delivery-assigner/internal/assign/ended.go @@ -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)", } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index 058770c8..9434cc0b 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -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 } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index 28b7f3a0..b2f428ae 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -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"]) + } +} diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/compute.go b/modules/mesh-delivery-assigner/internal/assigngraph/compute.go index fca2b7fb..4372c3dc 100644 --- a/modules/mesh-delivery-assigner/internal/assigngraph/compute.go +++ b/modules/mesh-delivery-assigner/internal/assigngraph/compute.go @@ -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 "/: 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 +} diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/cycle.go b/modules/mesh-delivery-assigner/internal/assigngraph/cycle.go new file mode 100644 index 00000000..a8d691f5 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assigngraph/cycle.go @@ -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 `: 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) +} diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/cycle_test.go b/modules/mesh-delivery-assigner/internal/assigngraph/cycle_test.go new file mode 100644 index 00000000..78cc4c82 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assigngraph/cycle_test.go @@ -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) + } + } +} diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/graph_test.go b/modules/mesh-delivery-assigner/internal/assigngraph/graph_test.go index 8e8b3589..3fb08808 100644 --- a/modules/mesh-delivery-assigner/internal/assigngraph/graph_test.go +++ b/modules/mesh-delivery-assigner/internal/assigngraph/graph_test.go @@ -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) } }) } diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/keep.go b/modules/mesh-delivery-assigner/internal/assigngraph/keep.go index 743d346e..9f340097 100644 --- a/modules/mesh-delivery-assigner/internal/assigngraph/keep.go +++ b/modules/mesh-delivery-assigner/internal/assigngraph/keep.go @@ -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)) +} diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/revert.go b/modules/mesh-delivery-assigner/internal/assigngraph/revert.go new file mode 100644 index 00000000..4362a850 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assigngraph/revert.go @@ -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 +} diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/revert_test.go b/modules/mesh-delivery-assigner/internal/assigngraph/revert_test.go new file mode 100644 index 00000000..a824bffa --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assigngraph/revert_test.go @@ -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) + } +} diff --git a/modules/mesh-delivery-assigner/internal/outside/outside.go b/modules/mesh-delivery-assigner/internal/outside/outside.go index f7b4209e..64d81ebc 100644 --- a/modules/mesh-delivery-assigner/internal/outside/outside.go +++ b/modules/mesh-delivery-assigner/internal/outside/outside.go @@ -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 diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index 9beaf86f..bbdf85e4 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -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 { diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index e305a49d..5cec1139 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -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", diff --git a/modules/mesh-delivery-contracts/go/clients/meshwantsclient/wants.go b/modules/mesh-delivery-contracts/go/clients/meshwantsclient/wants.go index 6a4cd96f..ea5a8dff 100644 --- a/modules/mesh-delivery-contracts/go/clients/meshwantsclient/wants.go +++ b/modules/mesh-delivery-contracts/go/clients/meshwantsclient/wants.go @@ -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 } diff --git a/modules/mesh-delivery-contracts/go/clients/meshwantsclient/wants_test.go b/modules/mesh-delivery-contracts/go/clients/meshwantsclient/wants_test.go index cb6ec2c7..28b454f7 100644 --- a/modules/mesh-delivery-contracts/go/clients/meshwantsclient/wants_test.go +++ b/modules/mesh-delivery-contracts/go/clients/meshwantsclient/wants_test.go @@ -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}, diff --git a/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go b/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go index 5919ecb2..bd2f04ef 100644 --- a/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go +++ b/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go @@ -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 diff --git a/modules/mesh-delivery-contracts/go/seats/meshwants/contract.go b/modules/mesh-delivery-contracts/go/seats/meshwants/contract.go index e73a7cb0..4e571ea9 100644 --- a/modules/mesh-delivery-contracts/go/seats/meshwants/contract.go +++ b/modules/mesh-delivery-contracts/go/seats/meshwants/contract.go @@ -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. type WantsByWantNodeNodeValue struct { Composed string `json:"composed"` diff --git a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json index 1c776e5d..da7c1d58 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json +++ b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json @@ -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": { diff --git a/modules/mesh-delivery-contracts/seats/mesh-wants/seat.json b/modules/mesh-delivery-contracts/seats/mesh-wants/seat.json index bdb5915b..9121926e 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-wants/seat.json +++ b/modules/mesh-delivery-contracts/seats/mesh-wants/seat.json @@ -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": { diff --git a/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts b/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts index 9d60bb13..0dd39a96 100644 --- a/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts +++ b/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts @@ -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 */ diff --git a/modules/mesh-delivery-contracts/ts/seats/meshwants/index.d.ts b/modules/mesh-delivery-contracts/ts/seats/meshwants/index.d.ts index ef4554b2..debf9a3c 100644 --- a/modules/mesh-delivery-contracts/ts/seats/meshwants/index.d.ts +++ b/modules/mesh-delivery-contracts/ts/seats/meshwants/index.d.ts @@ -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; diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_graph.go b/modules/mesh-delivery-indexer/internal/index/catalog_graph.go index 82fe97ef..24c653b1 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_graph.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_graph.go @@ -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 +} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_graph_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_graph_test.go index d1066aee..e2502b19 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_graph_test.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_graph_test.go @@ -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) + } +} diff --git a/modules/mesh-delivery-sdk/buildstep/ask_test.go b/modules/mesh-delivery-sdk/buildstep/ask_test.go index f63c248b..7676d0f4 100644 --- a/modules/mesh-delivery-sdk/buildstep/ask_test.go +++ b/modules/mesh-delivery-sdk/buildstep/ask_test.go @@ -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:."}, diff --git a/modules/mesh-delivery-sdk/buildstep/run.go b/modules/mesh-delivery-sdk/buildstep/run.go index e975cdf4..c98c6262 100644 --- a/modules/mesh-delivery-sdk/buildstep/run.go +++ b/modules/mesh-delivery-sdk/buildstep/run.go @@ -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 { diff --git a/modules/mesh-delivery-sdk/graph/entry.go b/modules/mesh-delivery-sdk/graph/entry.go index 49341939..a2fa9ab5 100644 --- a/modules/mesh-delivery-sdk/graph/entry.go +++ b/modules/mesh-delivery-sdk/graph/entry.go @@ -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) +}