Files
mesh-catalog/modules/mesh-watcher/cmd/mesh-watcher/heartbeat.go
T
jschoubben 204a02240c mesh-watcher stops consuming the controller's doctor-heartbeat (ADR 0564 clause 9)
The watcher hears the self-check's `checked` alone: the consume of mesh-controller.doctor-heartbeat, the subscription
to the controller's seat, the follow switch (takeover.go) and its read of mesh-conditions.takeover are gone. The
manifest test now lists the one consume and the heartbeat-key test refuses the controller's event. No test added.

(issue 774, 774/S11)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:18:29 +01:00

115 lines
3.9 KiB
Go

package main
// The self-check's heartbeat, read in this one place (novox/hq to-be 45 §4, S10).
//
// **The contract this reads, and every assumption it makes**, because the self-check's side was built
// at the same time from the same design, which says only "every run ends with a heartbeat event
// carrying the run's id and counts":
//
// - The event is the seat mesh-self-check's `checked`, consumed as `mesh-self-check.checked` (subject
// `mesh.seat.mesh-self-check.event.checked`). The controller's `doctor-heartbeat` is no longer heard (novox/hq ADR
// 0564 clause 9, issue 774).
// - The body is one JSON object: `run` (the run's id), `at` (when the run ended, RFC 3339),
// `interval-seconds` (how often the check runs), and `counts` ({pass, fail, failed-to-run}).
// `finished`/`time` are read for `at`; `interval_seconds`, `interval` (seconds, or a duration such
// as "5m") for the interval.
// - Without a time, the heartbeat is taken as made when it arrived (said in the status as such).
// Without an interval, the design's five minutes stand.
// - The counts are kept for the status only. A run that found failures is still a heartbeat: the
// watcher watches that the checker runs; what it finds is the checker's to raise.
import (
"encoding/json"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// HeartbeatEvent is the event's local name under the seat mesh-self-check.
const HeartbeatEvent = health.EventChecked
// Beat is one heartbeat.
type Beat struct {
Run string
At time.Time
Interval time.Duration
Counts map[string]int
}
// isHeartbeat says whether an envelope's key is the self-check's heartbeat.
func isHeartbeat(key string) bool { return key == health.SelfCheckModule+"."+HeartbeatEvent }
// DecodeBeat reads one heartbeat's body.
func DecodeBeat(body []byte) (Beat, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil || raw == nil {
return Beat{}, fmt.Errorf("%s: the body is not a JSON object", HeartbeatEvent)
}
var b Beat
if v, ok := raw["run"]; ok {
var s string
var n float64
switch {
case json.Unmarshal(v, &s) == nil:
b.Run = s
case json.Unmarshal(v, &n) == nil:
b.Run = fmt.Sprintf("%.0f", n)
default:
return Beat{}, fmt.Errorf("%s: run is neither a string nor a number", HeartbeatEvent)
}
}
for _, name := range []string{"at", "finished", "time"} {
v, ok := raw[name]
if !ok || string(v) == "null" {
continue
}
var s string
if json.Unmarshal(v, &s) != nil {
return Beat{}, fmt.Errorf("%s: %s is not a string", HeartbeatEvent, name)
}
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return Beat{}, fmt.Errorf("%s: %s is not an RFC 3339 time: %q", HeartbeatEvent, name, s)
}
b.At = t
break
}
for _, name := range []string{"interval-seconds", "interval_seconds", "interval"} {
v, ok := raw[name]
if !ok || string(v) == "null" {
continue
}
var n float64
var s string
switch {
case json.Unmarshal(v, &n) == nil:
b.Interval = time.Duration(n * float64(time.Second))
case json.Unmarshal(v, &s) == nil:
d, err := time.ParseDuration(strings.TrimSpace(s))
if err != nil {
return Beat{}, fmt.Errorf("%s: %s is not a duration: %q", HeartbeatEvent, name, s)
}
b.Interval = d
default:
return Beat{}, fmt.Errorf("%s: %s is neither seconds nor a duration", HeartbeatEvent, name)
}
if b.Interval < 0 || b.Interval > 24*time.Hour {
return Beat{}, fmt.Errorf("%s: an interval of %s is not one doctor runs at", HeartbeatEvent, b.Interval)
}
break
}
if v, ok := raw["counts"]; ok && string(v) != "null" {
var counts map[string]float64
if json.Unmarshal(v, &counts) != nil {
return Beat{}, fmt.Errorf("%s: counts is not an object of numbers", HeartbeatEvent)
}
b.Counts = map[string]int{}
for k, n := range counts {
b.Counts[k] = int(n)
}
}
return b, nil
}