diff --git a/modules/mesh-healers/cmd/mesh-healers/healers.go b/modules/mesh-healers/cmd/mesh-healers/healers.go index f1ad1caf6..b181959ca 100644 --- a/modules/mesh-healers/cmd/mesh-healers/healers.go +++ b/modules/mesh-healers/cmd/mesh-healers/healers.go @@ -30,6 +30,7 @@ import ( handacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts" 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/keys" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -287,6 +288,7 @@ func (h *Healers) act(row Healer, c Condition, budget string, spent int, by stri } return "", fmt.Errorf("healer %s did not act on %s: its attempt could not be begun: %w", row.ID, c.Key, err) } + h.sayAttempt(key, a, begun) act, said, err := row.repair(h, c) a.Outcome = OutcomeActed if err != nil { @@ -302,6 +304,8 @@ func (h *Healers) act(row Healer, c Condition, budget string, spent int, by stri a.Said, a.Finished = said+" ("+window+")", &finished if err := h.Store.PutTTL(AttemptsState, key, a, KeptFor); err != nil { h.Logf("[%s] healer %s %s %s and could not record what came of it: %v", Module, row.ID, a.Outcome, c.Key, err) + } else { + h.sayAttempt(key, a, finished) } h.tell(Acted{Healer: row.ID, Condition: c.Key, Kind: c.Kind, Act: a.Act, Outcome: a.Outcome, Said: said, Budget: window}) h.wake(budget, begun.Add(row.Settle), c.Key) @@ -321,14 +325,55 @@ func (h *Healers) escalate(row Healer, c Condition, budget string, spent []Attem window := fmt.Sprintf("%d of %d within %s", len(spent), row.Budget, row.Window) a := Attempt{Healer: row.ID, Condition: c.Key, Kind: c.Kind, BudgetKey: budget, Act: "handed the condition to the operator", Outcome: OutcomeEscalated, Said: said, Begun: now, Finished: &now} - if err := h.Store.CreateTTL(AttemptsState, attemptKey(row.ID, c.Key, now), a, KeptFor); err != nil && !errors.Is(err, ErrExists) { + ekey := attemptKey(row.ID, c.Key, now) + if err := h.Store.CreateTTL(AttemptsState, ekey, a, KeptFor); err != nil && !errors.Is(err, ErrExists) { return "", fmt.Errorf("healer %s could not record handing %s to the operator, and does not: %w", row.ID, c.Key, err) + } else if err == nil { + h.sayAttempt(ekey, a, now) } h.tell(Acted{Healer: row.ID, Condition: c.Key, Kind: c.Kind, Act: "handed to the operator", Outcome: OutcomeEscalated, Said: said, Budget: window}) return fmt.Sprintf("healer %s handed %s to the operator: %s", row.ID, c.Key, said), nil } +// sayAttempt says an attempt as the seat's attempt-changed (design 47 G §541, §665): the attempt whole as its key in `attempts` +// holds it, with `until`, the end of that key's lifetime (KeptFor from its write at wrote), on the subject keyed by the +// condition and the attempt, each escaped as a key's name is (§73). Not said is said in the journal: the attempt holds it. +func (h *Healers) sayAttempt(key string, a Attempt, wrote time.Time) { + if h.Publish == nil { + return + } + until := wrote.Add(KeptFor) + e := meshhealers.AttemptChangedEvent{Act: a.Act, Begun: &a.Begun, BudgetKey: a.BudgetKey, Condition: a.Condition, Finished: a.Finished, + Healer: a.Healer, Kind: a.Kind, Outcome: a.Outcome, Said: a.Said, Until: &until} + subject := meshhealers.SubjectAttemptChanged(keys.EscapeName(a.Condition), keys.EscapeName(key)) + if err := h.Publish(subject, e, key+"."+strconv.FormatInt(wrote.UnixNano(), 10)); err != nil { + h.Logf("[%s] the attempt %s of %s could NOT be said on the bus: %v", Module, key, a.Condition, err) + } +} + +// sayOpen says every attempt still kept, at the healers' start, so a keeper that missed one hears it (design 47 G §541). The +// end of an attempt's lifetime is a week from its last write: its finish, or its beginning while it is not finished. +func (h *Healers) sayOpen() { + names, err := h.Store.Keys(AttemptsState) + if err != nil { + h.Logf("[%s] the attempts kept cannot be read, so none is said again at the start: %v", Module, err) + return + } + for _, key := range names { + raw, found, err := h.Store.Get(AttemptsState, key) + var a Attempt + if err != nil || !found || json.Unmarshal(raw, &a) != nil { + continue + } + wrote := a.Begun + if a.Finished != nil { + wrote = *a.Finished + } + h.sayAttempt(key, a, wrote) + } +} + // tell says a heal as healer-acted. Not said is said in the log: the attempt holds it. func (h *Healers) tell(e Acted) { e.Event, e.At, e.By = ActedEvent, h.Now().UTC(), "healer "+e.Healer @@ -345,6 +390,7 @@ func (h *Healers) tell(e Acted) { // no expiry left to hear, so each newest act whose settle ended within its window is judged at once. A settle still // running keeps its key on the bus and needs nothing. func (h *Healers) Rearm() { + h.sayOpen() all, err := h.attempts() if err != nil { h.Logf("[%s] what the healers did cannot be read, so no ended settle is judged: %v", Module, err) diff --git a/modules/mesh-healers/cmd/mesh-healers/healers_test.go b/modules/mesh-healers/cmd/mesh-healers/healers_test.go index 8b1413991..b3c6a811c 100644 --- a/modules/mesh-healers/cmd/mesh-healers/healers_test.go +++ b/modules/mesh-healers/cmd/mesh-healers/healers_test.go @@ -16,6 +16,7 @@ import ( meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" handacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts" + 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/views" ) @@ -136,6 +137,8 @@ type world struct { said []Acted // events are the subjects the finding writer said its events on. events []string + // attempted are the attempt-changed events said: subject, outcome and until. + attempted []string } func newWorld() *world { @@ -157,6 +160,9 @@ func (w *world) fresh() { if a, ok := body.(Acted); ok && subject == "mesh.seat.mesh-healers.event.healer-acted" { w.said = append(w.said, a) } + if a, ok := body.(meshhealers.AttemptChangedEvent); ok { + w.attempted = append(w.attempted, subject+" "+a.Outcome+" "+a.Until.Format(time.RFC3339)) + } return nil }, } @@ -232,6 +238,25 @@ func TestTheHealersActWithNoLease(t *testing.T) { } } +// Design 47 G §541, §665: each attempt written is said as attempt-changed on the subject keyed by the condition and the attempt, +// escaped as a key's name is, with its until a week from its write; and said again for every attempt kept at the healers' start. +func TestAnAttemptIsSaidAsAttemptChangedAndAgainAtTheStart(t *testing.T) { + w := newWorld() + if err := w.h.Heard(body(lost("records"))); err != nil { + t.Fatal(err) + } + if len(w.attempted) != 2 || !strings.Contains(w.attempted[0], "mesh.seat.mesh-healers.event.attempt-changed.bus") || + !strings.HasSuffix(w.attempted[0], " acting "+w.now.Add(KeptFor).Format(time.RFC3339)) || !strings.Contains(w.attempted[1], " acted ") { + t.Fatalf("said %v: the begun attempt, then the finished one", w.attempted) + } + w.attempted = nil + w.fresh() + w.h.sayOpen() + if len(w.attempted) != 1 || !strings.Contains(w.attempted[0], " acted ") { + t.Errorf("at the start every attempt kept is said again: %v", w.attempted) + } +} + // An act waits out its settle, then its spent budget hands the condition to the operator once; an escalated condition // is passed over; a second instance cannot begin an act already begun; the brake stops every healer past twelve acts // in an hour and lets go an hour after the last (ADR 0231 §21, ADR 0564 §7).