diff --git a/modules/mesh-conditions/cmd/mesh-conditions/engine_events.go b/modules/mesh-conditions/cmd/mesh-conditions/engine_events.go new file mode 100644 index 000000000..1b50054de --- /dev/null +++ b/modules/mesh-conditions/cmd/mesh-conditions/engine_events.go @@ -0,0 +1,201 @@ +package main + +// What a node runs and what the healers attempt, heard as events (design 47 G §541): the keeper's `reads` of +// `node-engine.have` and `mesh-healers.attempts` are gone. The node-engine seat's `composed`, `running` (with `healthy` +// and `stopped`), `wait-held` and `wait-ended` make the have the hold compares with the wants (ADR 0564 §13); the seat +// `mesh-healers`' `attempt-changed` makes an attempt of a condition, dropped at its `until` on the keeper's own clock or +// when the healer says `removed`. Each is read as §556 has it: a message older by `at` than what the keeper holds changes nothing. + +import ( + "encoding/json" + "errors" + "strings" + "time" + + engine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/motion" +) + +// haveParts are the newest of each event a node-engine said of one module on one node. +type haveParts struct { + composed *engine.Composed + running *engine.Running + wait *engine.Wait + waitAt time.Time +} + +// The prefix of the keys of `bounds` that are an attempt's end. +const attemptPrefix = "attempt=" + +// engineKey reads the event and the node and module a key names: `node-engine.[.[.]]`. +func engineKey(key string) (event, node, module string, ok bool) { + rest, found := strings.CutPrefix(key, engine.Seat+".") + if !found { + return "", "", "", false + } + parts := strings.SplitN(rest, ".", 3) + event = parts[0] + if len(parts) > 1 { + node = parts[1] + } + if len(parts) > 2 { + module = parts[2] + } + return event, node, module, true +} + +// EngineEvent takes one `composed`, `running`, `wait-held` or `wait-ended` of a node-engine. +func (k *Keeper) EngineEvent(e stdio.Envelope) error { + event, node, module, ok := engineKey(e.Key) + if !ok { + return nil + } + switch event { + case engine.EventRunning: + var r engine.Running + if err := json.Unmarshal(e.Body, &r); err != nil || r.Module == "" || r.Node == "" { + k.Logf("the node-engine's running on %s is not readable: %v", node, err) + return nil + } + err := k.haveChanged(r.Node, r.Module, func(p *haveParts) { + if p.running == nil || !r.At.Before(p.running.At) { + p.running = &r + } + }) + if err != nil { + return err + } + return k.EngineRunning(node, e.Body) + case engine.EventComposed: + var c engine.Composed + if err := json.Unmarshal(e.Body, &c); err != nil || c.Module == "" || c.Node == "" { + k.Logf("the node-engine's composed on %s is not readable: %v", node, err) + return nil + } + return k.haveChanged(c.Node, c.Module, func(p *haveParts) { + if p.composed == nil || !c.At.Before(p.composed.At) { + p.composed = &c + } + }) + case engine.EventWaitHeld, engine.EventWaitEnded: + var w engine.Wait + if err := json.Unmarshal(e.Body, &w); err != nil || node == "" || module == "" { + k.Logf("the node-engine's %s on %s is not readable, or names no node and module: %v", event, node, err) + return nil + } + at := w.Since + return k.haveChanged(node, module, func(p *haveParts) { + if !p.waitAt.IsZero() && at.Before(p.waitAt) { + return + } + p.waitAt = at + if p.wait = &w; event == engine.EventWaitEnded { + p.wait = nil + } + }) + } + return nil +} + +// haveChanged applies one change to what a node-engine said of a module on a node, makes the have the hold reads, and +// judges the delivery. +func (k *Keeper) haveChanged(node, module string, apply func(*haveParts)) error { + k.mu.Lock() + k.init() + if k.parts == nil { + k.parts = map[string]*haveParts{} + } + p := k.parts[node+"."+module] + if p == nil { + p = &haveParts{} + k.parts[node+"."+module] = p + } + apply(p) + h, has := p.have() + if has { + k.haves[node+"."+module] = h + } else { + delete(k.haves, node+"."+module) + } + k.mu.Unlock() + return k.judge(node, module, "") +} + +// have is the have those events make: a hold is the phase `held` with its reason; a running build with its health, on the +// composition of the composed with its fingerprint; a composed alone is applying; a stopped module has none. +func (p *haveParts) have() (motion.Have, bool) { + switch { + case p.wait != nil && p.wait.Step == engine.WaitStepCompose: + h := motion.Have{Phase: motion.PhaseHeld, Detail: p.wait.Reason} + if p.composed != nil { + h.FP, h.Composed = p.composed.Fingerprint, p.composed.Digest + } + return h, true + case p.running != nil && !p.running.Stopped && (p.composed == nil || p.composed.At.Before(p.running.At) || p.composed.Fingerprint == p.running.Fingerprint): + h := motion.Have{FP: p.running.Fingerprint, Phase: motion.PhaseRunning, Health: motion.HealthOK} + if p.running.Healthy != nil && !*p.running.Healthy { + h.Health = motion.HealthFailed + } + if p.composed != nil && p.composed.Fingerprint == h.FP { + h.Composed = p.composed.Digest + } + return h, true + case p.composed != nil && (p.running == nil || p.composed.At.After(p.running.At)): + return motion.Have{FP: p.composed.Fingerprint, Composed: p.composed.Digest, Phase: motion.PhaseApplying}, true + } + return motion.Have{}, false +} + +// AttemptChanged takes one `attempt-changed` of the healers, kept by its subject's `.`: the +// attempt reaches its condition's tried, resolver and escalation (ADR 0564 §7); it is dropped when the healer says +// `removed` and at its `until`, on the keeper's own clock. +func (k *Keeper) AttemptChanged(key string, body []byte) error { + rest, ok := strings.CutPrefix(key, meshhealers.Seat+".attempt-changed.") + if !ok || rest == "" { + return nil + } + var e meshhealers.AttemptChangedEvent + if err := json.Unmarshal(body, &e); err != nil || e.Condition == "" { + k.Logf("the healers' attempt-changed %s is not readable, so it is left out: %v", key, err) + return nil + } + now := k.Now().UTC() + k.mu.Lock() + k.init() + if e.Removed || e.Until != nil && !now.Before(*e.Until) { + delete(k.attempts, rest) + k.mu.Unlock() + _ = k.Store.Delete(BoundsState, attemptPrefix+rest) + return k.Settle(e.Condition) + } + a := meshhealers.AttemptsByAttemptValue{Act: e.Act, BudgetKey: e.BudgetKey, Condition: e.Condition, Finished: e.Finished, + Healer: e.Healer, Kind: e.Kind, Outcome: e.Outcome, Said: e.Said} + if e.Begun != nil { + a.Begun = *e.Begun + } + k.attempts[rest] = a + k.mu.Unlock() + if e.Until != nil { + if err := k.Store.CreateTTL(BoundsState, attemptPrefix+rest, map[string]any{"key": rest, "condition": e.Condition, "until": *e.Until}, + e.Until.Sub(now)); err != nil && !errors.Is(err, stdio.ErrChanged) { + k.Logf("the end of the attempt %s could not be timed: %v", rest, err) + } + } + return k.Settle(e.Condition) +} + +// AttemptPassed is an attempt's `until` passing: it is dropped from its condition. +func (k *Keeper) AttemptPassed(bound string) error { + rest := strings.TrimPrefix(bound, attemptPrefix) + k.mu.Lock() + k.init() + a, ok := k.attempts[rest] + delete(k.attempts, rest) + k.mu.Unlock() + if !ok { + return nil + } + return k.Settle(a.Condition) +} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/events.go b/modules/mesh-conditions/cmd/mesh-conditions/events.go index 4eba71a96..4a22ca0b5 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/events.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/events.go @@ -18,6 +18,7 @@ import ( engine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers" watchdogs "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs" stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -71,8 +72,11 @@ func (k *Keeper) FindingEvent(e stdio.Envelope) error { if _, ok := emitterOf(e.Key, watchdogs.EventMachineBack); ok && strings.HasPrefix(e.Key, watchdogs.Seat+".") { return k.MachinePresence(e.Body, false) } - if e.Key == engine.Seat+"."+engine.EventRunning { - return k.EngineRunning(e.Node, e.Body) + if _, _, _, ok := engineKey(e.Key); ok { + return k.EngineEvent(e) + } + if strings.HasPrefix(e.Key, meshhealers.Seat+".attempt-changed.") { + return k.AttemptChanged(e.Key, e.Body) } if e.Key == broker.Seat+"."+broker.EventCreated { return k.BrokerCreated(e.Body) @@ -230,7 +234,9 @@ func (k *Keeper) MachinePresence(body []byte, away bool) error { func hear(k *Keeper) { patterns := []string{"*." + EventFindingWritten, "*." + EventFindingCleared, "*." + EventFindingsStated, watchdogs.Seat + "." + watchdogs.EventMachineAway, watchdogs.Seat + "." + watchdogs.EventMachineBack, - engine.Seat + "." + engine.EventRunning, broker.Seat + "." + broker.EventCreated} + engine.Seat + "." + engine.EventRunning, engine.Seat + "." + engine.EventComposed, engine.Seat + "." + engine.EventWaitHeld, + engine.Seat + "." + engine.EventWaitEnded, meshhealers.Seat + "." + meshhealers.EventAttemptChanged, + broker.Seat + "." + broker.EventCreated} for _, pattern := range patterns { go func() { <-stdio.Served() diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold.go b/modules/mesh-conditions/cmd/mesh-conditions/hold.go index 4459a659a..22b026b8b 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold.go @@ -18,7 +18,6 @@ import ( "strings" "time" - engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/motion" @@ -161,8 +160,8 @@ func (k *Keeper) dropHeld(key, why string) { } } -// Want takes one change of the assigner's wants; Have one of the nodes' haves. Each may end a delivery, which raises or -// drops what it held. +// Want takes one change of the assigner's wants, which may end a delivery, which raises or drops what it held (the nodes' haves +// are heard as the node-engine's events, engine_events.go). func (k *Keeper) Want(key string, value []byte, deleted bool) error { node, module, ok := meshwants.ParseWantsByWantNodeModuleKey(key) if !ok { @@ -181,25 +180,6 @@ func (k *Keeper) Want(key string, value []byte, deleted bool) error { return k.judge(node, module, "") } -// Have takes one change of a node's have. -func (k *Keeper) Have(key string, value []byte, deleted bool) error { - node, module, isNode, err := engineclient.ParseHaveKey(key) - if err != nil || isNode { - return nil - } - k.mu.Lock() - k.init() - if deleted { - delete(k.haves, node+"."+module) - } else if h, err := engineclient.DecodeHave(value); err == nil { - k.haves[node+"."+module] = motion.Have{FP: h.Fp, Composed: h.Composed, Phase: h.Phase, Health: h.Health, Detail: h.Detail} - } else { - k.Logf("the have %s is not readable: %v", key, err) - } - k.mu.Unlock() - return k.judge(node, module, "") -} - // Assignments takes one change of the assignments: which module holds which seat on a node. The keys are a node key and // its module keys (assignerclient.Keys; research 072 row 37). func (k *Keeper) Assignments(key string, value []byte, deleted bool) { @@ -238,6 +218,9 @@ func (k *Keeper) BoundPassed(key string) error { if strings.HasPrefix(key, ownerPrefix) { return k.OwnerPassed(key) } + if strings.HasPrefix(key, attemptPrefix) { + return k.AttemptPassed(key) + } node, module := splitNodeModule(key) k.mu.Lock() k.init() diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go b/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go index 2bd970e4a..b9e7a82c4 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go @@ -3,6 +3,9 @@ package main import ( "encoding/json" "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" "testing" assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" @@ -35,31 +38,28 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) { raw, _ := json.Marshal(meshwants.WantsByWantNodeModuleValue{V: 1, Fp: fp, Composed: composed}) return raw } - have := func(fp, composed string, phase, h string) []byte { - raw, _ := json.Marshal(nodeengine.HaveByNodeModuleValue{V: 1, Fp: fp, Composed: composed, Phase: phase, Health: h}) - return raw - } + have := func(fp, composed string, phase, h string) haveSpec { return haveSpec{fp, composed, phase, h} } for _, c := range []struct { name string // end is the have the delivery ends with, or nil for a delivery past its bound. - end []byte + end *haveSpec standing bool said string line string }{ - {name: "gone by its end", end: have(ctrlNext, ctrlNextComp, motion.PhaseRunning, engineclient.HealthOK)}, - {name: "still standing at its end", end: have(ctrlNext, ctrlNextComp, motion.PhaseRunning, engineclient.HealthOK), + {name: "gone by its end", end: ptr(have(ctrlNext, ctrlNextComp, motion.PhaseRunning, engineclient.HealthOK))}, + {name: "still standing at its end", end: ptr(have(ctrlNext, ctrlNextComp, motion.PhaseRunning, engineclient.HealthOK)), standing: true, said: "raised module.mesh-controller.novox.unhealthy", line: "during the delivery of version 0d1e2f3a4b5c"}, {name: "past its bound", standing: true, said: "raised module.mesh-controller.novox.unhealthy", line: "did not end within"}, - {name: "failed", end: have(ctrlNext, ctrlNextComp, motion.PhaseFailed, engineclient.HealthFailed), standing: true, + {name: "failed", end: ptr(have(ctrlNext, ctrlNextComp, motion.PhaseFailed, engineclient.HealthFailed)), standing: true, said: "raised module.mesh-controller.novox.unhealthy", line: "as the delivery of version 0d1e2f3a4b5c"}, } { r := newRig() _ = r.k.Want("want.novox.mesh-controller", want(ctrlRunning, ctrlComposed), false) - _ = r.k.Have("novox.mesh-controller", have(ctrlRunning, ctrlComposed, motion.PhaseRunning, engineclient.HealthOK), false) + r.engineHave("novox.mesh-controller", have(ctrlRunning, ctrlComposed, motion.PhaseRunning, engineclient.HealthOK)) // The delivery starts: the assigner wants the next fingerprint; the node applies it. _ = r.k.Want("want.novox.mesh-controller", want(ctrlNext, ctrlNextComp), false) - _ = r.k.Have("novox.mesh-controller", have(ctrlNext, ctrlNextComp, motion.PhaseApplying, engineclient.HealthUnknown), false) + r.engineHave("novox.mesh-controller", have(ctrlNext, ctrlNextComp, motion.PhaseApplying, engineclient.HealthUnknown)) own, ownRaw := finding(t, watchman, cc.ScopeModule, "mesh-controller.novox", "unhealthy", "health", cc.Warning, r.now) other, otherRaw := finding(t, watchman, cc.ScopeModule, "postgres.novox", "unhealthy", "health", cc.Warning, r.now) machine, machineRaw := finding(t, watchman, cc.ScopeMachine, "novox", "silent", "S1", cc.Urgent, r.now) @@ -83,7 +83,7 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) { _ = r.k.Finding(watchman, own, nil, true) } if c.end != nil { - _ = r.k.Have("novox.mesh-controller", c.end, false) + r.engineHave("novox.mesh-controller", *c.end) } else { r.now = r.now.Add(motion.DefaultDeliveryBound) _ = r.k.BoundPassed("novox.mesh-controller") @@ -110,10 +110,34 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) { r.k.Assignments("ace.mesh-delivery-assigner", placed["ace.mesh-delivery-assigner"], false) r.k.Assignments("ace.node", placed["ace.node"], false) _ = r.k.Want("want.ace.mesh-delivery-assigner", want(ctrlNext, ctrlNextComp), false) - _ = r.k.Have("ace.mesh-delivery-assigner", have(ctrlRunning, ctrlComposed, motion.PhaseRunning, engineclient.HealthOK), false) + r.engineHave("ace.mesh-delivery-assigner", have(ctrlRunning, ctrlComposed, motion.PhaseRunning, engineclient.HealthOK)) seatKey, seatRaw := finding(t, selfCheck, cc.ScopeSeat, "mesh-module-assigner.ace", "holder-silent", "D3", cc.Warning, r.now) _ = r.k.Finding(selfCheck, seatKey, 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()) } } + +// haveSpec is a have as the old tests spoke it; engineHave says it as the node-engine's events do (design 47 G §541). +type haveSpec struct{ fp, composed, phase, health string } + +func ptr[T any](v T) *T { return &v } + +func (r *rig) engineHave(nodeModule string, h haveSpec) { + node, module, _ := strings.Cut(nodeModule, ".") + say := func(event string, body any) { + raw, _ := json.Marshal(body) + if err := r.k.EngineEvent(stdio.Envelope{Key: nodeengine.Seat + "." + event + "." + node + "." + module, Node: node, Body: raw}); err != nil { + panic(err) + } + } + r.hevent++ + at := r.now.Add(time.Duration(r.hevent) * time.Millisecond) + if h.phase != motion.PhaseFailed { + say(nodeengine.EventComposed, nodeengine.Composed{At: at, Digest: h.composed, Fingerprint: h.fp, Module: module, Node: node, V: 1}) + } + if h.phase == motion.PhaseRunning || h.phase == motion.PhaseFailed { + healthy := h.health == engineclient.HealthOK + say(nodeengine.EventRunning, nodeengine.Running{At: at.Add(time.Microsecond), Fingerprint: h.fp, Healthy: &healthy, Module: module, Node: node, V: 1}) + } +} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go index b1cedb193..179e30b70 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go @@ -67,12 +67,10 @@ const HistoryKeptFor = 90 * 24 * time.Hour // The other states it reads, as `.`. const ( - AttemptsRead = healersModule + "." + meshhealers.StateAttempts - WantsRead = assignerModule + ".wants" + WantsRead = assignerModule + ".wants" // AssignmentsRead is the mesh-module-assigner seat's assignments, read for which module holds a seat on a node (hold.go; the // lead, 2026-10-29, for ADR 0564 clause 2's inputs). AssignmentsRead = assignerModule + "." + mesh.StateAssignments - HavesRead = "node-engine.have" ConditionWrites = 8 ) @@ -129,6 +127,8 @@ type Keeper struct { attempts map[string]meshhealers.AttemptsByAttemptValue wants map[string]motion.Want haves map[string]motion.Have + // parts are the newest of each event a node-engine said of a module on a node, which make its have (engine_events.go). + parts map[string]*haveParts // holders are, by `.`, the module the assignments says holds the seat there. holders map[string]string // assignments is the assignments' keys as heard, assembled per node. @@ -228,33 +228,6 @@ func (k *Keeper) Blind(observer, why string) { } } -// Attempt takes one change of the healers' `attempts`: the condition it names gets its tried, resolver and escalation -// (clause 7). -func (k *Keeper) Attempt(key string, value []byte, deleted bool) error { - k.mu.Lock() - k.init() - var condition string - if old, ok := k.attempts[key]; ok { - condition = old.Condition - } - if deleted { - delete(k.attempts, key) - } else { - var a meshhealers.AttemptsByAttemptValue - if err := json.Unmarshal(value, &a); err != nil || a.Condition == "" { - k.mu.Unlock() - k.Logf("the healers' attempt %s is not readable, so it is left out: %v", key, err) - return nil - } - k.attempts[key], condition = a, a.Condition - } - k.mu.Unlock() - if condition == "" { - return nil - } - return k.Settle(condition) -} - // endedWhy is why a condition of a kind that ended with the controller is cleared. const endedWhy = "its kind ended with the controller, which was unassigned (issue 1126): no finding raises it again" diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go index a4a3728a0..2c69ba789 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go @@ -111,6 +111,8 @@ type rig struct { st *fakeStore now time.Time said []Event + // hevent orders the node-engine events a test says. + hevent int } func newRig() *rig { @@ -231,8 +233,8 @@ func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) { return "" }}, {name: "a healer's attempt reaches tried and the resolver", do: func() error { - a, _ := json.Marshal(meshhealers.AttemptsByAttemptValue{Healer: "H3", Condition: key, Act: "asserted", Outcome: meshhealers.AttemptsByAttemptValueOutcomeActed, Begun: r.now}) - return r.k.Attempt("h3-1", a, false) + a, _ := json.Marshal(meshhealers.AttemptChangedEvent{Healer: "H3", Condition: key, Act: "asserted", Outcome: meshhealers.AttemptChangedEventOutcomeActed, Begun: &r.now}) + return r.k.AttemptChanged(meshhealers.Seat+".attempt-changed."+key+".h3-1", a) }, said: "resolver machine.g14.silent", check: func(c Condition, _ bool) string { if c.Resolver != "healer:H3" || len(c.Tried) != 1 || c.Tried[0].By != "healer H3" { return fmt.Sprintf("%+v", c) @@ -240,9 +242,10 @@ func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) { return "" }}, {name: "an escalation makes it urgent, the operator's", do: func() error { - a, _ := json.Marshal(meshhealers.AttemptsByAttemptValue{Healer: "H3", Condition: key, Act: "handed over", Outcome: meshhealers.AttemptsByAttemptValueOutcomeEscalated, - Begun: r.now.Add(time.Minute)}) - return r.k.Attempt("h3-2", a, false) + begun := r.now.Add(time.Minute) + a, _ := json.Marshal(meshhealers.AttemptChangedEvent{Healer: "H3", Condition: key, Act: "handed over", Outcome: meshhealers.AttemptChangedEventOutcomeEscalated, + Begun: &begun}) + return r.k.AttemptChanged(meshhealers.Seat+".attempt-changed."+key+".h3-2", a) }, said: "severity machine.g14.silent, resolver machine.g14.silent", check: func(c Condition, _ bool) string { if c.Severity != cc.Urgent || c.Resolver != cc.ResolverOperator || len(c.Tried) != 2 { return fmt.Sprintf("%+v", c) diff --git a/modules/mesh-conditions/cmd/mesh-conditions/main.go b/modules/mesh-conditions/cmd/mesh-conditions/main.go index 2a72170f7..859732bdc 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/main.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/main.go @@ -126,10 +126,6 @@ func Keep(k *Keeper, v *views.Reader) { logf("cleared %d condition(s) of kinds that ended with the controller: %v", n, err) } watch(k, v, WantsRead, "", func(c stdio.StateChange) error { return said(k.Want(c.Key, c.Value, c.Op != "put")) }) - watch(k, v, HavesRead, "", func(c stdio.StateChange) error { return said(k.Have(c.Key, c.Value, c.Op != "put")) }) - watch(k, v, AttemptsRead, healersModule+" attempts", func(c stdio.StateChange) error { - return said(k.Attempt(c.Key, c.Value, c.Op != "put")) - }) hear(k) go func() { <-stdio.Served(); ListBound(k, stdio.Ask) }() watch(k, v, BoundsState, "", func(c stdio.StateChange) error { diff --git a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go index 9335eeb7b..62f46aa07 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go @@ -35,11 +35,11 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { } // The assigner's wants and assignments are asked once of their read tools and followed by their events (ADR 0615 §1); // the observers' findings are heard as events and no `findings` is read (design 47 G §383). - reads := []string{HavesRead, AttemptsRead} + var reads []string if !slices.Equal(m.Invokes, []string{WantsTool, AssignmentsTool, "seat:mesh-hand-acts.hand-act", "seat:mesh-wants.revision", "seat:mesh-catalog.registrations", "seat:mesh-catalog.revision", "seat:mesh-broker.list"}) || !slices.Equal(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-assigner.views.assignments", - "*.finding-written", "*.finding-cleared", "*.findings-stated", "mesh-watchdogs.machine-away", "mesh-watchdogs.machine-back", "mesh-wants.assignment-revision", "mesh-catalog.registration-changed", "mesh-catalog.catalog-revision", "mesh-broker.created", "node-engine.running"}) { + "*.finding-written", "*.finding-cleared", "*.findings-stated", "mesh-watchdogs.machine-away", "mesh-watchdogs.machine-back", "mesh-wants.assignment-revision", "mesh-catalog.registration-changed", "mesh-catalog.catalog-revision", "mesh-broker.created", "node-engine.running", "node-engine.composed", "node-engine.wait-held", "node-engine.wait-ended", "mesh-healers.attempt-changed"}) { t.Errorf("invokes %v, consumes %v", m.Invokes, m.Consumes) } for _, r := range m.Reads { diff --git a/modules/mesh-conditions/cmd/mesh-conditions/owner_test.go b/modules/mesh-conditions/cmd/mesh-conditions/owner_test.go index 4b8326f65..d590f9b13 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/owner_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/owner_test.go @@ -141,3 +141,69 @@ func TestAStatementCoversOnlyItsKindsAndEventsComeInEitherForm(t *testing.T) { func stdioEnvelope(key, node string, body []byte) stdio.Envelope { return stdio.Envelope{Key: key, Node: node, Body: body} } + +// Design 47 G §541: the healers' attempts and a node's holds are heard as events: an attempt shows on its condition and +// is dropped at its `until` or when the healer says it removed; a wait-held makes the module's have held with its +// reason, and a wait-ended ends it; a message older by `at` than the one held changes nothing. +func TestTheKeeperHearsAttemptsAndHoldsAsEvents(t *testing.T) { + r := newRig() + key, raw := finding(t, watchman, cc.ScopeMachine, "g14", "silent", "S1", cc.Warning, r.now) + if err := r.k.Finding(watchman, key, raw, false); err != nil { + t.Fatal(err) + } + subject := meshhealersSeat + ".attempt-changed." + key + ".a1" + until := r.now.Add(time.Hour) + a := mustJSON(t, map[string]any{"healer": "H3", "condition": key, "act": "asserted", "outcome": "acted", "begun": r.now, "until": until}) + if err := r.k.AttemptChanged(subject, a); err != nil { + t.Fatal(err) + } + if c, _ := r.open(key); len(c.Tried) != 1 || c.Resolver != "healer:H3" { + t.Fatalf("an attempt said is shown on its condition: %+v", c) + } + if r.st.ttls["bounds/"+attemptPrefix+key+".a1"] != time.Hour { + t.Errorf("its end is a bound of its own: %v", r.st.ttls) + } + r.now = until + if err := r.k.BoundPassed(attemptPrefix + key + ".a1"); err != nil { + t.Fatal(err) + } + r.k.mu.Lock() + left := len(r.k.attempts) + r.k.mu.Unlock() + if left != 0 { + t.Errorf("an attempt past its until is dropped: %d held", left) + } + // The healer ending one says removed. + a = mustJSON(t, map[string]any{"healer": "H3", "condition": key, "act": "asserted", "outcome": "acted", "begun": r.now}) + _ = r.k.AttemptChanged(meshhealersSeat+".attempt-changed."+key+".a2", a) + _ = r.k.AttemptChanged(meshhealersSeat+".attempt-changed."+key+".a2", mustJSON(t, map[string]any{"condition": key, "removed": true})) + r.k.mu.Lock() + left = len(r.k.attempts) + r.k.mu.Unlock() + if left != 0 { + t.Errorf("a removed attempt is dropped: %d held", left) + } + // A hold of a node-engine is the module's have held, until its wait-ended; an older message changes nothing. + say := func(event string, w map[string]any) { + if err := r.k.EngineEvent(stdioEnvelope(nodeEngineSeat+"."+event+".ace.mod", "ace", mustJSON(t, w))); err != nil { + t.Fatal(err) + } + } + say("wait-held", map[string]any{"step": "compose", "reason": "restore point missing", "since": r.now}) + if h := r.k.haves["ace.mod"]; h.Phase != "held" || h.Detail != "restore point missing" { + t.Fatalf("a hold is heard: %+v", h) + } + say("wait-held", map[string]any{"step": "compose", "reason": "older", "since": r.now.Add(-time.Hour)}) + if h := r.k.haves["ace.mod"]; h.Detail != "restore point missing" { + t.Errorf("an older message changes nothing: %+v", h) + } + say("wait-ended", map[string]any{"step": "compose", "since": r.now.Add(time.Minute)}) + if h := r.k.haves["ace.mod"]; h.Phase == "held" { + t.Errorf("wait-ended ends the hold: %+v", h) + } +} + +const ( + meshhealersSeat = "mesh-healers" + nodeEngineSeat = "node-engine" +) diff --git a/modules/mesh-conditions/module.json b/modules/mesh-conditions/module.json index fe6bc43a0..7079970a7 100644 --- a/modules/mesh-conditions/module.json +++ b/modules/mesh-conditions/module.json @@ -13,10 +13,6 @@ ] } ], - "reads": [ - "mesh-healers.attempts", - "node-engine.have" - ], "invokes": [ "mesh-delivery-assigner.assigner_read_wants", "mesh-delivery-assigner.assigner_read_assignments", @@ -38,7 +34,11 @@ "mesh-catalog.registration-changed", "mesh-catalog.catalog-revision", "mesh-broker.created", - "node-engine.running" + "node-engine.running", + "node-engine.composed", + "node-engine.wait-held", + "node-engine.wait-ended", + "mesh-healers.attempt-changed" ], "state": [ "conditions",