From 265d3b83b2b539ffce539ee9e87157977cd34ee9 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:53:09 +0100 Subject: [PATCH] COND8 (haves and attempts as events): the keeper hears what a node runs and the healers' attempts, and reads neither state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node-engine's composed, running, wait-held and wait-ended make the have the hold compares with the wants; the healers' attempt-changed makes an attempt, dropped at its until on the keeper's own clock (a bound with its own time to live) or when removed. The manifest's reads are gone and its consumes gain the five events. Builds: design 47 G §541, §665, §556, §499, §434 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-conditions/engine_events.go | 201 ++++++++++++++++++ .../cmd/mesh-conditions/events.go | 12 +- .../cmd/mesh-conditions/hold.go | 27 +-- .../cmd/mesh-conditions/hold_test.go | 48 +++-- .../cmd/mesh-conditions/keeper.go | 33 +-- .../cmd/mesh-conditions/keeper_test.go | 13 +- .../cmd/mesh-conditions/main.go | 4 - .../cmd/mesh-conditions/manifest_test.go | 4 +- .../cmd/mesh-conditions/owner_test.go | 66 ++++++ modules/mesh-conditions/module.json | 10 +- 10 files changed, 335 insertions(+), 83 deletions(-) create mode 100644 modules/mesh-conditions/cmd/mesh-conditions/engine_events.go 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",