// Package health probes backends with a status ping, caching their server-list // response and tracking whether each one is up. package health import ( "context" "encoding/json" "log/slog" "net" "sort" "sync" "time" "git.veillax.com/Veillax/spinner/internal/config" "git.veillax.com/Veillax/spinner/internal/protocol" ) type State struct { Name string `json:"name"` Up bool `json:"up"` LastCheck time.Time `json:"last_check"` LatencyMS int64 `json:"latency_ms"` Error string `json:"error,omitempty"` Version string `json:"version,omitempty"` Online int `json:"online"` Max int `json:"max"` status string } type Tracker struct { mu sync.RWMutex st map[string]*State log *slog.Logger } func New(log *slog.Logger) *Tracker { return &Tracker{st: map[string]*State{}, log: log} } // Cached returns the backend's last status JSON if it is up and the probe is // no older than maxAge. func (t *Tracker) Cached(name string, maxAge time.Duration) (string, bool) { t.mu.RLock() defer t.mu.RUnlock() s := t.st[name] if s == nil || !s.Up || s.status == "" || time.Since(s.LastCheck) > maxAge { return "", false } return s.status, true } func (t *Tracker) Snapshot() []State { t.mu.RLock() defer t.mu.RUnlock() out := make([]State, 0, len(t.st)) for _, s := range t.st { out = append(out, *s) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // Run probes every server immediately and then on each interval until ctx ends. func (t *Tracker) Run(ctx context.Context, m *config.Manager) { for { cfg := m.Current() t.probeAll(ctx, cfg) select { case <-ctx.Done(): return case <-time.After(cfg.ProbeInterval()): } } } func (t *Tracker) probeAll(ctx context.Context, cfg *config.Config) { var wg sync.WaitGroup for i := range cfg.Servers { wg.Add(1) go func(s *config.Server) { defer wg.Done() t.record(s.Name, Probe(ctx, s, cfg.ConnectTimeout())) }(&cfg.Servers[i]) } wg.Wait() names := map[string]bool{} for _, s := range cfg.Servers { names[s.Name] = true } t.mu.Lock() for n := range t.st { if !names[n] { delete(t.st, n) } } t.mu.Unlock() } // Result is the outcome of one probe. type Result struct { Status string Latency time.Duration Err error } // Probe tries each of the server's addresses in order and returns the first success. func Probe(ctx context.Context, s *config.Server, timeout time.Duration) Result { var last Result for _, addr := range s.Addresses { start := time.Now() d := net.Dialer{Timeout: timeout} conn, err := d.DialContext(ctx, "tcp", addr) if err != nil { last = Result{Err: err} continue } host, port := protocol.SplitHostPort(addr) js, err := protocol.QueryStatus(conn, host, port, timeout) conn.Close() if err != nil { last = Result{Err: err} continue } return Result{Status: js, Latency: time.Since(start)} } return last } func (t *Tracker) record(name string, r Result) { s := State{Name: name, Up: r.Err == nil, LastCheck: time.Now(), LatencyMS: r.Latency.Milliseconds(), status: r.Status} if r.Err != nil { s.Error = r.Err.Error() } else { var parsed struct { Version struct{ Name string } `json:"version"` Players struct{ Online, Max int } `json:"players"` } if json.Unmarshal([]byte(r.Status), &parsed) == nil { s.Version, s.Online, s.Max = parsed.Version.Name, parsed.Players.Online, parsed.Players.Max } } t.mu.Lock() prev := t.st[name] t.st[name] = &s t.mu.Unlock() if prev == nil || prev.Up != s.Up { if s.Up { t.log.Info("backend up", "server", name, "latency_ms", s.LatencyMS) } else { t.log.Warn("backend down", "server", name, "err", s.Error) } } }