Feat v0.6.0 cluster registry (#36)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m40s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 1m22s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #36.
This commit is contained in:
2026-03-30 22:57:11 +00:00
committed by xcaliber
parent 768f15b3cd
commit 1a7f41493d
21 changed files with 1101 additions and 19 deletions

143
server/cluster/registry.go Normal file
View File

@@ -0,0 +1,143 @@
package cluster
import (
"context"
"encoding/json"
"log"
"os"
"runtime"
"sync"
"time"
"switchboard-core/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
}
// 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,
"gc_cycles": m.NumGC,
"gc_pause_ns": m.PauseNs[(m.NumGC+255)%256],
"uptime_sec": time.Since(r.startTime).Seconds(),
"ws_clients": r.hub.ConnCount(),
}
data, _ := json.Marshal(stats)
return data
}