From 6ce46884d53983f068fa16e45b2c4920734a49a9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:01:28 +0100 Subject: [PATCH 01/12] AS9: an entry waits for its acting part's new version in composing and placing; assigner_waits lists the wait, assigner_release now lets it go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §266, §268, §276, §269 (the assigner's part: wait-held with bound and clock-from, wait-ended; acting-part-late is the watchman's) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-assigner/actingwaits.go | 152 ++++++++++++++++++ .../actingwaits_test.go | 118 ++++++++++++++ .../cmd/mesh-delivery-assigner/graphkept.go | 3 + .../mesh-delivery-assigner/outside_test.go | 4 +- .../mesh-delivery-assigner/outside_tools.go | 77 ++++++++- .../cmd/mesh-delivery-assigner/pass.go | 3 + .../cmd/mesh-delivery-assigner/tools.go | 2 +- .../internal/acting/acting.go | 103 ++++++++++++ .../internal/assign/wants.go | 33 ++++ .../internal/assign/wants_test.go | 25 +++ .../internal/outside/outside.go | 6 +- 11 files changed, 519 insertions(+), 7 deletions(-) create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits_test.go create mode 100644 modules/mesh-delivery-assigner/internal/acting/acting.go 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..8df05f4e --- /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) { + 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..0f45a224 --- /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{}}}, 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/graphkept.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go index 884723dd..bfa522b0 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go @@ -36,6 +36,8 @@ type graphKeeper struct { said string // 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. @@ -51,6 +53,7 @@ func (p *passer) keepGraph(ctx context.Context) { in.FirstBranch = func(module string) string { return p.ends.branchOf(module, "") } } c := assigngraph.Compute(in) + g.computed = c for _, u := range c.Uncomputed { if g.said != u { g.said = u 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 4a06236e..9623e4f1 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 @@ -265,10 +265,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 9d22b933..ae75ce2f 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 @@ -11,6 +11,7 @@ import ( "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" stdio "git.novox.be/novox/mesh-sdk/go" + "mesh-delivery-assigner/internal/acting" "mesh-delivery-assigner/internal/buckets" "mesh-delivery-assigner/internal/outside" ) @@ -33,6 +34,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) @@ -40,12 +48,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) { + 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 @@ -65,6 +83,9 @@ func (s *putTools) doWaits(args map[string]any) (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) } @@ -110,9 +131,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 := acting.Prefix + node + "." + module + "." + for _, k := range keys { + if !strings.HasPrefix(k, prefix) { + 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 71581d0e..e0d97613 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -284,6 +284,8 @@ 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() + defer func() { p.actingWaits(ctx, viewOf(trunkBranch).nodes, wanted) }() for name := range names { groups := p.groups(name, nodesOf[name], assignedBranches[name]) for gi, g := range groups { @@ -350,6 +352,7 @@ 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)) } + m.Acting = p.actingOf(name, released) out := assign.Wants(m, nodes, wantedOf[name], last) for _, node := range m.Nodes { for _, prov := range m.MissingProvisions(node) { 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 a362a264..b8325a1d 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go @@ -366,7 +366,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"}, []string{"node", "module", "why"}), Run: func(a map[string]any) (any, error) { return s.doRelease(a, stdio.CallerOf(a)) }}, s.waitsTool(), 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..e4607f99 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/acting/acting.go @@ -0,0 +1,103 @@ +// 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." + +// 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. + 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 } + +// 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: 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/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index d3cb0742..e1decc0b 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 d0f3be9f..e048eb0f 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -1608,3 +1608,28 @@ func TestAWantNamesAKeptBuildsPartsAndNodeForANodeThatReadsThem(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/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 From de069491212c310bde31fbd8274b8bcf5ebb0ece Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:02:18 +0100 Subject: [PATCH 02/12] AS10: the assignment graph finds a cycle among the waits within one step and says it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §272, §356 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-assigner/graphkept.go | 8 ++ .../internal/assigngraph/compute.go | 3 + .../internal/assigngraph/cycle.go | 73 +++++++++++++++++++ .../internal/assigngraph/cycle_test.go | 36 +++++++++ 4 files changed, 120 insertions(+) create mode 100644 modules/mesh-delivery-assigner/internal/assigngraph/cycle.go create mode 100644 modules/mesh-delivery-assigner/internal/assigngraph/cycle_test.go 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 bfa522b0..e3b2e22b 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go @@ -34,6 +34,8 @@ type graphKeeper struct { source int64 ok bool said string + // cycled is the cycles last said (assigngraph.Cycles). + cycled string // 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). @@ -60,6 +62,12 @@ func (p *passer) keepGraph(ctx context.Context) { 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.has && g.ok && assigngraph.Same(g.last, c.Revision) { return } diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/compute.go b/modules/mesh-delivery-assigner/internal/assigngraph/compute.go index fca2b7fb..2b5b8750 100644 --- a/modules/mesh-delivery-assigner/internal/assigngraph/compute.go +++ b/modules/mesh-delivery-assigner/internal/assigngraph/compute.go @@ -48,6 +48,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. @@ -212,6 +214,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 } 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) + } + } +} From 84db9581107eb88d6a57d4860680ea6e7fa08282 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:04:45 +0100 Subject: [PATCH 03/12] =?UTF-8?q?AS13=20(=C2=A7181):=20the=20assignment=20?= =?UTF-8?q?graph=20store=20says=20filling,=20full=20and=20room;=20a=20full?= =?UTF-8?q?=20store=20keeps=20the=20last=20revision=20and=20writes=20no=20?= =?UTF-8?q?new=20want?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §181, §579 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-assigner/graphfill.go | 82 ++++++++++++++++++ .../mesh-delivery-assigner/graphfill_test.go | 83 +++++++++++++++++++ .../cmd/mesh-delivery-assigner/graphkept.go | 11 +++ .../cmd/mesh-delivery-assigner/objects.go | 17 ++++ .../cmd/mesh-delivery-assigner/pass.go | 4 +- .../internal/assigngraph/keep.go | 26 ++++++ 6 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphfill.go create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphfill_test.go 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 e3b2e22b..77980e47 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go @@ -36,6 +36,8 @@ type graphKeeper struct { 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). @@ -68,9 +70,17 @@ func (p *passer) keepGraph(ctx context.Context) { 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) @@ -86,6 +96,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 } 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/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index e0d97613..aad8b9c1 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -205,7 +205,9 @@ func (p *passer) clock() time.Time { func (p *passer) pass(ctx context.Context) { p.assignmentsSeat(ctx) p.keepGraph(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) diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/keep.go b/modules/mesh-delivery-assigner/internal/assigngraph/keep.go index 7ce8600e..c97b5e9f 100644 --- a/modules/mesh-delivery-assigner/internal/assigngraph/keep.go +++ b/modules/mesh-delivery-assigner/internal/assigngraph/keep.go @@ -149,3 +149,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)) +} From a5cafab0e84f7a3e646a41d69cd2894b3716e7de Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:06:42 +0100 Subject: [PATCH 04/12] =?UTF-8?q?AS20=20(=C2=A7558):=20the=20assigner's=20?= =?UTF-8?q?invokes=20name=20the=20fields=20it=20relies=20on=20of=20the=20c?= =?UTF-8?q?atalog=20seat's=20revision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §558, §530 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/mesh-delivery-assigner/manifest_test.go | 4 ++++ modules/mesh-delivery-assigner/module.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index 49fab370..5b287c86 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -142,6 +142,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 163efe1d..0b7c2970 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -68,7 +68,7 @@ "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "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-hand-acts.hand-act", From 12e1fd440376b40a92655bd89e0ff2a18b27ab01 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:07:19 +0100 Subject: [PATCH 05/12] AS17 (a, b): a revert is a want going back to an earlier fingerprint by the assignment revisions' numbers; a kept data's form change is read from the entries' manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §298, §300, §303, §213; lead answer A-217 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/assigngraph/revert.go | 81 +++++++++++++++++++ .../internal/assigngraph/revert_test.go | 49 +++++++++++ 2 files changed, 130 insertions(+) create mode 100644 modules/mesh-delivery-assigner/internal/assigngraph/revert.go create mode 100644 modules/mesh-delivery-assigner/internal/assigngraph/revert_test.go 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) + } +} From 642a0ad2f951e8eded9af8996239b8683ce29ce0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:19:53 +0100 Subject: [PATCH 06/12] AS14 (b, c): a requirement points first to the named seat's holder, and a contributed one carries a contribution edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat whose definition delivers a requirement, among those the entry's catalog graph revision names, is resolved first: its holder from the placements' holds, as seat-holders keeps them; a delivering seat no placement holds holds the entry, naming the seat. A hold now names the requirement, the seat and each candidate {node, module}; an optional requirement nothing provides is left absent. The catalog entry gains contributes, filled by the indexer, and requires names every requirement as Wants lists them, optional ones included (the module graph and the assigner's halves leave the optional ones out by the entry's manifest). A contributed requirement gives a contribution edge to the same provider. Builds: design 47 G §708, §709, §357; lead answers A-167, A-229 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-assigner/graphkept.go | 5 + .../cmd/mesh-delivery-assigner/halves.go | 2 + .../mesh-delivery-assigner/pipelines_test.go | 8 +- .../internal/assigngraph/compute.go | 124 +++++++++++++----- .../internal/assigngraph/graph_test.go | 79 +++++++---- .../go/seats/meshcatalog/contract.go | 2 + .../go/seats/meshwants/contract.go | 21 ++- .../seats/mesh-catalog/seat.json | 7 + .../seats/mesh-wants/seat.json | 37 ++++-- .../ts/seats/meshcatalog/index.d.ts | 2 + .../ts/seats/meshwants/index.d.ts | 21 ++- .../internal/index/catalog_graph.go | 25 +++- .../internal/index/catalog_graph_test.go | 24 ++++ modules/mesh-delivery-sdk/graph/entry.go | 18 ++- 14 files changed, 294 insertions(+), 81 deletions(-) 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 77980e47..a6bccf5b 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go @@ -51,6 +51,11 @@ func (p *passer) keepGraph(ctx context.Context) { return } in := assigngraph.Inputs{Halves: g.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. in.FirstBranch = func(string) string { return trunkBranch } if p.ends != nil { 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 8d617071..8aa16801 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go @@ -108,6 +108,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/pipelines_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines_test.go index 19a2d4a4..39429d02 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 @@ -293,8 +293,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) @@ -306,7 +307,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/internal/assigngraph/compute.go b/modules/mesh-delivery-assigner/internal/assigngraph/compute.go index 2b5b8750..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. @@ -60,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 } @@ -120,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 { @@ -241,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/graph_test.go b/modules/mesh-delivery-assigner/internal/assigngraph/graph_test.go index 2827b7f6..21099c45 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-contracts/go/seats/meshcatalog/contract.go b/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go index 504752fc..a0a56a2a 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..c3ab5467 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. diff --git a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json index 0233171e..c9c69cad 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json +++ b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json @@ -1932,6 +1932,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..8100f320 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-wants/seat.json +++ b/modules/mesh-delivery-contracts/seats/mesh-wants/seat.json @@ -773,11 +773,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 +844,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 dfe29ba2..8c717ae2 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..57ec1e0f 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 { 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/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) +} From 9e02d6234bbbf0e57289ac34de28f90672fef883 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:23:13 +0100 Subject: [PATCH 07/12] A-238: a run ask whose program reads only its input names no head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indexer's validation runs its manifest validator over its input alone (design 47 G §712: heads none); the holder lays an empty MESH_RUN_HEADS. Builds: design 47 G §712, A-238 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/mesh-delivery-sdk/buildstep/ask_test.go | 5 +++-- modules/mesh-delivery-sdk/buildstep/run.go | 7 +++---- 2 files changed, 6 insertions(+), 6 deletions(-) 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 { From c4ba9eb23ec5519d732e37b00df6f52dcda87ea9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:19:54 +0100 Subject: [PATCH 08/12] B2h AS17 (1): a want carries the operator's answer live to a held revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat mesh-wants' want gains `revert`, enum live, beside `backup`; the wants codec refuses any other answer. The assigner's state form changes, so its manifest's version moves to 0.2 once (A-245). Builds: design 47 G §710, §300; A-245 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/mesh-delivery-assigner/module.json | 2 +- .../go/clients/meshwantsclient/wants.go | 7 +++++++ .../go/clients/meshwantsclient/wants_test.go | 2 ++ .../mesh-delivery-contracts/go/seats/meshwants/contract.go | 7 +++++++ modules/mesh-delivery-contracts/seats/mesh-wants/seat.json | 7 +++++++ .../mesh-delivery-contracts/ts/seats/meshwants/index.d.ts | 2 ++ 6 files changed, 26 insertions(+), 1 deletion(-) diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index 0b7c2970..d4bc86ac 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.2", "slug": "assigner", "runs-as": "mesh-delivery-assigner", "claims": [ 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/meshwants/contract.go b/modules/mesh-delivery-contracts/go/seats/meshwants/contract.go index c3ab5467..4e571ea9 100644 --- a/modules/mesh-delivery-contracts/go/seats/meshwants/contract.go +++ b/modules/mesh-delivery-contracts/go/seats/meshwants/contract.go @@ -921,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"` @@ -937,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-wants/seat.json b/modules/mesh-delivery-contracts/seats/mesh-wants/seat.json index 8100f320..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" }, 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 57ec1e0f..debf9a3c 100644 --- a/modules/mesh-delivery-contracts/ts/seats/meshwants/index.d.ts +++ b/modules/mesh-delivery-contracts/ts/seats/meshwants/index.d.ts @@ -539,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; From 42c3db1d5baec02e825dc4bb14670863740db938 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:24:04 +0100 Subject: [PATCH 09/12] B2h AS17 (2a): the assigner's ask of a held revert has its kind and key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asks state gains the kind revert, keyed revert... after the reverted build's fingerprint; like the backup ask it needs no proof of who answered, so the view may answer it, and an asking that ends unanswered while the hold stands is written again. Builds: design 47 G §710, §300 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/mesh-delivery-assigner/asks/asks.go | 27 ++++++++++++++----- .../mesh-delivery-assigner/asks/asks_test.go | 12 ++++++--- .../internal/assign/asks_test.go | 4 +++ .../internal/assign/ended.go | 3 ++- 4 files changed, 34 insertions(+), 12 deletions(-) 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/internal/assign/asks_test.go b/modules/mesh-delivery-assigner/internal/assign/asks_test.go index 3e23b1db..dd3ecd79 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"}, 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)", } From b316489b93faeb8451293082deb14a1523702bbe Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:24:24 +0100 Subject: [PATCH 10/12] B2h AS17 (2b): the revert ask's words, options and its finding revert-held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RevertAsk asks hold or live, naming the reverted build, the kept data, its form and the node's words; it expires leaving the newer build running. RevertHeldFinding is delivery...revert-held, a warning that needs nothing, so the ask is the operator's one message. Builds: design 47 G §710, §300 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/assign/asks.go | 48 +++++++++++++++++++ .../internal/assign/asks_test.go | 23 ++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) 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 dd3ecd79..ab9a26b9 100644 --- a/modules/mesh-delivery-assigner/internal/assign/asks_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/asks_test.go @@ -337,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) } @@ -367,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) From 1e48d040772b39cc4e5eb74fcca809a8959351f5 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:34:13 +0100 Subject: [PATCH 11/12] B2h AS17 (3): the pass asks about a held revert, and writes live into the want MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A have held with restore point missing or damaged, for a want the assignment graph tells a revert (Reverted) across a kept data's form (FormChanges of the running entry and the want's, read through the catalog seat's verb entry), gets the revert ask and the finding revert-held; backups leaves it. Live writes the want with revert live and its words, hold writes nothing; the finding clears once the reverted build runs, and a newer want takes the ask and the finding away. Builds: design 47 G §710, §300, §298; A-217 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-assigner/main.go | 6 +- .../cmd/mesh-delivery-assigner/pass.go | 20 ++- .../cmd/mesh-delivery-assigner/revert.go | 160 ++++++++++++++++++ .../cmd/mesh-delivery-assigner/revert_test.go | 136 +++++++++++++++ .../mesh-delivery-assigner/manifest_test.go | 2 +- modules/mesh-delivery-assigner/module.json | 1 + 6 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert.go create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert_test.go 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 a2a9ccb1..d671bb71 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-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/seatlist" @@ -122,7 +123,7 @@ var keptStates = []announce.State{ {Name: writtenState, Readable: true}, {Name: buckets.WaitsState, Readable: true}, {Name: buckets.InputsState, Readable: true}, // read by the tool process alone: the verb input and assigner_inputs (design 47 G §354) - {Name: pinsState, Readable: true}, // the records pinning each assignment graph revision (design 47 G §579) + {Name: pinsState, Readable: true}, // the records pinning each assignment graph revision (design 47 G §579) } // kvSet is every bucket the assigner opened, by name. @@ -555,6 +556,9 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, if iv != nil && iv.h != nil { objects := newBusObjects(js, process.Account{Node: thisNode, Module: assignment.Module}, assigngraph.RevisionsObjects) p.graph = &graphKeeper{objects: objects, halves: iv.h.views} + // 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 { 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 aad8b9c1..9eb26f44 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,9 @@ 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 + // 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. @@ -210,7 +214,7 @@ func (p *passer) pass(ctx context.Context) { } 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). @@ -743,7 +747,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 @@ -754,7 +758,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 "" @@ -875,6 +879,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 @@ -994,7 +1002,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) @@ -1002,7 +1012,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/revert.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert.go new file mode 100644 index 00000000..80640eb7 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert.go @@ -0,0 +1,160 @@ +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/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 + } + running, rs := p.entries.Need(ctx, run.Fingerprint) + applied, as := p.entries.Need(ctx, wantFp) + if rs != mcc.EntryAnswered || as != mcc.EntryAnswered { + return "", "", false // asked: its answer wakes a pass + } + 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, run.Fingerprint, wantFp, err) + return "", "", false + } + 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]) +} 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/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index 5b287c86..f453d867 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -134,7 +134,7 @@ func TestManifest(t *testing.T) { 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", - "seat:mesh-catalog.registrations", "seat:mesh-catalog.revision", "seat:mesh-catalog.seat-definitions", // ADR 0636 §4: the seat list + "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-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", // ADR 0565 §9, §11: the seat alone "seat:node-backup.backed-up", "seat:node-backup.now", diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index d4bc86ac..cc25c1c1 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -67,6 +67,7 @@ "invokes": [ "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", + "seat:mesh-catalog.entry", "seat:mesh-catalog.registrations", {"verb": "seat:mesh-catalog.revision", "fields": ["none-yet"]}, "seat:mesh-catalog.seat-definitions", From cd51fec81a97d538f609fb8f44e8fadf861a6206 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:49:25 +0100 Subject: [PATCH 12/12] =?UTF-8?q?B2l=20AS17=20(4):=20=C2=A7298's=20three?= =?UTF-8?q?=20order=20edges=20are=20acting=20waits=20of=20the=20assigner?= =?UTF-8?q?=20under=20`order.`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new node-backup and bus module holders run before a module whose kept data's form changes moves; at a revert the node-engine goes back last on its node; the reverted assigner and gate are wanted only once take-in.taken names the trunk's head (A-260). Kept in the state waits, said as wait-held and wait-ended, read and released by assigner_waits and assigner_release. The manifest consumes the indexer's take-in.taken and its version is 0.3 (A-245). Builds: design 47 G §298, A-245, A-258, A-259, A-260 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-assigner/actingwaits.go | 2 +- .../cmd/mesh-delivery-assigner/buildended.go | 7 +- .../cmd/mesh-delivery-assigner/main.go | 2 +- .../cmd/mesh-delivery-assigner/orderwaits.go | 296 +++++++++++++++ .../mesh-delivery-assigner/orderwaits_test.go | 355 ++++++++++++++++++ .../mesh-delivery-assigner/outside_tools.go | 6 +- .../cmd/mesh-delivery-assigner/pass.go | 11 +- .../cmd/mesh-delivery-assigner/revert.go | 33 +- .../cmd/mesh-delivery-assigner/world.go | 2 + modules/mesh-delivery-assigner/go.mod | 1 + .../internal/acting/acting.go | 39 +- .../mesh-delivery-assigner/manifest_test.go | 2 +- modules/mesh-delivery-assigner/module.json | 3 +- 13 files changed, 740 insertions(+), 19 deletions(-) create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/orderwaits.go create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/orderwaits_test.go diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go index 8df05f4e..4b7f7095 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go @@ -55,7 +55,7 @@ func (p *passer) entryEdges(node, module string) []seat.Edge { 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) { + if w, err := acting.Decode(raw); err == nil && w.Released && (acting.Is(key) || acting.IsOrder(key)) { out[key+"@"+w.Fp] = true } } 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 98c0465d..899c4ade 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildended.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildended.go @@ -105,7 +105,7 @@ func (r *releasedVersions) of(module string, source int64) (assign.KeptBuild, bo } // hearBuildsAndReleases registers the hub's takers of build.ended and version-released, each waking the pass; before the hub starts. -func hearBuildsAndReleases(hub *process.EventHub, ends *builtEnds, released *releasedVersions, wake func(), logf func(string, ...any)) error { +func hearBuildsAndReleases(hub *process.EventHub, ends *builtEnds, released *releasedVersions, taken *takenSources, wake func(), logf func(string, ...any)) error { taker := func(put func([]byte) error) func(jetstream.Msg) { return func(m jetstream.Msg) { if err := put(m.Data()); err != nil { @@ -120,5 +120,8 @@ func hearBuildsAndReleases(hub *process.EventHub, ends *builtEnds, released *rel if err := hub.Handle(buildEndedSubjects, taker(ends.put)); err != nil { return err } - return hub.Handle(meshcatalog.SubjectVersionReleased, taker(released.put)) + if err := hub.Handle(meshcatalog.SubjectVersionReleased, taker(released.put)); err != nil { + return err + } + return hub.Handle(takenSubject, taker(taken.put)) } 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 d671bb71..c39440fe 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -604,7 +604,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, logger.Printf("a pipeline's end or a registration is not heard, so the wants keep the trunk-only form until the assigner starts again: %v", err) } } - if err := hearBuildsAndReleases(hub, &w.ends, &w.released, wake, logger.Printf); err != nil { + if err := hearBuildsAndReleases(hub, &w.ends, &w.released, &w.taken, wake, logger.Printf); err != nil { logger.Printf("a build's end or a version's release is not heard, so no want names a build's parts, its keeping node or its version until the assigner starts again: %v", err) } hearReadsChanged(ctx, js, hub, reads, findings, gate.EventsLive, logger.Printf) 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..2a921b0f --- /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(trunkBranch) >= 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(trunkBranch) { + 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..36b3835f --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/orderwaits_test.go @@ -0,0 +1,355 @@ +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-builder/builds" + "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}} + bkey, _ := builds.BuilderBuildKey(m.name, m.trunk) + built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: mdigest}) + p.w.put(buckets.Builds, bkey, built) + 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) + } + on, _ := presenceValue(true) + p.w.put(buckets.Presence, "ace", on) + 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)}, 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_tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/outside_tools.go index ae75ce2f..461a9e01 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 @@ -54,7 +54,7 @@ func (s *putTools) doWaits(args map[string]any) (any, error) { if err != nil || !found { continue } - if acting.Is(key) { + 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 { @@ -146,9 +146,9 @@ func (s *putTools) doReleaseActing(node, module, why, by string) (any, error) { w acting.Wait } var held []one - prefix := acting.Prefix + node + "." + module + "." + prefix, orderPrefix := acting.Prefix+node+"."+module+".", acting.OrderPrefix+node+"."+module+"." for _, k := range keys { - if !strings.HasPrefix(k, prefix) { + if !strings.HasPrefix(k, prefix) && !strings.HasPrefix(k, orderPrefix) { continue } raw, _, found, err := s.st.get(waitsState, k) 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 9eb26f44..86b1e644 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -104,6 +104,11 @@ 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 @@ -291,7 +296,9 @@ func (p *passer) wants(ctx context.Context) { // 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(trunkBranch).nodes, wanted) }() + defer func() { p.orderWaits(ctx, viewOf(trunkBranch).nodes, wanted) }() for name := range names { groups := p.groups(name, nodesOf[name], assignedBranches[name]) for gi, g := range groups { @@ -358,7 +365,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)) } - m.Acting = p.actingOf(name, released) + 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) { diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert.go index 80640eb7..5b61ea2d 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/revert.go @@ -9,6 +9,7 @@ import ( "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" @@ -108,16 +109,10 @@ func (p *passer) revertCrosses(ctx context.Context, node, module, wantFp string) if !reverted { return "", "", false } - running, rs := p.entries.Need(ctx, run.Fingerprint) - applied, as := p.entries.Need(ctx, wantFp) - if rs != mcc.EntryAnswered || as != mcc.EntryAnswered { + changes, known := p.formChange(ctx, module, run.Fingerprint, wantFp) + if !known { return "", "", false // asked: its answer wakes a pass } - 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, run.Fingerprint, wantFp, err) - return "", "", false - } var names, forms []string for _, c := range changes { f := 0 @@ -158,3 +153,25 @@ func (p *passer) revertFinding(o assign.Observed, raised bool) { 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/world.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go index e591751b..b9a8a0d8 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -45,6 +45,8 @@ type world struct { ends builtEnds // released are the catalog's version-released heard, by module and source revision (buildended.go). released releasedVersions + // 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 index e4607f99..abc217c1 100644 --- a/modules/mesh-delivery-assigner/internal/acting/acting.go +++ b/modules/mesh-delivery-assigner/internal/acting/acting.go @@ -27,6 +27,21 @@ const ( // 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 } @@ -44,6 +59,8 @@ type Wait struct { 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"` @@ -53,6 +70,14 @@ type Wait struct { // 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) } @@ -92,7 +117,19 @@ func Pending(edges []meshwants.Edge, runs func(e meshwants.Edge) bool) []meshwan 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: 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)} + 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 { diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index f453d867..cc62a74b 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -130,7 +130,7 @@ 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. - 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-catalog.version-released", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", builds.BuilderModule + "." + builds.BuildEndedEvent, buckets.TellerModule + "." + tellerevents.AskEnded, "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-catalog.version-released", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", builds.BuilderModule + "." + builds.BuildEndedEvent, buckets.IndexerModule + ".take-in.taken", buckets.TellerModule + "." + tellerevents.AskEnded, "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", diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index cc25c1c1..e2a37e59 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.2", + "version": "0.3", "slug": "assigner", "runs-as": "mesh-delivery-assigner", "claims": [ @@ -54,6 +54,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-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended",