Pulse/pkg/licensing/host_lifecycle.go
2026-03-18 16:06:30 +00:00

256 lines
6.6 KiB
Go

// Package licensing — host_lifecycle.go
//
// HostLifecycleTracker implements the v6 host counting rules:
//
// - H2: A host is only counted toward the license limit after 10+ minutes
// of stable heartbeat (prevents transient connections from consuming slots).
// - H3: A host slot is released after 72 hours of inactivity (no heartbeat).
//
// Wiring instructions:
//
// - node_limit.go should call tracker.StableActiveHosts() to filter which
// hosts count toward the license limit, instead of counting all hosts
// unconditionally.
// - The monitor's update loop (where host heartbeats arrive) should call
// tracker.RecordHeartbeat(hostID) on every heartbeat.
// - The host ledger (host_ledger.go) can use tracker.FirstSeen(hostID) for
// the first_seen field in ledger entries.
// - Call tracker.Prune() periodically (e.g. every hour) to release stale
// entries and free memory.
// - On startup, call LoadState(path) to restore persisted lifecycle state.
// On shutdown (or periodically), call SaveState(path) to persist it.
package licensing
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
const (
// HostStabilizationPeriod is the minimum duration of heartbeats before a
// host is considered "stable" and counted toward the license limit.
HostStabilizationPeriod = 10 * time.Minute
// HostInactivityTimeout is the duration after which a host with no
// heartbeat is considered inactive and its slot is released.
HostInactivityTimeout = 72 * time.Hour
)
// hostEntry tracks lifecycle timestamps for a single host.
type hostEntry struct {
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
StableAt *time.Time `json:"stable_at,omitempty"`
}
// HostLifecycleTracker tracks heartbeat stability and inactivity per host.
// All methods are safe for concurrent use.
type HostLifecycleTracker struct {
mu sync.RWMutex
hosts map[string]*hostEntry
// nowFunc is used for time; overridden in tests.
nowFunc func() time.Time
}
// NewHostLifecycleTracker creates a new tracker with an empty host map.
func NewHostLifecycleTracker() *HostLifecycleTracker {
return &HostLifecycleTracker{
hosts: make(map[string]*hostEntry),
nowFunc: time.Now,
}
}
// RecordHeartbeat records a heartbeat for the given host. If the host is new,
// its firstSeen is set. Once HostStabilizationPeriod has elapsed since
// firstSeen, stableAt is set (and never cleared). Empty hostID is ignored.
func (t *HostLifecycleTracker) RecordHeartbeat(hostID string) {
if hostID == "" {
return
}
t.mu.Lock()
defer t.mu.Unlock()
now := t.nowFunc()
e, ok := t.hosts[hostID]
if !ok {
e = &hostEntry{
FirstSeen: now,
}
t.hosts[hostID] = e
}
e.LastSeen = now
if e.StableAt == nil && now.Sub(e.FirstSeen) >= HostStabilizationPeriod {
ts := now
e.StableAt = &ts
}
}
// IsStable reports whether the host has achieved stability (10+ minutes of
// heartbeats since first seen).
func (t *HostLifecycleTracker) IsStable(hostID string) bool {
t.mu.RLock()
defer t.mu.RUnlock()
e, ok := t.hosts[hostID]
if !ok {
return false
}
return e.StableAt != nil
}
// IsActive reports whether the host has sent a heartbeat within the last 72
// hours.
func (t *HostLifecycleTracker) IsActive(hostID string) bool {
t.mu.RLock()
defer t.mu.RUnlock()
e, ok := t.hosts[hostID]
if !ok {
return false
}
return t.nowFunc().Sub(e.LastSeen) <= HostInactivityTimeout
}
// FirstSeen returns the time the host was first seen. Returns the zero value
// if the host is unknown.
func (t *HostLifecycleTracker) FirstSeen(hostID string) time.Time {
t.mu.RLock()
defer t.mu.RUnlock()
e, ok := t.hosts[hostID]
if !ok {
return time.Time{}
}
return e.FirstSeen
}
// StableActiveHosts returns the IDs of all hosts that are both stable (10+
// minutes of heartbeats) and active (heartbeat within the last 72 hours).
// The returned slice is in no particular order.
func (t *HostLifecycleTracker) StableActiveHosts() []string {
t.mu.RLock()
defer t.mu.RUnlock()
now := t.nowFunc()
var result []string
for id, e := range t.hosts {
if e.StableAt != nil && now.Sub(e.LastSeen) <= HostInactivityTimeout {
result = append(result, id)
}
}
return result
}
// Prune removes hosts that have been inactive for longer than
// HostInactivityTimeout. Call periodically (e.g. hourly) to free memory.
func (t *HostLifecycleTracker) Prune() {
t.mu.Lock()
defer t.mu.Unlock()
now := t.nowFunc()
for id, e := range t.hosts {
if now.Sub(e.LastSeen) > HostInactivityTimeout {
delete(t.hosts, id)
}
}
}
// HostCount returns the total number of tracked hosts (regardless of state).
func (t *HostLifecycleTracker) HostCount() int {
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.hosts)
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
// persistedState is the JSON-serializable representation of the tracker state.
type persistedState struct {
Hosts map[string]*hostEntry `json:"hosts"`
}
// SaveState persists the current tracker state to a JSON file at the given
// path. The file is written atomically (temp file + rename).
func (t *HostLifecycleTracker) SaveState(path string) error {
t.mu.RLock()
state := persistedState{Hosts: t.hosts}
data, err := json.MarshalIndent(state, "", " ")
t.mu.RUnlock()
if err != nil {
return err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
f, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
tmp := f.Name()
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// LoadState restores tracker state from a JSON file previously written by
// SaveState. If the file does not exist, the tracker is left empty (no error).
func (t *HostLifecycleTracker) LoadState(path string) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(data) == 0 {
t.mu.Lock()
t.hosts = make(map[string]*hostEntry)
t.mu.Unlock()
return nil
}
var state persistedState
if err := json.Unmarshal(data, &state); err != nil {
return err
}
t.mu.Lock()
defer t.mu.Unlock()
if state.Hosts == nil {
t.hosts = make(map[string]*hostEntry)
return nil
}
// Filter out nil entries that could cause panics on read paths.
for id, e := range state.Hosts {
if e == nil {
delete(state.Hosts, id)
}
}
t.hosts = state.Hosts
return nil
}