Files
llmgw/internal/pgha/status.go
T
Ilker ManapandClaude Opus 5.5 aa3efcebbe Add a standby control plane with server add, and draw the screens as windows
server add gives another machine this llmgw, a certificate, the
configuration and store address, lets it into the store through Patroni, and
starts it; --vip keeps a floating address on the leader with keepalived,
following a new /leader endpoint. Nodes learn every control plane from the
leader and notice a dead one by HTTP/2 ping instead of after the kernel's
fifteen minutes. The setup, console, node and init screens are drawn full
screen in one frame: title bar, sidebar, bordered panel, key bar, dialogs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 22:29:12 +02:00

115 lines
2.7 KiB
Go

package pgha
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
)
// MemberState is one member as Patroni reports it.
type MemberState struct {
Name string `json:"name"`
// Role is "leader", "replica" or "sync_standby".
Role string `json:"role"`
// State is "running" for a leader, "streaming" for a replica keeping up,
// and anything else for one that is not.
State string `json:"state"`
Host string `json:"host"`
Timeline int `json:"timeline"`
// Lag is how far behind the leader a replica is, in bytes; -1 when
// Patroni does not know, which is itself worth an alert.
Lag int64 `json:"-"`
RawLag json.RawMessage `json:"lag"`
}
// Cluster is the cluster's state, as any member's API reports it.
type Cluster struct {
Members []MemberState `json:"members"`
}
// Leader is the leading member's name, or "".
func (c Cluster) Leader() string {
for _, m := range c.Members {
if m.Role == "leader" || m.Role == "standby_leader" {
return m.Name
}
}
return ""
}
// Complete reports whether n members are up: one leading, the rest streaming.
func (c Cluster) Complete(n int) bool {
if c.Leader() == "" || len(c.Members) < n {
return false
}
streaming := 0
for _, m := range c.Members {
if m.Role != "leader" && m.State == "streaming" {
streaming++
}
}
return streaming >= n-1
}
func (c Cluster) String() string {
if len(c.Members) == 0 {
return "empty"
}
var parts []string
for _, m := range c.Members {
parts = append(parts, m.Name+" "+m.Role+"/"+m.State)
}
return strings.Join(parts, ", ")
}
// HTTPStatus asks each host's Patroni in turn and returns the first answer:
// any member describes the whole cluster.
func HTTPStatus(ctx context.Context, hosts []string) (Cluster, error) {
client := &http.Client{Timeout: 3 * time.Second}
var last error
for _, h := range hosts {
u := "http://" + net.JoinHostPort(h, strconv.Itoa(restPort)) + "/cluster"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return Cluster{}, err
}
resp, err := client.Do(req)
if err != nil {
last = err
continue
}
var c Cluster
err = json.NewDecoder(resp.Body).Decode(&c)
resp.Body.Close()
if err != nil {
last = fmt.Errorf("%s: %w", u, err)
continue
}
for i := range c.Members {
c.Members[i].Lag = lagOf(c.Members[i].RawLag)
}
return c, nil
}
if last == nil {
last = fmt.Errorf("no hosts to ask")
}
return Cluster{}, fmt.Errorf("no member's Patroni answered: %w", last)
}
// lagOf reads Patroni's lag: a number of bytes, or "unknown".
func lagOf(raw json.RawMessage) int64 {
if len(raw) == 0 {
return 0
}
var n int64
if json.Unmarshal(raw, &n) == nil {
return n
}
return -1
}