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
115 lines
3.9 KiB
Go
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
|
|
}
|