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 486ac10..f9f8df8 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -524,7 +524,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, p.acts = newPutActs(bus, wake, logger.Printf, w.handedAway) } if bus != nil { - p.seat = newSeatActs(bus, wake, logger.Printf, w.handedAway) + p.seat = newSeatActs(bus, wake, logger.Printf) } if js != nil { p.publish = streamPublisher(ctx, js, thisNode) 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 e7a6f36..1d5bbd2 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -59,12 +59,10 @@ type passer struct { // nodeFailed is, by node and module, the words of the condition last raised for a failure that is the node's own (ADR 0557 // §1, issue 1496): raised once per words, cleared once when it ends. nodeFailed map[string]string - // callSaid is, by the key of a refused controller call's condition, what is raised (issue 774): by this process, or by - // a previous one and taken over from the findings. - callSaid map[string]*callSaid - // callsSwept says the first pass took over the refused calls' conditions a previous process raised (sweepCalls). - callsSwept bool - // The seat mesh-placement's holder (seat.go, ADR 0567): its calls of the controller (nil: none is made), how it says + // callsCleared says the first pass cleared the conditions of refused controller calls a previous process raised + // (clearStaleCalls): the assigner makes no such call now (issue 774). + callsCleared bool + // The seat mesh-placement's holder (seat.go, ADR 0567): its acts (nil: none is made), how it says // the seat's events (nil: none is said), the proposals it said, and what Intended computed by proposal and base. seat *seatActs publish publisher @@ -156,8 +154,8 @@ func (p *passer) wants(ctx context.Context) { defer p.unplacedFindings() defer p.seatHolders(ctx) defer p.policySplits(manifests, chosen) - differ := p.withCalls(p.w.placementDiffers()) - p.sayCalls(differ) + p.clearStaleCalls() + differ := p.w.placementDiffers() frozen := p.sayPlacement(differ) placed := p.w.placedNodes() puts := p.putsByModule() diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index 41347bd..8412f40 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -8,7 +8,6 @@ import ( "fmt" "slices" "strings" - "sync" "testing" "time" @@ -1003,178 +1002,37 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) { } } -// refusingBus is the assigner's process account against a controller that refuses the calls whose verb is in refuse. -type refusingBus struct { - fakeBus - refuse map[string]string - // pins are the controller's pins its placement-export answers, by node. - mu sync.Mutex - pins map[string]map[string]delivery.Provider -} - -func (b *refusingBus) setPins(node string, pins map[string]delivery.Provider) { - b.mu.Lock() - defer b.mu.Unlock() - if b.pins == nil { - b.pins = map[string]map[string]delivery.Provider{} +// 774/S25/AC1 (ADR 0567 §8): an approved placement change is written and followed with its warrant recorded and +// placement-changed said, and no verb of the controller is called; the refused-call conditions a previous process raised are +// cleared once, and said again at a later pass while the findings cannot be read. +func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) { + p := newPasser(buckets{}, time.Date(2026, 11, 2, 9, 0, 0, 0, time.UTC)) + bus := &fakeBus{calls: make(chan string, 8)} + p.seat = newSeatActs(bus, func() {}, func(string, ...any) {}) + var said []string + p.publish = func(event string, _ any, _ string) error { said = append(said, event); return nil } + prop := delivery.PlacementProposal{ID: "p-one", By: "operator@laptop", Why: "move web"} + change := delivery.ChangeOf(delivery.Placement{Node: "anchor"}, delivery.Placement{Node: "anchor", Modules: []delivery.PlacedModule{{Module: "web"}}}) + p.followWrite(prop, delivery.Answer{}, []delivery.PlacementChange{change}) + if len(bus.calls) != 0 || !slices.Equal(said, []string{delivery.PlacementChangedEvent}) { + t.Fatalf("calls = %d, said = %v", len(bus.calls), said) } - b.pins[node] = pins -} -func (b *refusingBus) Call(key string, body any) (json.RawMessage, error) { - if key == exportCall { - b.mu.Lock() - defer b.mu.Unlock() - node, _ := body.(map[string]any)["node"].(string) - answer, _ := json.Marshal(map[string]delivery.Placement{node: {V: delivery.Version, Node: node, Pins: b.pins[node]}}) - return json.Marshal(map[string]any{"ok": true, "answer": json.RawMessage(answer)}) - } - for verb, words := range b.refuse { - if strings.HasSuffix(key, "."+verb) { - return nil, errors.New(words) - } - } - return json.RawMessage(`{"ok":true}`), nil -} - -// Issue 774: a call of the controller it refuses is a condition, never only a journal line: raised once, urgent, naming the verb, -// the node, the module and the refusal; cleared when a later call for the pair is answered, or, for a module's place, once the -// pair differed and the two sources agree. -func TestARefusedControllerCallIsACondition(t *testing.T) { - p := newPasser(buckets{}, time.Date(2026, 10, 30, 21, 4, 0, 0, time.UTC)) var conds []asks.Condition p.say = func(c asks.Condition) error { conds = append(conds, c); return nil } - bus := &refusingBus{refuse: map[string]string{"assign": `assign takes no argument "why" — it takes "build", "module", "node"; nothing was done`}} - woke := make(chan struct{}, 4) - p.seat = newSeatActs(bus, func() { woke <- struct{}{} }, func(string, ...any) {}, func() bool { return false }) - assignWeb := assign.ControllerCall{Verb: "assign", Node: "anchor", Module: "web", Args: map[string]any{"node": "anchor", "module": "web"}} - call := func(c assign.ControllerCall) { - p.seat.callAll([]assign.ControllerCall{c}) - <-woke + fails := 1 + p.findings.Keys = func() ([]string, error) { + if fails > 0 { + fails-- + return nil, errors.New("the bucket is not there yet") + } + return []string{"module.anchor.web.controller-call", "module.laptop.db.controller-pin", "module.anchor.web.neither"}, nil } - differ := []assign.Pair{{Node: "anchor", Module: "web", Only: assign.OnlyPlacement}} - - call(assignWeb) - p.sayCalls(differ) - p.sayCalls(differ) - if len(conds) != 1 || conds[0].State != asks.StateRaised || conds[0].Key != "mesh-delivery-assigner.controller-call.anchor.web" || - !strings.Contains(conds[0].Explanation, `takes no argument "why"`) || !strings.Contains(conds[0].Headline, "assign web on anchor") { - t.Fatalf("after a refused assign, said = %+v", conds) - } - - bus.refuse = nil - call(assignWeb) - p.sayCalls(nil) - if len(conds) != 2 || conds[1].State != asks.StateCleared || conds[1].Key != conds[0].Key { - t.Fatalf("after the assign is answered, said = %+v", conds) - } - - // Refused again, then assigned by hand: cleared once the pair, seen differing, agrees; not before it was seen differing. - bus.refuse = map[string]string{"assign": "refused"} - call(assignWeb) - p.sayCalls(nil) - if len(conds) != 3 || conds[2].State != asks.StateRaised { - t.Fatalf("a second refusal is not raised: %+v", conds) - } - p.sayCalls(nil) - if len(conds) != 3 { - t.Fatalf("cleared before the pair was seen differing: %+v", conds) - } - p.sayCalls(differ) - p.sayCalls(nil) - if len(conds) != 4 || conds[3].State != asks.StateCleared || !strings.Contains(conds[3].Resolved, "agree") { - t.Fatalf("the hand assign does not clear it: %+v", conds) - } - p.sayCalls(nil) - if len(conds) != 4 { - t.Fatalf("said again after the clear: %+v", conds) - } -} - -// Issue 774: a refused pin's condition, and one a previous process raised, clear once the pair agrees after it was seen differing: -// the controller's pins (its placement-export, read again when its compositions change) against the placement's, the controller's -// assignments against the placement for a module's place. A start whose findings cannot be read takes them over at a later pass. -func TestARefusedCallClearsWhenTheSourcesAgree(t *testing.T) { - store := delivery.Provider{Node: "anchor", Module: "store"} - pinDB := assign.ControllerCall{Verb: "pin", Node: "laptop", Module: "db", - Args: map[string]any{"node": "laptop", "provision": "db", "from": "anchor", "module": "store"}} - placeWeb := assign.ControllerCall{Verb: "assign", Node: "anchor", Module: "web", Args: map[string]any{"node": "anchor", "module": "web"}} - webDiffers := []assign.Pair{{Node: "anchor", Module: "web", Only: assign.OnlyPlacement}} - for name, c := range map[string]struct { - call *assign.ControllerCall // refused in this process; nil: raised by a previous one - findings []string // the findings a previous process left - keysFail int // how many first reads of the findings fail - differ []assign.Pair // the pairs the two sources name apart, while they differ - agree func(*refusingBus) // the person's hand call - key string - }{ - "a pin refused, pinned by hand": {call: &pinDB, agree: func(b *refusingBus) { b.setPins("laptop", map[string]delivery.Provider{"db": store}) }, - key: "mesh-delivery-assigner.controller-pin.laptop.db"}, - "a place refused, assigned by hand": {call: &placeWeb, differ: webDiffers, agree: func(*refusingBus) {}, - key: "mesh-delivery-assigner.controller-call.anchor.web"}, - "a pin raised before a start": {findings: []string{"module.laptop.db.controller-pin"}, keysFail: 1, - agree: func(b *refusingBus) { b.setPins("laptop", map[string]delivery.Provider{"db": store}) }, - key: "mesh-delivery-assigner.controller-pin.laptop.db"}, - "a place raised before a start": {findings: []string{"module.anchor.web.controller-call"}, differ: webDiffers, agree: func(*refusingBus) {}, - key: "mesh-delivery-assigner.controller-call.anchor.web"}, - } { - t.Run(name, func(t *testing.T) { - p := newPasser(buckets{}, time.Date(2026, 10, 30, 21, 4, 0, 0, time.UTC)) - var conds []asks.Condition - p.say = func(c asks.Condition) error { conds = append(conds, c); return nil } - fails := c.keysFail - p.findings.Keys = func() ([]string, error) { - if fails > 0 { - fails-- - return nil, errors.New("the bucket is not there yet") - } - return c.findings, nil - } - bus := &refusingBus{refuse: map[string]string{"pin": "refused", "assign": "refused"}} - woke := make(chan struct{}, 16) - p.seat = newSeatActs(bus, func() { woke <- struct{}{} }, func(string, ...any) {}, func() bool { return false }) - // The placement pins db on laptop; the controller does not. - raw, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "laptop", Pins: map[string]delivery.Provider{"db": store}}) - key, _ := delivery.LegacyPlacementKey("laptop") - p.w.put(delivery.PlacementBucket, key, raw) - // pass is a pass, then one more for each background read it started. - pass := func(differ []assign.Pair) { - p.sayCalls(differ) - for { - select { - case <-woke: - p.sayCalls(differ) - case <-time.After(20 * time.Millisecond): - return - } - } - } - if c.call != nil { - p.seat.callAll([]assign.ControllerCall{*c.call}) - <-woke - } - pass(c.differ) - pass(c.differ) - for _, cond := range conds { - if cond.State == asks.StateCleared { - t.Fatalf("cleared while the sources differ: %+v", conds) - } - } - c.agree(bus) - p.w.put(delivery.ControllerComposedBucket, "laptop.x", []byte("moved")) // a pin changes a composition - pass(nil) - if len(conds) == 0 { - t.Fatal("nothing was raised") - } - last := conds[len(conds)-1] - if last.Key != c.key || last.State != asks.StateCleared || !strings.Contains(last.Resolved, "agree") { - t.Fatalf("the hand call does not clear it: %+v", conds) - } - n := len(conds) - pass(nil) - if len(conds) != n { - t.Errorf("said again after the clear: %+v", conds[n:]) - } - }) + p.clearStaleCalls() + p.clearStaleCalls() + p.clearStaleCalls() + if len(conds) != 2 || conds[0].State != asks.StateCleared || conds[0].Key != "mesh-delivery-assigner.controller-call.anchor.web" || + conds[1].Key != "mesh-delivery-assigner.controller-pin.laptop.db" { + t.Fatalf("the old conditions cleared = %+v", conds) } } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go index 0e90670..8aa635a 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go @@ -17,7 +17,6 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/compose" "git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/compose/judge" @@ -59,31 +58,19 @@ func streamPublisher(ctx context.Context, js jetstream.JetStream, node string) p } } -// seatActs keeps what the seat's calls of the controller came to while this process runs: the export read again at a -// seed's warrant, and each call that keeps the controller's assignments in step (§8). +// seatActs keeps what the seat's calls came to while this process runs: the export read again at a seed's warrant. +// The seat calls no verb of the controller to keep its assignments in step: placement is the one source (ADR 0567 §8, +// issue 774). type seatActs struct { mu sync.Mutex bus caller wake func() logf func(string, ...any) export map[string]exportCheck - // calls is, by pair (node and module, or node and provision for a pin), what the last call for it came to. - calls map[string]string - // outcomes is, by the key of a call's condition, the last call made for its pair once answered or refused: refused - // "" when the controller answered it (issue 774: a refused call is a condition, never only a journal line). - outcomes map[string]callOutcome - // pins is, by node, the controller's pins as its placement-export last answered them (pinsOf). - pins map[string]pinRead // rec records the seat's acts in the hand-act log (ADR 0565 §11). rec handacts.Recorder } -// callOutcome is what one call of the controller came to: the call, and the controller's refusal ("" when it answered). -type callOutcome struct { - call assign.ControllerCall - refused string -} - // exportCheck is the export read again for one proposal: running, its answer, or why it was not read. type exportCheck struct { running bool @@ -91,9 +78,8 @@ type exportCheck struct { err string } -func newSeatActs(bus caller, wake func(), logf func(string, ...any), leaseAway func() bool) *seatActs { - return &seatActs{bus: bus, wake: wake, logf: logf, export: map[string]exportCheck{}, calls: map[string]string{}, - outcomes: map[string]callOutcome{}, pins: map[string]pinRead{}, rec: handacts.Recorder{Ask: bus.Call, LeaseAway: leaseAway}} +func newSeatActs(bus caller, wake func(), logf func(string, ...any)) *seatActs { + return &seatActs{bus: bus, wake: wake, logf: logf, export: map[string]exportCheck{}, rec: handacts.Recorder{Ask: bus.Call}} } // exportOf is the export read again for a proposal, asked once in the background. @@ -159,245 +145,35 @@ func (a *seatActs) handAct(prop delivery.PlacementProposal) { }() } -// callAll makes the controller's calls in order in the background, each said in the journal and kept by its pair. -func (a *seatActs) callAll(calls []assign.ControllerCall) { - if len(calls) == 0 { +// clearStaleCalls clears, once, the conditions of the refused controller calls a previous process raised (findings of the +// kinds controller-call and controller-pin): the assigner calls no verb of the controller any more (ADR 0567 §8, issue 774), +// so nothing would clear them. It is tried again at the next pass while the findings cannot be read. +func (p *passer) clearStaleCalls() { + if p.callsCleared || p.say == nil || p.findings.Keys == nil { return } - a.mu.Lock() - for _, c := range calls { - a.calls[c.Node+"."+c.Module] = c.Key() + " is asked" - } - a.mu.Unlock() - go func() { - defer a.wake() - for _, c := range calls { - words, refused := c.Key()+" answered", "" - if _, err := a.bus.Call("seat:"+catalogue.ControllerSeatName+"."+c.Verb, c.Args); err != nil { - refused = err.Error() - words = c.Key() + " refused: " + refused - } - a.logf("keeping the controller in step with placement: %s", words) - a.mu.Lock() - a.calls[c.Node+"."+c.Module] = words - a.outcomes[assign.CallConditionKey(c)] = callOutcome{call: c, refused: refused} - a.mu.Unlock() - } - }() -} - -// outcomesNow is a copy of every call's last outcome, by the key of its condition. -func (a *seatActs) outcomesNow() map[string]callOutcome { - if a == nil { - return nil - } - a.mu.Lock() - defer a.mu.Unlock() - return maps.Clone(a.outcomes) -} - -// pinRead is the controller's pins of one node as its placement-export answered them, for the generation of the -// controller's compositions it was asked at: a pin made at the controller changes a composition, which the assigner -// watches, so a read is asked again only after such a change, never on a timer. -type pinRead struct { - gen uint64 - running bool - known bool - pins map[string]delivery.Provider -} - -// pinsOf is the controller's pins of node as last read, and whether a read answered; a read is asked in the background -// when none was made at gen, and wakes a pass when it answers. -func (a *seatActs) pinsOf(node string, gen uint64) (map[string]delivery.Provider, bool) { - if a == nil { - return nil, false - } - a.mu.Lock() - defer a.mu.Unlock() - r, ok := a.pins[node] - if !ok || (r.gen != gen && !r.running) { - r.gen, r.running = gen, true - a.pins[node] = r - go func() { - defer a.wake() - raw, err := a.bus.Call(exportCall, map[string]any{"node": node}) - var pins map[string]delivery.Provider - var known bool - if err == nil { - var export map[string]delivery.Placement - if export, err = assign.ExportOf(raw); err == nil { - pins, known = export[node].Pins, true - } - } - if err != nil { - a.logf("the controller's pins of %s were not read, so a refused pin's condition there waits: %v", node, err) - } - a.mu.Lock() - now := a.pins[node] - now.running = false - if known { - now.pins, now.known = pins, true - } - a.pins[node] = now - a.mu.Unlock() - }() - } - return r.pins, r.known -} - -// sayCalls makes a refused call of the controller loud (issue 774): the condition of each pair whose last call was refused -// is raised once per refusal, naming the verb, the node, the module and the controller's words. It is cleared when a later -// call for the pair is answered, or once the pair, seen differing since the refusal, agrees between the two sources (a -// person made the call by hand): for a module's place the controller's assignments and the placement, for a pin the -// controller's pins (its placement-export, read again after its compositions change) and the placement's. The first pass -// after a start takes over what a previous process raised (its findings) and clears each whose pair agrees; until the -// findings are read it is tried again at every pass. A say that fails is made again at the next pass. -func (p *passer) sayCalls(differ []assign.Pair) { - if p.say == nil { - return - } - if p.callSaid == nil { - p.callSaid = map[string]*callSaid{} - } - differs := map[string]bool{} - for _, d := range differ { - differs[d.Node+"."+d.Module] = true - } - outcomes := p.seat.outcomesNow() - if !p.callsSwept { - p.callsSwept = p.sweepCalls(outcomes) - } - for key, o := range outcomes { - said := p.callSaid[key] - switch { - case o.refused != "" && (said == nil || said.adopted || said.refused != o.refused): - if err := p.say(assign.CallRefusedCondition(o.call, o.refused, true, "")); err != nil { - p.logf("that %s was refused could not be said, and is said at the next pass: %v", o.call.Key(), err) - continue - } - p.callSaid[key] = &callSaid{call: o.call, refused: o.refused} - case o.refused == "" && said != nil: - if err := p.say(assign.CallRefusedCondition(o.call, "", false, o.call.Key()+" was answered.")); err != nil { - p.logf("the condition of %s could not be cleared, and is cleared at the next pass: %v", o.call.Key(), err) - continue - } - delete(p.callSaid, key) - } - } - var placements map[string]delivery.Placement - for key, said := range p.callSaid { - c := said.call - var apart bool - if c.CallKind() == assign.KindControllerCall { - apart = differs[c.Node+"."+c.Module] - } else { - theirs, known := p.seat.pinsOf(c.Node, p.w.bucketGen(delivery.ControllerComposedBucket)) - if !known { - continue - } - if placements == nil { - placements, _ = p.seatPlacements() - } - ours, has := placements[c.Node].Pins[c.Module] - to, had := theirs[c.Module] - apart = has != had || ours != to - } - if apart { - said.differed = true - continue - } - if !said.differed { - continue // not seen differing since the refusal: the world may not hold the write yet - } - if err := p.say(assign.CallRefusedCondition(c, "", false, agreeWords(c))); err != nil { - p.logf("the condition of %s could not be cleared, and is cleared at the next pass: %v", c.Key(), err) - continue - } - if o, ok := outcomes[key]; ok { - p.seat.forgetOutcome(key, o) - } - delete(p.callSaid, key) - } -} - -// sweepCalls takes over the refused calls' conditions a previous process raised, from the findings: each is followed as -// one seen differing, so it clears at the first pass its pair agrees. It answers whether the findings were read. -func (p *passer) sweepCalls(outcomes map[string]callOutcome) bool { - if p.findings.Keys == nil { - return true - } keys, err := p.findings.Keys() if err != nil { - p.logf("the findings were not read, so the refused calls a previous process raised are taken over at the next pass: %v", err) - return false + p.logf("the findings were not read, so the refused controller calls a previous process raised are cleared at the next pass: %v", err) + return } + p.callsCleared = true for _, k := range keys { rest, ok := strings.CutPrefix(k, "module.") if !ok { continue } for _, kind := range []string{assign.KindControllerCall, assign.KindControllerPin} { - pair, ok := strings.CutSuffix(rest, "."+kind) - node, module, two := strings.Cut(pair, ".") - if !ok || !two { - continue + if pair, ok := strings.CutSuffix(rest, "."+kind); ok { + if node, module, two := strings.Cut(pair, "."); two { + if err := p.say(assign.CallClearedCondition(kind, node, module)); err != nil { + p.callsCleared = false + p.logf("the condition of the old %s call for %s on %s could not be cleared, and is cleared at the next pass: %v", kind, module, node, err) + } + } } - verb := "assign" - if kind == assign.KindControllerPin { - verb = "pin" - } - c := assign.ControllerCall{Verb: verb, Node: node, Module: module} - key := assign.CallConditionKey(c) - if _, made := outcomes[key]; made || p.callSaid[key] != nil { - continue - } - p.callSaid[key] = &callSaid{call: c, differed: true, adopted: true} } } - return true -} - -func agreeWords(c assign.ControllerCall) string { - if c.CallKind() == assign.KindControllerPin { - return "the controller's pins and the placement agree on " + c.Module + " for " + c.Node + "." - } - return "the controller's assignments and the placement agree on " + c.Module + " on " + c.Node + "." -} - -// callSaid is a raised call's condition: its call, the refusal it said, whether its pair was seen differing since, and -// whether it was taken over from a previous process. -type callSaid struct { - call assign.ControllerCall - refused string - differed bool - adopted bool -} - -// forgetOutcome drops a refused call's outcome once its condition is cleared, unless a later call replaced it. -func (a *seatActs) forgetOutcome(key string, o callOutcome) { - a.mu.Lock() - defer a.mu.Unlock() - if now, ok := a.outcomes[key]; ok && now.refused == o.refused && now.call.Verb == o.call.Verb { - delete(a.outcomes, key) - } -} - -// callOf is what the last call for a pair came to, "" when none was made. -func (a *seatActs) callOf(node, module string) string { - if a == nil { - return "" - } - a.mu.Lock() - defer a.mu.Unlock() - return a.calls[node+"."+module] -} - -// withCalls names, on each pair the two sources do not both name, the call the assigner made for it (§8). -func (p *passer) withCalls(pairs []assign.Pair) []assign.Pair { - for i := range pairs { - pairs[i].Call = p.seat.callOf(pairs[i].Node, pairs[i].Module) - } - return pairs } // sayWaits raises, once, that the seat waits for the states not yet bound, and clears it once, when all are. @@ -780,7 +556,7 @@ func (p *passer) lays(ctx context.Context) (func(string) bool, func(string) bool // warranted writes an approved proposal (§5): for the seed, after the export is read again and found unchanged (§7); // each changed key by compare-and-set at the revision it was laid over; then the warrant recorded, the candidates and the -// proposal deleted, placement-changed said, and the controller kept in step (§8). +// proposal deleted, and placement-changed said. func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, rev uint64, akey string, current map[string]delivery.Placement, revs map[string]uint64) { if err := p.performs(akey, assign.PlacementAct(prop)); err != nil { p.endProposal(ctx, prop, "its warrant does not bind these changes, so nothing is written: "+err.Error()) @@ -831,7 +607,7 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, // Another writer came first (a hand-back): what is written stands and is followed through; the rest is laid // again over the keys as this write left them. p.logf("placement %s is not written at revision %d (%v): the rest of the proposal %s is laid again", w.Key, w.Base, err, prop.ID) - p.followWrite(prop, answer, changes, current, writtenLaid) + p.followWrite(prop, answer, changes) if rest, ok := assign.Rest(prop, writtenLaid); ok { p.layAgain(ctx, rest, rev, after, afterRevs, "a key moved while it was written, after "+strings.Join(sortedNodes(writtenLaid), ", ")+" were") } else { @@ -857,22 +633,17 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, p.deletePulses(ctx, prop.ID) _ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID) p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil) - p.followWrite(prop, answer, changes, current, writtenLaid) + p.followWrite(prop, answer, changes) } -// followWrite follows the keys a warrant wrote: the warrant recorded, placement-changed said, and, until the lease -// write, the controller's assignments kept in step for them (§8). The seed calls no controller verb: it is what the -// controller holds already (§9). -func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange, - before map[string]delivery.Placement, written map[string]*delivery.Placement) { +// followWrite follows the keys a warrant wrote: the warrant recorded and placement-changed said. No verb of the +// controller is called: the placement is the one source of where modules run (ADR 0567 §8, issue 774). +func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange) { if len(changes) == 0 { return } p.seat.record(answer.Warrant) p.sayChanged(prop, answer, changes) - if p.seat != nil && !p.w.handedAway() && !prop.Seed { - p.seat.callAll(assign.ControllerCalls(before, written)) - } } func sortedNodes(m map[string]*delivery.Placement) []string { diff --git a/modules/mesh-delivery-assigner/internal/assign/placement.go b/modules/mesh-delivery-assigner/internal/assign/placement.go index 131512d..bef6b49 100644 --- a/modules/mesh-delivery-assigner/internal/assign/placement.go +++ b/modules/mesh-delivery-assigner/internal/assign/placement.go @@ -23,8 +23,6 @@ import ( // OnlyPlacement. type Pair struct { Node, Module, Only string - // Call is what became of the assigner's call of the controller's verb for the pair, in words, when it made one. - Call string } // The source that names a pair alone. @@ -107,9 +105,6 @@ func PlacementCondition(p Pair, raised bool) asks.Condition { } else { needs = fmt.Sprintf("assign %s to %s at the controller, as the approved placement says, or propose taking it off", p.Module, p.Node) } - if p.Call != "" { - needs += "; the assigner's call: " + p.Call - } return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised, Summary: headline, Headline: headline, Needs: needs, Explanation: fmt.Sprintf("%s%s. %s names %s on %s and the other source does not. Until ADR 0547's third milestone both must "+ diff --git a/modules/mesh-delivery-assigner/internal/assign/seat.go b/modules/mesh-delivery-assigner/internal/assign/seat.go index e0ac6f8..3c07d9a 100644 --- a/modules/mesh-delivery-assigner/internal/assign/seat.go +++ b/modules/mesh-delivery-assigner/internal/assign/seat.go @@ -3,7 +3,6 @@ package assign import ( "encoding/json" "fmt" - "sort" "strings" "time" @@ -118,112 +117,19 @@ func Writes(p delivery.PlacementProposal, laid map[string]*delivery.Placement) ( return out, nil } -// ControllerCall is one call of the controller's seat verbs the holder makes after writing an approved proposal, until -// the lease write (§8): assign, unassign, pin or unpin, with its arguments. -type ControllerCall struct { - Verb string - Args map[string]any - // Node and Module name the pair the call keeps in step (a pin names no module). - Node, Module string -} - -// Key names the call, as the condition of its pair says it. -func (c ControllerCall) Key() string { - return fmt.Sprintf("mesh-controller.%s %s %s", c.Verb, c.Node, c.Module) -} - -// ControllerCalls is every call that brings the controller's assignments and pins to the placement after: assign for a -// module placed on a node before not, unassign for one taken off, pin for a pin set or changed, unpin for one removed. In -// node order, unassigns before assigns, so a module moved between nodes is first taken off. -// -// Each call carries only the arguments the controller's seat contract declares for its verb (catalogue.ControllerVerbs): -// the controller refuses any other argument and does nothing (issue 774: a "why" added to every call had every approved -// placement refused since 2026-10-28). The proposal and its warrant are in the assigner's own record instead: the -// placement-changed event and the hand-act log. -func ControllerCalls(before map[string]delivery.Placement, after map[string]*delivery.Placement) []ControllerCall { - nodes := make([]string, 0, len(after)) - for node := range after { - nodes = append(nodes, node) - } - sort.Strings(nodes) - var off, on, pins []ControllerCall - for _, node := range nodes { - was, now := before[node], delivery.Placement{} - if a := after[node]; a != nil { - now = *a - } - had, has := map[string]bool{}, map[string]bool{} - for _, m := range was.Modules { - had[m.Module] = true - } - for _, m := range now.Modules { - has[m.Module] = true - if !had[m.Module] { - on = append(on, ControllerCall{Verb: "assign", Node: node, Module: m.Module, Args: map[string]any{"node": node, "module": m.Module}}) - } - } - for _, m := range was.Modules { - if !has[m.Module] { - off = append(off, ControllerCall{Verb: "unassign", Node: node, Module: m.Module, Args: map[string]any{"node": node, "module": m.Module}}) - } - } - for _, provision := range sortedKeys(now.Pins) { - to := now.Pins[provision] - if old, ok := was.Pins[provision]; !ok || old != to { - pins = append(pins, ControllerCall{Verb: "pin", Node: node, Module: provision, - Args: map[string]any{"node": node, "provision": provision, "from": to.Node, "module": to.Module}}) - } - } - for _, provision := range sortedKeys(was.Pins) { - if _, ok := now.Pins[provision]; !ok { - pins = append(pins, ControllerCall{Verb: "unpin", Node: node, Module: provision, Args: map[string]any{"node": node, "provision": provision}}) - } - } - } - return append(append(off, on...), pins...) -} - -// CallKind is the kind of the condition of a refused call: one for a module's place (assign, unassign), one for a pin. -func (c ControllerCall) CallKind() string { - if c.Verb == "pin" || c.Verb == "unpin" { - return KindControllerPin - } - return KindControllerCall -} - -// The kinds of a refused controller call's condition (and finding). +// The kinds of the condition (and finding) of a refused controller call, which the assigner raised until it stopped calling +// the controller's verbs (ADR 0567 §8, issue 774). const ( KindControllerCall = "controller-call" KindControllerPin = "controller-pin" ) -// CallConditionKey is the key of the condition of a call refused for its pair (node and module, or node and provision). -func CallConditionKey(c ControllerCall) string { - return delivery.AssignerModule + "." + c.CallKind() + "." + c.Node + "." + c.Module -} - -// CallRefusedCondition is the condition the assigner raises when the controller refused (or did not answer) a call that -// keeps its assignments in step with an approved placement (ADR 0567 §8, issue 774): the placement is written, the -// controller's assignments are not, so nothing new reaches the node (no bus account, no composition). It names the verb, -// the node, the module (or provision) and the controller's words, keyed by the pair, and is cleared (raised false) when a -// later call for the pair is answered, or both sources agree on it, with resolved saying which. -func CallRefusedCondition(c ControllerCall, refusal string, raised bool, resolved string) asks.Condition { - key := CallConditionKey(c) - subject := asks.Scope{Scope: "module", ID: c.Module, Machine: c.Node} - if !raised { - return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared, Resolved: resolved} - } - headline := fmt.Sprintf("the controller refused %s %s on %s", c.Verb, c.Module, c.Node) - if n := len([]rune(headline)); n > asks.HeadlineLength { - headline = string([]rune(headline)[:asks.HeadlineLength]) - } - needs := fmt.Sprintf("call mesh-controller.%s for %s on %s by hand once the refusal's cause is fixed", c.Verb, c.Module, c.Node) - return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised, - Summary: headline, Headline: headline, Needs: needs, - Explanation: fmt.Sprintf("%s%s. An approved placement was written, and the assigner's call %s to keep the controller's "+ - "assignments in step was refused: %s. Until the controller holds it, the placement and the assignments differ, so "+ - "the assigner writes no want for the pair and nothing new reaches %s. This clears when a call for it is answered "+ - "or both sources agree.", asks.NeedsYou, needs, c.Key(), refusal, c.Node)} +// CallClearedCondition clears the condition of a refused controller call a previous process raised, for its pair (node and +// module, or node and provision for a pin). +func CallClearedCondition(kind, node, module string) asks.Condition { + return asks.Condition{Key: delivery.AssignerModule + "." + kind + "." + node + "." + module, + Subject: asks.Scope{Scope: "module", ID: module, Machine: node}, State: asks.StateCleared, + Resolved: "the assigner calls no verb of the controller any more: placement is the one source."} } // PlacementAct is the act an approved proposal's yes binds: writing exactly its changes at its base revisions. diff --git a/modules/mesh-delivery-assigner/internal/assign/seat_test.go b/modules/mesh-delivery-assigner/internal/assign/seat_test.go index d57bcee..a6809d0 100644 --- a/modules/mesh-delivery-assigner/internal/assign/seat_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/seat_test.go @@ -8,7 +8,6 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -106,18 +105,6 @@ func TestThePlacementSeatDecides(t *testing.T) { if err != nil || len(writes) != 2 || writes[0].Key != "anchor.node" || writes[0].Base != 4 || writes[1].Base != 9 { t.Fatalf("writes = %+v, %v", writes, err) } - pinned := *laid["laptop"] - pinned.Pins = map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}} - calls := ControllerCalls(map[string]delivery.Placement{"anchor": anchor, "laptop": laptop}, - map[string]*delivery.Placement{"anchor": laid["anchor"], "laptop": &pinned}) - var said []string - for _, c := range calls { - said = append(said, c.Key()) - } - if !slices.Equal(said, []string{"mesh-controller.unassign anchor web", "mesh-controller.assign laptop web", "mesh-controller.pin laptop db"}) { - t.Errorf("the controller's calls = %v", said) - } - a, err := PlacementAsk("placement.p-one", p, "move web (proposed by operator@laptop)", 7, time.Unix(100, 0)) if err != nil || a.Ask.Check(time.Unix(100, 0)) != nil || a.Ask.Options[0].Level != asks.Approve || a.Ask.Options[0].Performs(PlacementAct(p)) != nil { t.Fatalf("the ask = %+v, %v", a, err) @@ -153,86 +140,3 @@ func quote(s string) string { raw, _ := json.Marshal(s) return string(raw) } - -// Issue 774: every call the assigner makes of the controller carries only the arguments the controller's seat contract -// declares for its verb, and every one it requires; the controller refuses any other argument and does nothing. A "why" -// added to every call (2026-10-28) had each approved placement refused. -func TestControllerCallsFollowTheContract(t *testing.T) { - declared := map[string]map[string]any{} - required := map[string][]string{} - for _, v := range catalogue.ControllerVerbs { - props, _ := v.Input["properties"].(map[string]any) - declared[v.Name] = props - switch r := v.Input["required"].(type) { - case []string: - required[v.Name] = r - } - } - db := map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}} - moved := map[string]delivery.Provider{"db": {Node: "laptop", Module: "store"}} - placed := func(node string, pins map[string]delivery.Provider, modules ...string) delivery.Placement { - p := delivery.Placement{Node: node, Pins: pins} - for _, m := range modules { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) - } - return p - } - ptr := func(p delivery.Placement) *delivery.Placement { return &p } - for name, c := range map[string]struct { - before map[string]delivery.Placement - after map[string]*delivery.Placement - verbs []string - }{ - "assign": {map[string]delivery.Placement{}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", nil, "web"))}, []string{"assign"}}, - "unassign": {map[string]delivery.Placement{"anchor": placed("anchor", nil, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", nil))}, []string{"unassign"}}, - "key deleted": {map[string]delivery.Placement{"anchor": placed("anchor", db, "web")}, map[string]*delivery.Placement{"anchor": nil}, []string{"unassign", "unpin"}}, - "pin set": {map[string]delivery.Placement{"anchor": placed("anchor", nil, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", db, "web"))}, []string{"pin"}}, - "pin changed": {map[string]delivery.Placement{"anchor": placed("anchor", db, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", moved, "web"))}, []string{"pin"}}, - "moved and pinned": {map[string]delivery.Placement{"anchor": placed("anchor", nil, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", nil)), "laptop": ptr(placed("laptop", db, "web"))}, []string{"unassign", "assign", "pin"}}, - } { - calls := ControllerCalls(c.before, c.after) - var verbs []string - for _, call := range calls { - verbs = append(verbs, call.Verb) - props, ok := declared[call.Verb] - if !ok { - t.Errorf("%s: the controller's seat has no verb %q", name, call.Verb) - continue - } - for arg := range call.Args { - if _, ok := props[arg]; !ok { - t.Errorf("%s: %s gives %q, which mesh-controller.%s does not take, so the controller refuses it", name, call.Key(), arg, call.Verb) - } - } - for _, arg := range required[call.Verb] { - if _, ok := call.Args[arg]; !ok { - t.Errorf("%s: %s lacks %q, which mesh-controller.%s requires", name, call.Key(), arg, call.Verb) - } - } - } - if !slices.Equal(verbs, c.verbs) { - t.Errorf("%s: the calls = %v, want %v", name, verbs, c.verbs) - } - } -} - -// Issue 774: a refused call is a condition keyed by its pair, a module's place apart from a pin, naming the verb, the node, the -// module and the refusal; its clear says why. -func TestCallRefusedCondition(t *testing.T) { - assign := ControllerCall{Verb: "assign", Node: "anchor", Module: "web"} - pin := ControllerCall{Verb: "pin", Node: "anchor", Module: "web"} - c := CallRefusedCondition(assign, `assign takes no argument "why"`, true, "") - if c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent || c.Key != "mesh-delivery-assigner.controller-call.anchor.web" || - !strings.Contains(c.Headline, "assign web on anchor") || !strings.Contains(c.Explanation, `takes no argument "why"`) { - t.Errorf("the raised condition = %+v", c) - } - if CallConditionKey(pin) == c.Key { - t.Error("a pin's condition shares the key of a module's place") - } - if cl := CallRefusedCondition(assign, "", false, "answered"); cl.State != asks.StateCleared || cl.Key != c.Key || cl.Resolved != "answered" { - t.Errorf("the clear = %+v", cl) - } - if _, _, raised, err := FindingOf(c, time.Unix(0, 0)); err != nil || !raised { - t.Errorf("the condition is no finding: %v", err) - } -} diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index fd07447..e89a772 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -82,8 +82,7 @@ func TestManifest(t *testing.T) { !slices.Equal(slices.Sorted(slices.Values(m.Streams[0].Events)), slices.Sorted(slices.Values(delivery.PlacementEvents))) { t.Errorf("streams = %+v, want placement carrying the seat's four events for %d days", m.Streams, delivery.PlacementStreamDays) } - if want := []string{"seat:mesh-controller.assign", "seat:mesh-controller.pin", - "seat:mesh-controller.placement-export", "seat:mesh-controller.unassign", "seat:mesh-controller.unpin", + if want := []string{"seat:mesh-controller.placement-export", "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"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { t.Errorf("invokes = %v, want %v", m.Invokes, want) diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index 5ca8293..65993c5 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -49,11 +49,7 @@ "operator-channel" ], "invokes": [ - "seat:mesh-controller.assign", - "seat:mesh-controller.pin", "seat:mesh-controller.placement-export", - "seat:mesh-controller.unassign", - "seat:mesh-controller.unpin", "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted",