diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold.go b/modules/mesh-conditions/cmd/mesh-conditions/hold.go index 8cc8e7b8..8491812e 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold.go @@ -52,8 +52,14 @@ func (k *Keeper) aboutOf(kind, source string, s health.Subject) []about { out = append(out, about{Node: parts[1], Module: parts[0]}) } case health.ScopeSeat: + // The module the placement says holds the seat on that node, or the seat's own name where none is read. if len(parts) >= 2 { - out = append(out, about{Node: parts[len(parts)-1], Module: strings.Join(parts[:len(parts)-1], ".")}) + node, seat := parts[len(parts)-1], strings.Join(parts[:len(parts)-1], ".") + module := k.holders[node+"."+seat] + if module == "" { + module = seat + } + out = append(out, about{Node: node, Module: module}) } case health.ScopeMachine: // The machine itself (presence, network, resolver, disk) is never held; its tools are, while its tool runner is @@ -143,9 +149,8 @@ func (k *Keeper) keepHeld(h held) error { return nil } -// bound is a module's delivery bound: its manifest names none yet, so the default (clause 13 (d)). The state `bounds` -// keeps each key that long (its ttl-seconds), since the runtime writes no key with a time to live of its own; a bound a -// manifest names needs that first. +// bound is a module's delivery bound: its manifest names none yet, so the default (clause 13 (d)). Each key of the state +// `bounds` is written with it as its own time to live, and its expiry is the bound passing. func (k *Keeper) bound(string) time.Duration { if k.Bound > 0 { return k.Bound @@ -211,6 +216,33 @@ func (k *Keeper) Have(key string, value []byte, deleted bool) error { return k.judge(node, module, "") } +// Placement takes one change of the placement: which module holds which seat on a node. +func (k *Keeper) Placement(key string, value []byte, deleted bool) { + node, err := delivery.ParsePlacementKey(key) + if err != nil { + return + } + k.mu.Lock() + defer k.mu.Unlock() + k.init() + for hk := range k.holders { + if strings.HasPrefix(hk, node+".") { + delete(k.holders, hk) + } + } + if deleted { + return + } + p, err := delivery.DecodePlacement(value) + if err != nil { + k.Logf("the placement of %s is not readable, so its seats' holders are not known: %v", node, err) + return + } + for _, h := range p.Holds { + k.holders[node+"."+h.Claim] = h.Module + } +} + // BoundPassed is a delivery's bound key expiring: what it holds is raised, saying the delivery did not end. func (k *Keeper) BoundPassed(key string) error { node, module := splitNodeModule(key) diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go b/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go index 0ee296a2..fa668544 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go @@ -71,6 +71,10 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) { if err := r.k.Controller("core.controller.deaf", deaf, false); err != nil { t.Fatal(err) } + // The delivery's bound is its own key's time to live, the module's bound (the default where it names none). + if r.st.ttls["bounds/novox.mesh-controller"] != delivery.DefaultDeliveryBound { + t.Errorf("%s: the bound key lives %s", c.name, r.st.ttls["bounds/novox.mesh-controller"]) + } if got := r.changes(); got != "raised "+other+", raised "+machine { t.Errorf("%s: during the delivery said %q: another module's and the machine's at once, nothing of its own", c.name, got) } @@ -106,4 +110,20 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) { t.Errorf("%s: the carried deaf condition was raised, or something is still held: %+v", c.name, r.k.Held()) } } + // A seat's finding is about the module the placement says holds the seat on that node (the lead, 2026-10-29): held + // while the assigner is delivered on ace, though the seat is named mesh-placement. + r := newRig() + placed, err := delivery.EncodePlacement(delivery.Placement{Node: "ace", Modules: []delivery.PlacedModule{{Module: "mesh-delivery-assigner"}}, + Holds: []delivery.Holding{{Claim: "mesh-placement", Scope: "mesh", Module: "mesh-delivery-assigner"}}}) + if err != nil { + t.Fatal(err) + } + r.k.Placement("placement.ace", placed, false) + _ = r.k.Want("want.ace.mesh-delivery-assigner", want(ctrlNext, ctrlNextComp), false) + _ = r.k.Have("ace.mesh-delivery-assigner", have(ctrlRunning, ctrlComposed, delivery.PhaseRunning, delivery.HealthOK), false) + seat, seatRaw := finding(t, selfCheck, health.ScopeSeat, "mesh-placement.ace", "holder-silent", "D3", health.Warning, r.now) + _ = r.k.Finding(selfCheck, seat, seatRaw, false) + if got := r.changes(); got != "" || len(r.k.Held()) != 1 || r.k.Held()[0].Module != "mesh-delivery-assigner" { + t.Errorf("a seat's finding while its holder is delivered: said %q, held %+v", got, r.k.Held()) + } } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go index 70a82f3d..802126f0 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go @@ -48,8 +48,11 @@ const ( ControllerLease = "mesh-controller.lease" ControllerLeaseKey = "holder" WantsRead = delivery.AssignerModule + ".wants" - HavesRead = "node-engine.have" - ConditionWrites = 8 + // PlacementRead is the mesh-placement seat's placement, read for which module holds a seat on a node (hold.go; the + // lead, 2026-10-29, for ADR 0564 clause 2's inputs). + PlacementRead = delivery.PlacementRead + HavesRead = "node-engine.have" + ConditionWrites = 8 ) // Store is the bus as the keeper uses it: its own states, written by compare-and-set, and a key with its own time to @@ -104,8 +107,10 @@ type Keeper struct { takeover health.Takeover wants map[string]delivery.Want haves map[string]delivery.Have - stuck map[string]string - held map[string]held + // holders are, by `.`, the module the placement says holds the seat there. + holders map[string]string + stuck map[string]string + held map[string]held // passed are the deliveries whose bound passed, by node and module, at the fingerprint wanted then; outOfHold the // line a key raised out of a hold says (hold.go). passed map[string]string @@ -121,7 +126,7 @@ func (k *Keeper) init() { k.wants, k.haves, k.stuck, k.held, k.cleared = map[string]delivery.Want{}, map[string]delivery.Have{}, map[string]string{}, map[string]held{}, map[string]clearedMemo{} k.takeover = health.Takeover{V: health.TakeoverVersion, Follow: health.FollowController} - k.passed, k.outOfHold = map[string]string{}, map[string]string{} + k.passed, k.outOfHold, k.holders = map[string]string{}, map[string]string{}, map[string]string{} } if k.Now == nil { k.Now = time.Now diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go index 6f8dd1d8..cc90e26a 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go @@ -20,6 +20,8 @@ type fakeStore struct { vals map[string][]byte revs map[string]uint64 next uint64 + // ttls are the times to live the keys were created with. + ttls map[string]time.Duration } func newFakeStore() *fakeStore { @@ -79,7 +81,13 @@ func (f *fakeStore) Keys(state string) ([]string, error) { return out, nil } -func (f *fakeStore) CreateTTL(state, key string, value any, _ time.Duration) error { +func (f *fakeStore) CreateTTL(state, key string, value any, ttl time.Duration) error { + f.mu.Lock() + if f.ttls == nil { + f.ttls = map[string]time.Duration{} + } + f.ttls[state+"/"+key] = ttl + f.mu.Unlock() _, err := f.Create(state, key, value) return err } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/main.go b/modules/mesh-conditions/cmd/mesh-conditions/main.go index 2a1c654e..ac597d4b 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/main.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/main.go @@ -44,10 +44,9 @@ func (runtimeStore) Delete(state, key string) error { return stdio.State(state). func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } -// CreateTTL creates the key in a state whose own age is the time to live (`bounds`, ttl-seconds): the runtime writes no -// key with a time to live of its own. -func (runtimeStore) CreateTTL(state, key string, value any, _ time.Duration) error { - _, err := stdio.State(state).Create(key, value) +// CreateTTL creates the key with its own time to live (`bounds` is key-ttl); its expiry is handed over as OpExpire. +func (runtimeStore) CreateTTL(state, key string, value any, ttl time.Duration) error { + _, err := stdio.State(state).CreateTTL(key, value, ttl) return err } @@ -132,11 +131,15 @@ func Keep(k *Keeper) { }) } watch(k, BoundsState, "", func(c stdio.StateChange) error { - if c.Op != "put" && !c.Current { + if c.Op == stdio.OpExpire { return said(k.BoundPassed(c.Key)) } return nil }) + watch(k, PlacementRead, "", func(c stdio.StateChange) error { + k.Placement(c.Key, c.Value, c.Op != stdio.OpPut) + return nil + }) } func main() { diff --git a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go index 66174fa2..94b1801c 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go @@ -29,7 +29,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { !slices.Equal(Verbs, catalogue.VerbNames(seat.Serves)) { t.Errorf("module %s runs as %s and claims %+v; the seat serves %v", m.Module, m.RunsAs, m.Claims, catalogue.VerbNames(seat.Serves)) } - reads := []string{ControllerConditions, ControllerLease, WantsRead, HavesRead, AttemptsRead} + reads := []string{ControllerConditions, ControllerLease, WantsRead, HavesRead, AttemptsRead, PlacementRead} for _, o := range Observers { reads = append(reads, o+"."+health.FindingsState) } @@ -39,12 +39,14 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { if !slices.Equal(got, reads) || slices.Contains(m.Reads, health.HealersModule+"."+health.WouldState) { t.Errorf("reads %v, want %v and never the healers' would", got, reads) } - ttl := map[string]int{} + ttl, keyTTL := map[string]int{}, map[string]bool{} for _, s := range m.State { - ttl[s.Name] = s.TTLSeconds + ttl[s.Name], keyTTL[s.Name] = s.TTLSeconds, s.KeyTTL } - if ttl[HistoryState] != int(HistoryKeptFor.Seconds()) || ttl[BoundsState] != 900 { - t.Errorf("history is kept %d s and a bound %d s", ttl[HistoryState], ttl[BoundsState]) + // Each bound is its own key's time to live (the runtime's per-key TTL), never the bucket's age. + if ttl[HistoryState] != int(HistoryKeptFor.Seconds()) || ttl[BoundsState] != 0 || !keyTTL[BoundsState] { + t.Errorf("history is kept %d s, a bound's bucket ages %d s, its keys have their own time to live: %v", + ttl[HistoryState], ttl[BoundsState], keyTTL[BoundsState]) } // Clause 12: each state with the tool that reads it, each act with the tool that starts it. readBy := map[string]string{ConditionsState: Seat + ".conditions", HistoryState: HistoryTool, TakeoverState: TakeoverTool, diff --git a/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go b/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go index 877a8ce1..f8881730 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go @@ -261,7 +261,7 @@ func TestTheKeeperWholeRaisesAndClearsOnTheBus(t *testing.T) { if err != nil { t.Fatal(err) } - buckets := []string{"mesh-controller_conditions", "mesh-controller_lease", "mesh-delivery-assigner_wants", "node-engine_have", + buckets := []string{"mesh-controller_conditions", "mesh-controller_lease", "mesh-delivery-assigner_wants", "node-engine_have", "mesh-delivery-assigner_placement", "mesh-healers_attempts"} for _, o := range Observers { buckets = append(buckets, o+"_findings") diff --git a/modules/mesh-conditions/go.mod b/modules/mesh-conditions/go.mod index fcfe14e1..23a7dffb 100644 --- a/modules/mesh-conditions/go.mod +++ b/modules/mesh-conditions/go.mod @@ -3,7 +3,7 @@ module mesh-conditions go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029210043-32f5ce371268 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029211442-bc6c8a6d244a github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-conditions/go.sum b/modules/mesh-conditions/go.sum index 8a5c3b7a..3ecc6a80 100644 --- a/modules/mesh-conditions/go.sum +++ b/modules/mesh-conditions/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029210043-32f5ce371268 h1:+jH77IJmxUiFDdbJljhynQRY0Cc5/5jtwmMV7UUqRI4= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029210043-32f5ce371268/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029211442-bc6c8a6d244a h1:haFs0zoHDUOrdtKn0Cw9lhCg/K3Me6Ff3Mg+IdVlK34= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029211442-bc6c8a6d244a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-conditions/module.json b/modules/mesh-conditions/module.json index 20805f2b..89354c38 100644 --- a/modules/mesh-conditions/module.json +++ b/modules/mesh-conditions/module.json @@ -28,7 +28,8 @@ "mesh-controller.conditions", "mesh-controller.lease", "mesh-delivery-assigner.wants", - "node-engine.have" + "node-engine.have", + "mesh-delivery-assigner.placement" ], "state": [ "conditions", @@ -40,7 +41,6 @@ "held", { "name": "bounds", - "ttl-seconds": 900, "key-ttl": true }, "asked"