mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
256 lines
6.6 KiB
Go
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
|
|
}
|