All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
179 lines
4.6 KiB
Go
179 lines
4.6 KiB
Go
package cluster
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"os"
|
|
"runtime"
|
|
"sync"
|
|
"time"
|
|
|
|
"armature/store"
|
|
)
|
|
|
|
// ConnCounter provides WebSocket connection count (satisfied by events.Hub).
|
|
type ConnCounter interface {
|
|
ConnCount() int
|
|
}
|
|
|
|
// RegistryConfig holds tunable cluster registry parameters.
|
|
type RegistryConfig struct {
|
|
HeartbeatInterval time.Duration
|
|
StaleThreshold time.Duration
|
|
}
|
|
|
|
// Registry manages node self-registration, heartbeat, and stale sweep.
|
|
// Follows the workflow.Scanner pattern: stopCh + wg + ticker.
|
|
type Registry struct {
|
|
nodeID string
|
|
endpoint string
|
|
store store.ClusterStore
|
|
hub ConnCounter
|
|
cfg RegistryConfig
|
|
startTime time.Time
|
|
stopCh chan struct{}
|
|
wg sync.WaitGroup
|
|
|
|
// Optional callbacks — set after construction via setters.
|
|
sandboxStats func() (exec, errors uint64, avgMs float64)
|
|
triggerFires func() int64
|
|
extensionCount func() int
|
|
}
|
|
|
|
// SetSandboxStats registers a callback for sandbox execution counters.
|
|
func (r *Registry) SetSandboxStats(fn func() (uint64, uint64, float64)) {
|
|
r.sandboxStats = fn
|
|
}
|
|
|
|
// SetTriggerFireCount registers a callback for trigger fire count.
|
|
func (r *Registry) SetTriggerFireCount(fn func() int64) {
|
|
r.triggerFires = fn
|
|
}
|
|
|
|
// SetExtensionCount registers a callback for active extension count.
|
|
func (r *Registry) SetExtensionCount(fn func() int) {
|
|
r.extensionCount = fn
|
|
}
|
|
|
|
// NewRegistry creates a cluster registry instance.
|
|
func NewRegistry(nodeID, endpoint string, s store.ClusterStore, hub ConnCounter, cfg RegistryConfig) *Registry {
|
|
return &Registry{
|
|
nodeID: nodeID,
|
|
endpoint: endpoint,
|
|
store: s,
|
|
hub: hub,
|
|
cfg: cfg,
|
|
startTime: time.Now(),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// NodeID returns this node's identifier.
|
|
func (r *Registry) NodeID() string { return r.nodeID }
|
|
|
|
// Start registers the node and launches the heartbeat goroutine.
|
|
func (r *Registry) Start() error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := r.store.Register(ctx, r.nodeID, r.endpoint); err != nil {
|
|
return err
|
|
}
|
|
|
|
r.wg.Add(1)
|
|
go func() {
|
|
defer r.wg.Done()
|
|
ticker := time.NewTicker(r.cfg.HeartbeatInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
r.tick()
|
|
case <-r.stopCh:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
log.Printf("[cluster] started (node=%s, heartbeat=%s, stale=%s)",
|
|
r.nodeID, r.cfg.HeartbeatInterval, r.cfg.StaleThreshold)
|
|
return nil
|
|
}
|
|
|
|
// Stop deregisters the node and waits for the heartbeat goroutine to exit.
|
|
func (r *Registry) Stop() {
|
|
close(r.stopCh)
|
|
r.wg.Wait()
|
|
|
|
// Best-effort deregister so peers don't have to wait for stale sweep.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := r.store.Deregister(ctx, r.nodeID); err != nil {
|
|
log.Printf("[cluster] deregister failed: %v", err)
|
|
}
|
|
log.Printf("[cluster] stopped (node=%s)", r.nodeID)
|
|
}
|
|
|
|
// tick runs one heartbeat + sweep cycle.
|
|
func (r *Registry) tick() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
stats := r.collectStats()
|
|
|
|
rows, err := r.store.Heartbeat(ctx, r.nodeID, stats)
|
|
if err != nil {
|
|
log.Printf("[cluster] heartbeat error: %v", err)
|
|
return
|
|
}
|
|
if rows == 0 {
|
|
// Self-eviction: this node was swept by a peer.
|
|
// Kubernetes will restart the process → re-register.
|
|
log.Printf("[cluster] FATAL: node %s evicted from registry — shutting down", r.nodeID)
|
|
os.Exit(1)
|
|
}
|
|
|
|
swept, err := r.store.SweepStale(ctx, r.cfg.StaleThreshold)
|
|
if err != nil {
|
|
log.Printf("[cluster] sweep error: %v", err)
|
|
return
|
|
}
|
|
if swept > 0 {
|
|
log.Printf("[cluster] swept %d stale node(s)", swept)
|
|
}
|
|
}
|
|
|
|
// collectStats gathers runtime metrics for the stats JSONB column.
|
|
func (r *Registry) collectStats() json.RawMessage {
|
|
var m runtime.MemStats
|
|
runtime.ReadMemStats(&m)
|
|
|
|
stats := map[string]any{
|
|
"goroutines": runtime.NumGoroutine(),
|
|
"heap_alloc": m.HeapAlloc,
|
|
"heap_sys": m.HeapSys,
|
|
"stack_in_use": m.StackInuse,
|
|
"gc_cycles": m.NumGC,
|
|
"gc_pause_ns": m.PauseNs[(m.NumGC+255)%256],
|
|
"gc_cpu_pct": m.GCCPUFraction * 100,
|
|
"uptime_sec": time.Since(r.startTime).Seconds(),
|
|
"ws_clients": r.hub.ConnCount(),
|
|
}
|
|
|
|
if r.extensionCount != nil {
|
|
stats["extensions_loaded"] = r.extensionCount()
|
|
}
|
|
if r.sandboxStats != nil {
|
|
exec, errors, avgMs := r.sandboxStats()
|
|
stats["starlark_exec_total"] = exec
|
|
stats["starlark_errors_total"] = errors
|
|
stats["starlark_avg_duration_ms"] = avgMs
|
|
}
|
|
if r.triggerFires != nil {
|
|
stats["trigger_fires_total"] = r.triggerFires()
|
|
}
|
|
|
|
data, _ := json.Marshal(stats)
|
|
return data
|
|
}
|