package nodes import ( "context" "fmt" "time" "github.com/redis/go-redis/v9" ) const ( // nodeLoadKeyPrefix is the Redis HASH key prefix for per-node runtime load metrics. // Full key: node:load:{node_uuid} (doc/03 §4) nodeLoadKeyPrefix = "node:load:" // nodeLoadTTL is the TTL on each node:load hash. A node that stops sending // heartbeats has its entry automatically evicted after 90 seconds. nodeLoadTTL = 90 * time.Second ) // NodeLoad holds the runtime metrics reported in each heartbeat. type NodeLoad struct { OnlineCount int64 BandwidthUpBps int64 BandwidthDownBps int64 CPUPercent float64 // DataPlaneHealthy is the agent's last sing-box health probe result. The // control plane downgrades a node to "down" when this is false (even if the // agent's gRPC stream is still up). Absent in older hashes → read as true. DataPlaneHealthy bool } // LoadCache reads and writes node:load Redis hashes. // It accepts redis.Cmdable so it can be used with a *redis.Client or pipeline. type LoadCache struct { rdb redis.Cmdable } // NewLoadCache creates a LoadCache backed by rdb. func NewLoadCache(rdb redis.Cmdable) *LoadCache { return &LoadCache{rdb: rdb} } // Set writes nodeUUID's current load metrics to Redis and refreshes the TTL. // The hash fields match the names in doc/03 §4: online_count, bw_up, bw_down, cpu. func (c *LoadCache) Set(ctx context.Context, nodeUUID string, load NodeLoad) error { key := nodeLoadKeyPrefix + nodeUUID dpHealthy := 0 if load.DataPlaneHealthy { dpHealthy = 1 } pipe := c.rdb.Pipeline() pipe.HSet(ctx, key, "online_count", load.OnlineCount, "bw_up", load.BandwidthUpBps, "bw_down", load.BandwidthDownBps, "cpu", load.CPUPercent, "dp_healthy", dpHealthy, ) pipe.Expire(ctx, key, nodeLoadTTL) if _, err := pipe.Exec(ctx); err != nil { return fmt.Errorf("nodes.LoadCache.Set: %w", err) } return nil } // Get reads nodeUUID's last-known load. Returns (nil, false, nil) if the key has // expired or was never set. Fields missing from the hash default to zero. func (c *LoadCache) Get(ctx context.Context, nodeUUID string) (*NodeLoad, bool, error) { key := nodeLoadKeyPrefix + nodeUUID vals, err := c.rdb.HGetAll(ctx, key).Result() if err != nil { return nil, false, fmt.Errorf("nodes.LoadCache.Get: %w", err) } if len(vals) == 0 { return nil, false, nil } // Default healthy: hashes written before dp_healthy existed (or by an older // control plane during rollout) must not be read as "unhealthy" → false-down. l := NodeLoad{DataPlaneHealthy: true} if v, ok := vals["dp_healthy"]; ok { l.DataPlaneHealthy = v == "1" } if v, ok := vals["online_count"]; ok { _, _ = fmt.Sscan(v, &l.OnlineCount) } if v, ok := vals["bw_up"]; ok { _, _ = fmt.Sscan(v, &l.BandwidthUpBps) } if v, ok := vals["bw_down"]; ok { _, _ = fmt.Sscan(v, &l.BandwidthDownBps) } if v, ok := vals["cpu"]; ok { _, _ = fmt.Sscan(v, &l.CPUPercent) } return &l, true, nil }