Files
mesh-delivery/modules/mesh-delivery-watchman/internal/watch/finding.go
T
jschoubben d4598f5772 F-delivery 4: a consumer that does not hear its family is a finding until it binds
The indexer's, the assigner's, the builder's and the watchman's consumers that were not there or could not be
heard only said so in the log, so the part stayed healthy while it heard nothing (ADR 0615 §5). sdk/heard is
the one shared helper: the finding family-not-heard names the observer and the family, written when the
consumer does not bind and deleted when it does.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:34:02 +01:00

181 lines
6.5 KiB
Go

package watch
import (
"time"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-delivery/sdk/heard"
)
// The watchman is an observer of the seat mesh-watchdogs (novox/hq ADR 0564 §2, §4): what it observes wrong it writes as a
// finding, one key `<scope>.<id>.<kind>` of its own bucket `findings` in mesh-sdk's layout (health.Finding), only when what
// it says changes, and it deletes the key when it no longer observes it. The condition keeper (mesh-conditions) raises and
// clears the conditions from them; the watchman writes no condition and reads none (Limits 11).
// FindingsBucket is the watchman's findings.
var FindingsBucket = health.FindingsBucket(health.WatchmanModule)
// Observer is the name a finding of the watchman carries.
const Observer = health.WatchmanModule
// The scopes and severities a finding of the watchman names, as the controller's conditions name them, so the keeper's
// `compare` meets the same keys.
const (
ScopeMachine = health.ScopeMachine
ScopeDelivery = health.ScopeDelivery
ScopeModule = health.ScopeModule
ScopeProvider = health.ScopeProvider
ScopeBus = health.ScopeBus
ScopeCore = health.ScopeCore
Urgent = string(health.Urgent)
Warning = string(health.Warning)
)
// Finding is what the watchman builds of one thing it observes wrong: its subject (scope, id, the machine and the others
// it concerns), kind and severity, its evidence (Summary, and Said with Since, its one observation), the words the
// operator reads (ADR 0253; empty leaves the keeper's wording of the kind), and the row or fact it comes from (Source).
// It is written as health.Finding.
type Finding struct {
Scope, ID, Token, Kind, Machine string
Also []string
Severity string
Summary, Said string
Headline, Explanation string
Needs, Resolved string
Source string
Observer string
// Since is when what it says began, as the watchman saw it: the finding's time and its observation's, so the same
// thing observed again writes the same bytes.
Since time.Time
}
// FindingKey composes a finding's key as the controller composes a condition's (health.Key).
func FindingKey(scope, id, token string) string { return health.FindingKey(scope, id, token) }
// Key is the finding's key: its token, or its kind when it has none.
func (f Finding) Key() string {
token := f.Token
if token == "" {
token = f.Kind
}
return FindingKey(f.Scope, f.ID, token)
}
// EncodeFinding is f in mesh-sdk's layout, refusing one the keeper could not make a condition of.
func EncodeFinding(f Finding) ([]byte, error) {
said := f.Said
if said == "" {
said = f.Summary
}
return health.EncodeFinding(health.Finding{Key: f.Key(), Kind: f.Kind, Ref: f.Token,
Subject: health.Subject{Scope: f.Scope, ID: f.ID, Machine: f.Machine, Also: f.Also},
Severity: health.Severity(f.Severity), Summary: f.Summary, Headline: f.Headline, Explanation: f.Explanation,
Resolved: f.Resolved, Needs: f.Needs, Evidence: []health.Evidence{{At: f.Since.UTC(), Said: said}},
Source: f.Source, Observer: Observer, At: f.Since.UTC()})
}
// DecodeFinding reads a finding the watchman wrote.
func DecodeFinding(data []byte) (Finding, error) {
h, err := health.DecodeFinding(data)
if err != nil {
return Finding{}, err
}
f := Finding{Scope: h.Subject.Scope, ID: h.Subject.ID, Token: h.Ref, Kind: h.Kind, Machine: h.Subject.Machine,
Also: h.Subject.Also, Severity: string(h.Severity), Summary: h.Summary, Headline: h.Headline, Explanation: h.Explanation,
Needs: h.Needs, Resolved: h.Resolved, Source: h.Source, Observer: h.Observer, Since: h.At}
if len(h.Evidence) > 0 {
f.Said = h.Evidence[0].Said
}
return f, nil
}
// find writes a finding when what it says differs from what the bucket holds (ADR 0289 §1).
func (w *Watchman) find(f Finding) {
value, err := EncodeFinding(f)
if err != nil {
w.say("no finding: %v", err)
return
}
key := f.Key()
delete(w.seededFindings, key)
if string(w.findings[key]) == string(value) {
return
}
if err := w.out.PutFinding(key, value); err != nil {
w.say("cannot write the finding %s: %v", key, err)
return
}
w.findings[key] = value
}
// unfind deletes a finding the watchman no longer observes.
func (w *Watchman) unfind(key string) {
if _, ok := w.findings[key]; !ok {
return
}
if err := w.out.DeleteFinding(key); err != nil {
w.say("cannot delete the finding %s: %v", key, err)
return
}
delete(w.findings, key)
}
// SeedFinding tells the watchman a finding that stands in its bucket at its start; ReconcileFindings deletes it unless what
// the watchman reads at its start observes it again.
func (w *Watchman) SeedFinding(key string, value []byte) {
w.mu.Lock()
defer w.mu.Unlock()
w.findings[key] = value
w.seededFindings[key] = true
}
// Findings is every finding the watchman holds now, by key.
func (w *Watchman) Findings() map[string]Finding {
w.mu.Lock()
defer w.mu.Unlock()
out := map[string]Finding{}
for k, v := range w.findings {
if f, err := DecodeFinding(v); err == nil {
out[k] = f
}
}
return out
}
// Rewrite writes every finding the watchman holds again (resync, ADR 0564 §12): a bucket cleared or a key lost by hand is
// written back as the watchman observes it. It answers how many it wrote.
func (w *Watchman) Rewrite() int {
w.mu.Lock()
defer w.mu.Unlock()
n := 0
for key, value := range w.findings {
if err := w.out.PutFinding(key, value); err != nil {
w.say("cannot write the finding %s again: %v", key, err)
continue
}
n++
}
return n
}
// NotHeard is the watchman's finding of a family it does not hear (heard.Kind, ADR 0615 §5): a consumer not there yet, a
// hearing or a watch that could not start; consequence says what goes unheard. Heard ends it, once the consumer binds.
func (w *Watchman) NotHeard(family, consequence string, why error) {
w.mu.Lock()
defer w.mu.Unlock()
summary, headline, explanation := heard.Words(Observer, family, consequence, why)
w.say("%s", summary)
w.find(Finding{Scope: ScopeModule, ID: heard.ID(Observer, family), Token: heard.Kind, Kind: heard.Kind, Severity: Warning,
Source: Observer + "." + heard.Kind, Since: w.now().UTC().Truncate(time.Second), Summary: summary, Headline: headline,
Explanation: explanation, Resolved: "the watchman's consumer of " + family + " is bound"})
}
// Heard ends the finding that the watchman does not hear family.
func (w *Watchman) Heard(family string) {
w.mu.Lock()
defer w.mu.Unlock()
w.unfind(heard.Key(Observer, family))
}