merge: maestro/tsk_NPgPRxBGv0g9 into main (bulk integration)
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
package orchestrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/idgen"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Redis key constants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// breakerWindowKey is the sliding-window ZSET key prefix.
|
||||
// Full key: sched:breaker:{tier}:{region}
|
||||
// Schema: member = replacement UUID, score = Unix timestamp (seconds).
|
||||
breakerWindowKey = "sched:breaker:"
|
||||
|
||||
// breakerTripKey is the trip-flag key prefix.
|
||||
// Full key: sched:breaker:trip:{tier}:{region}
|
||||
// TTL = BreakerWindowMin. Used for: alert dedup + admin visibility.
|
||||
// The key auto-expires when the sliding window clears (enabling auto-recovery).
|
||||
breakerTripKey = "sched:breaker:trip:"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RedisBreaker
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// RedisBreaker is the production 15F circuit-breaker. It satisfies the
|
||||
// Breaker interface (Allow + Record) and additionally exposes Reset for the
|
||||
// admin handler (task #8).
|
||||
//
|
||||
// Mechanism:
|
||||
//
|
||||
// ZSET sched:breaker:{tier}:{region}
|
||||
// member = replacement UUID (unique per completed replacement)
|
||||
// score = Unix timestamp of completion
|
||||
//
|
||||
// Window: BreakerWindowMin (default 60 min). Each Allow call
|
||||
// runs ZREMRANGEBYSCORE first to expire out-of-window entries.
|
||||
//
|
||||
// Threshold N:
|
||||
// N = ceil(poolTarget × BreakerFractionPct / 100), min BreakerMinN (3).
|
||||
//
|
||||
// Trip:
|
||||
// When window-count ≥ N: Allow returns false. The first detection in a
|
||||
// window epoch sets a trip-flag key with TTL = BreakerWindowMin and emits
|
||||
// a critical alert (idempotent via SetNX).
|
||||
//
|
||||
// Auto-recovery:
|
||||
// After BreakerWindowMin minutes with no new Record calls, all ZSET entries
|
||||
// expire out of the window and the trip flag TTL also expires → Allow true.
|
||||
//
|
||||
// Manual reset:
|
||||
// Admin calls Reset, which deletes the trip flag and the ZSET immediately.
|
||||
type RedisBreaker struct {
|
||||
rdb *redis.Client
|
||||
pools PoolReader
|
||||
notifier Notifier
|
||||
lc LifecycleService // for audit log; may be nil
|
||||
cfgMgr *ConfigManager
|
||||
clock Clock
|
||||
}
|
||||
|
||||
// RedisBreakerConfig holds all dependencies for NewRedisBreaker.
|
||||
type RedisBreakerConfig struct {
|
||||
RDB *redis.Client
|
||||
Pools PoolReader
|
||||
Notifier Notifier
|
||||
LC LifecycleService // for audit log; may be nil
|
||||
CfgMgr *ConfigManager
|
||||
Clock Clock
|
||||
}
|
||||
|
||||
// NewRedisBreaker constructs a RedisBreaker. If Clock is nil, RealClock is used.
|
||||
func NewRedisBreaker(cfg RedisBreakerConfig) *RedisBreaker {
|
||||
if cfg.Clock == nil {
|
||||
cfg.Clock = RealClock{}
|
||||
}
|
||||
return &RedisBreaker{
|
||||
rdb: cfg.RDB,
|
||||
pools: cfg.Pools,
|
||||
notifier: cfg.Notifier,
|
||||
lc: cfg.LC,
|
||||
cfgMgr: cfg.CfgMgr,
|
||||
clock: cfg.Clock,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow implements Breaker. Returns false when the pool's sliding-window
|
||||
// replacement count has reached the threshold (breaker is tripped), blocking
|
||||
// further replacements from starting.
|
||||
//
|
||||
// On Redis errors the breaker fails open (returns true) to avoid a single
|
||||
// point of failure halting all replacements.
|
||||
func (b *RedisBreaker) Allow(tier, region string) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
cfg := b.cfgMgr.Current()
|
||||
window := time.Duration(cfg.Capacity.BreakerWindowMin) * time.Minute
|
||||
threshold := b.computeThreshold(ctx, tier, region, cfg)
|
||||
|
||||
zKey := breakerWindowKey + tier + ":" + region
|
||||
tripKey := breakerTripKey + tier + ":" + region
|
||||
cutoff := b.clock.Now().Add(-window)
|
||||
|
||||
// Expire entries outside the sliding window.
|
||||
cutoffStr := fmt.Sprintf("%d", cutoff.Unix())
|
||||
if err := b.rdb.ZRemRangeByScore(ctx, zKey, "-inf", cutoffStr).Err(); err != nil {
|
||||
slog.Error("breaker: ZREMRANGEBYSCORE failed; failing open",
|
||||
"tier", tier, "region", region, "error", err)
|
||||
return true // fail open
|
||||
}
|
||||
|
||||
// Count remaining (in-window) entries.
|
||||
count, err := b.rdb.ZCard(ctx, zKey).Result()
|
||||
if err != nil {
|
||||
slog.Error("breaker: ZCARD failed; failing open",
|
||||
"tier", tier, "region", region, "error", err)
|
||||
return true // fail open
|
||||
}
|
||||
|
||||
if count < int64(threshold) {
|
||||
return true // below threshold — allow replacement
|
||||
}
|
||||
|
||||
// Count ≥ threshold: trip. Emit alert exactly once per window epoch via SetNX.
|
||||
set, setErr := b.rdb.SetNX(ctx, tripKey, "1", window).Result()
|
||||
if setErr != nil {
|
||||
slog.Error("breaker: trip flag set failed",
|
||||
"tier", tier, "region", region, "error", setErr)
|
||||
}
|
||||
if set {
|
||||
b.emitTripAlert(ctx, tier, region, int(count), threshold, cfg)
|
||||
}
|
||||
|
||||
slog.Warn("breaker: replacement blocked — circuit open",
|
||||
"tier", tier, "region", region,
|
||||
"window_count", count, "threshold", threshold,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record implements Breaker. Records a completed replacement for the given
|
||||
// pool in the sliding-window ZSET. Called by the orchestrator after the old
|
||||
// node is successfully destroyed.
|
||||
//
|
||||
// Record also checks whether the count has just reached the threshold and,
|
||||
// if so, trips the breaker and emits a critical alert.
|
||||
func (b *RedisBreaker) Record(tier, region string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
cfg := b.cfgMgr.Current()
|
||||
window := time.Duration(cfg.Capacity.BreakerWindowMin) * time.Minute
|
||||
threshold := b.computeThreshold(ctx, tier, region, cfg)
|
||||
|
||||
zKey := breakerWindowKey + tier + ":" + region
|
||||
tripKey := breakerTripKey + tier + ":" + region
|
||||
now := b.clock.Now()
|
||||
|
||||
// Add a unique entry for this replacement.
|
||||
member := idgen.NewString()
|
||||
if err := b.rdb.ZAdd(ctx, zKey, redis.Z{
|
||||
Score: float64(now.Unix()),
|
||||
Member: member,
|
||||
}).Err(); err != nil {
|
||||
slog.Error("breaker: ZADD failed",
|
||||
"tier", tier, "region", region, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have just reached the threshold; trip once per epoch.
|
||||
count, err := b.rdb.ZCard(ctx, zKey).Result()
|
||||
if err != nil {
|
||||
slog.Error("breaker: ZCARD after record failed",
|
||||
"tier", tier, "region", region, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if count >= int64(threshold) {
|
||||
set, setErr := b.rdb.SetNX(ctx, tripKey, "1", window).Result()
|
||||
if setErr != nil {
|
||||
slog.Error("breaker: trip flag on record failed",
|
||||
"tier", tier, "region", region, "error", setErr)
|
||||
return
|
||||
}
|
||||
if set {
|
||||
// First trip in this window epoch: emit critical alert.
|
||||
b.emitTripAlert(ctx, tier, region, int(count), threshold, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears a tripped breaker for the given pool by deleting both the trip
|
||||
// flag and the sliding-window ZSET. This lets new replacements proceed
|
||||
// immediately without waiting for the window to auto-expire.
|
||||
//
|
||||
// An audit log entry is written with actor and action metadata.
|
||||
// Reset is not part of the Breaker interface; it is called only by the admin
|
||||
// handler (task #8).
|
||||
func (b *RedisBreaker) Reset(ctx context.Context, tier, region, actor string) error {
|
||||
zKey := breakerWindowKey + tier + ":" + region
|
||||
tripKey := breakerTripKey + tier + ":" + region
|
||||
|
||||
pipe := b.rdb.Pipeline()
|
||||
pipe.Del(ctx, tripKey)
|
||||
pipe.Del(ctx, zKey)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return fmt.Errorf("breaker: reset %s/%s: %w", tier, region, err)
|
||||
}
|
||||
|
||||
slog.Info("breaker: manually reset by operator",
|
||||
"tier", tier, "region", region, "actor", actor)
|
||||
|
||||
// Audit trail.
|
||||
meta := fmt.Sprintf(
|
||||
`{"tier":%q,"region":%q,"actor":%q,"action":"breaker_reset"}`,
|
||||
tier, region, actor,
|
||||
)
|
||||
if b.lc != nil {
|
||||
if err := b.lc.WriteAuditLog(
|
||||
ctx, actor, "breaker_reset", "pool:"+tier+":"+region, meta,
|
||||
); err != nil {
|
||||
slog.Error("breaker: write audit log failed", "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsTripped reports whether the trip flag is currently set for the pool.
|
||||
// Useful for admin status queries.
|
||||
func (b *RedisBreaker) IsTripped(ctx context.Context, tier, region string) (bool, error) {
|
||||
tripKey := breakerTripKey + tier + ":" + region
|
||||
exists, err := b.rdb.Exists(ctx, tripKey).Result()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("breaker: IsTripped %s/%s: %w", tier, region, err)
|
||||
}
|
||||
return exists > 0, nil
|
||||
}
|
||||
|
||||
// WindowCount returns the number of completed replacements currently recorded
|
||||
// in the sliding window for the pool. Useful for admin status queries.
|
||||
func (b *RedisBreaker) WindowCount(ctx context.Context, tier, region string) (int64, error) {
|
||||
cfg := b.cfgMgr.Current()
|
||||
window := time.Duration(cfg.Capacity.BreakerWindowMin) * time.Minute
|
||||
cutoff := b.clock.Now().Add(-window)
|
||||
cutoffStr := fmt.Sprintf("%d", cutoff.Unix())
|
||||
|
||||
zKey := breakerWindowKey + tier + ":" + region
|
||||
if err := b.rdb.ZRemRangeByScore(ctx, zKey, "-inf", cutoffStr).Err(); err != nil {
|
||||
return 0, fmt.Errorf("breaker: WindowCount cleanup %s/%s: %w", tier, region, err)
|
||||
}
|
||||
count, err := b.rdb.ZCard(ctx, zKey).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("breaker: WindowCount ZCARD %s/%s: %w", tier, region, err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// computeThreshold returns the trip threshold for a pool:
|
||||
//
|
||||
// N = ceil(poolTarget × BreakerFractionPct / 100), minimum BreakerMinN.
|
||||
//
|
||||
// Falls back to BreakerMinN when the pool cannot be found or has zero target.
|
||||
func (b *RedisBreaker) computeThreshold(ctx context.Context, tier, region string, cfg *SchedConfig) int {
|
||||
_, target, err := b.pools.PoolCapacity(ctx, tier, region)
|
||||
if err != nil || target <= 0 {
|
||||
return cfg.Capacity.BreakerMinN
|
||||
}
|
||||
n := int(math.Ceil(float64(target) * float64(cfg.Capacity.BreakerFractionPct) / 100.0))
|
||||
if n < cfg.Capacity.BreakerMinN {
|
||||
n = cfg.Capacity.BreakerMinN
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// emitTripAlert sends a critical alert to the Notifier for a tripped breaker.
|
||||
func (b *RedisBreaker) emitTripAlert(ctx context.Context, tier, region string, count, threshold int, cfg *SchedConfig) {
|
||||
poolID := "pool:" + tier + ":" + region
|
||||
reason := fmt.Sprintf(
|
||||
"circuit breaker tripped: %d replacements in %d-min window (threshold=%d) — manual Reset required",
|
||||
count, cfg.Capacity.BreakerWindowMin, threshold,
|
||||
)
|
||||
slog.Error("breaker: circuit breaker tripped",
|
||||
"tier", tier, "region", region,
|
||||
"window_count", count, "threshold", threshold,
|
||||
)
|
||||
if b.notifier != nil {
|
||||
if err := b.notifier.NotifyFault(ctx, poolID, reason); err != nil {
|
||||
slog.Error("breaker: emit trip alert failed",
|
||||
"pool", poolID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package orchestrate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/scheduler/orchestrate"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Mock PoolReader
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// mockPoolReader is a simple in-memory PoolReader for tests.
|
||||
type mockPoolReader struct {
|
||||
mu sync.Mutex
|
||||
pools map[string]orchestrate.PoolStat // key = tier+":"+region
|
||||
}
|
||||
|
||||
func newMockPoolReader() *mockPoolReader {
|
||||
return &mockPoolReader{pools: make(map[string]orchestrate.PoolStat)}
|
||||
}
|
||||
|
||||
func (r *mockPoolReader) setPool(tier, region string, up, target int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.pools[tier+":"+region] = orchestrate.PoolStat{
|
||||
Tier: tier, Region: region, UpCount: up, Target: target,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *mockPoolReader) AllPools(_ context.Context) ([]orchestrate.PoolStat, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]orchestrate.PoolStat, 0, len(r.pools))
|
||||
for _, s := range r.pools {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *mockPoolReader) PoolCapacity(_ context.Context, tier, region string) (int, int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.pools[tier+":"+region]
|
||||
if !ok {
|
||||
return 0, 0, nil
|
||||
}
|
||||
return s.UpCount, s.Target, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helper: build a RedisBreaker for tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func newTestBreaker(t *testing.T, pools *mockPoolReader, notifier *mockNotifier, clock *mockClock) (*orchestrate.RedisBreaker, *orchestrate.ConfigManager) {
|
||||
t.Helper()
|
||||
rdb, _ := newTestRedis(t)
|
||||
cfgMgr := orchestrate.NewConfigManager("", nil)
|
||||
_ = cfgMgr.Load()
|
||||
|
||||
return orchestrate.NewRedisBreaker(orchestrate.RedisBreakerConfig{
|
||||
RDB: rdb,
|
||||
Pools: pools,
|
||||
Notifier: notifier,
|
||||
CfgMgr: cfgMgr,
|
||||
Clock: clock,
|
||||
}), cfgMgr
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: N-th Record trips the breaker; (N+1)-th Allow returns false
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerTripsAtThreshold(t *testing.T) {
|
||||
const (
|
||||
tier = "free"
|
||||
region = "hkg"
|
||||
)
|
||||
|
||||
pools := newMockPoolReader()
|
||||
// Pool of 10 nodes → threshold = ceil(10 × 30%) = 3.
|
||||
pools.setPool(tier, region, 10, 10)
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
|
||||
b, _ := newTestBreaker(t, pools, notifier, clock)
|
||||
|
||||
// Before any records: Allow should return true.
|
||||
if !b.Allow(tier, region) {
|
||||
t.Fatal("Allow should return true before any records")
|
||||
}
|
||||
|
||||
// Record N-1 replacements (threshold-1 = 2); Allow should still be true.
|
||||
b.Record(tier, region) // count = 1
|
||||
b.Record(tier, region) // count = 2
|
||||
if !b.Allow(tier, region) {
|
||||
t.Fatalf("Allow should return true with count < threshold (count=2, threshold=3)")
|
||||
}
|
||||
|
||||
// Record the N-th replacement; now count = 3 = threshold.
|
||||
b.Record(tier, region) // count = 3
|
||||
|
||||
// (N+1)-th Allow: breaker is tripped → false.
|
||||
if b.Allow(tier, region) {
|
||||
t.Error("Allow should return false when count >= threshold (tripped)")
|
||||
}
|
||||
|
||||
// Critical alert must have been emitted exactly once.
|
||||
if n := notifier.count(); n != 1 {
|
||||
t.Errorf("NotifyFault calls = %d; want 1 (on trip)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Window slide-out auto-recovers the breaker
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerAutoRecoveryAfterWindowExpiry(t *testing.T) {
|
||||
const (
|
||||
tier = "premium"
|
||||
region = "sin"
|
||||
)
|
||||
|
||||
pools := newMockPoolReader()
|
||||
// Pool of 10 → threshold = 3.
|
||||
pools.setPool(tier, region, 10, 10)
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
b, _ := newTestBreaker(t, pools, notifier, clock)
|
||||
|
||||
// Record N entries to trip.
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
if b.Allow(tier, region) {
|
||||
t.Fatal("breaker should be tripped after 3 records (threshold=3)")
|
||||
}
|
||||
|
||||
// Advance clock past the 1-h window; all ZSET entries are now stale.
|
||||
clock.advance(61 * time.Minute)
|
||||
|
||||
// Allow should return true: ZREMRANGEBYSCORE removes stale entries,
|
||||
// count drops to 0 < threshold, and the trip flag TTL has expired.
|
||||
if !b.Allow(tier, region) {
|
||||
t.Error("Allow should return true after the window has expired (auto-recovery)")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Reset manually clears the breaker with audit
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerResetRestoresAllow(t *testing.T) {
|
||||
const (
|
||||
tier = "free"
|
||||
region = "tyo"
|
||||
)
|
||||
|
||||
pools := newMockPoolReader()
|
||||
pools.setPool(tier, region, 10, 10) // threshold = 3
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
lc := newMockLC()
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
rdb, _ := newTestRedis(t)
|
||||
cfgMgr := orchestrate.NewConfigManager("", nil)
|
||||
_ = cfgMgr.Load()
|
||||
|
||||
b := orchestrate.NewRedisBreaker(orchestrate.RedisBreakerConfig{
|
||||
RDB: rdb,
|
||||
Pools: pools,
|
||||
Notifier: notifier,
|
||||
LC: lc,
|
||||
CfgMgr: cfgMgr,
|
||||
Clock: clock,
|
||||
})
|
||||
|
||||
// Trip the breaker.
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
if b.Allow(tier, region) {
|
||||
t.Fatal("breaker should be tripped")
|
||||
}
|
||||
|
||||
// Reset.
|
||||
ctx := context.Background()
|
||||
if err := b.Reset(ctx, tier, region, "admin-alice"); err != nil {
|
||||
t.Fatalf("Reset error: %v", err)
|
||||
}
|
||||
|
||||
// Allow should now return true.
|
||||
if !b.Allow(tier, region) {
|
||||
t.Error("Allow should return true after Reset")
|
||||
}
|
||||
|
||||
// Audit log must mention the reset.
|
||||
if !lc.hasAudit("admin-alice|breaker_reset|pool:" + tier + ":" + region) {
|
||||
t.Errorf("audit log missing breaker_reset entry; got: %v", lc.auditLogs)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: BreakerMinN = 3 enforced on tiny pool (target < 10)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerMinNEnforcedOnSmallPool(t *testing.T) {
|
||||
const (
|
||||
tier = "free"
|
||||
region = "fra"
|
||||
)
|
||||
|
||||
pools := newMockPoolReader()
|
||||
// Pool of 2 nodes; 30% of 2 = 0.6 → ceil = 1, but min = 3.
|
||||
pools.setPool(tier, region, 2, 2)
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
b, _ := newTestBreaker(t, pools, notifier, clock)
|
||||
|
||||
// 2 records should NOT trip (threshold = 3, not 1).
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
if !b.Allow(tier, region) {
|
||||
t.Error("Allow should return true: count=2, threshold=min(3)")
|
||||
}
|
||||
|
||||
// 3rd record trips.
|
||||
b.Record(tier, region)
|
||||
if b.Allow(tier, region) {
|
||||
t.Error("Allow should return false: count=3 >= minN(3)")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Trip alert is emitted only once per window epoch (dedup)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerAlertSentOnlyOnce(t *testing.T) {
|
||||
const (
|
||||
tier = "free"
|
||||
region = "ams"
|
||||
)
|
||||
|
||||
pools := newMockPoolReader()
|
||||
pools.setPool(tier, region, 10, 10) // threshold = 3
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
b, _ := newTestBreaker(t, pools, notifier, clock)
|
||||
|
||||
// Trip.
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
|
||||
// Call Allow multiple times while tripped.
|
||||
for i := 0; i < 5; i++ {
|
||||
b.Allow(tier, region)
|
||||
}
|
||||
|
||||
// NotifyFault must have been called exactly once (from Record reaching N).
|
||||
if n := notifier.count(); n != 1 {
|
||||
t.Errorf("NotifyFault calls = %d; want exactly 1 (trip alert dedup)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: pool not found → falls back to BreakerMinN (3)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerFallsBackToMinNForUnknownPool(t *testing.T) {
|
||||
pools := newMockPoolReader()
|
||||
// No pool registered for "unknown"/"xyz".
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
b, _ := newTestBreaker(t, pools, notifier, clock)
|
||||
|
||||
b.Record("unknown", "xyz")
|
||||
b.Record("unknown", "xyz")
|
||||
if !b.Allow("unknown", "xyz") {
|
||||
t.Error("Allow should return true: count=2, threshold=minN(3) for unknown pool")
|
||||
}
|
||||
|
||||
b.Record("unknown", "xyz")
|
||||
if b.Allow("unknown", "xyz") {
|
||||
t.Error("Allow should return false: count=3 >= minN(3)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package orchestrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PoolReader — pool capacity interface
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// PoolStat holds capacity metrics for a single tier+region pool.
|
||||
type PoolStat struct {
|
||||
Tier string
|
||||
Region string
|
||||
UpCount int // number of nodes currently in "up" status
|
||||
Target int // configured target node count for this pool
|
||||
}
|
||||
|
||||
// PoolReader provides pool capacity information for the capacity monitor and
|
||||
// circuit breaker. The real implementation (wired by task 15H) queries the
|
||||
// lifecycle service.
|
||||
type PoolReader interface {
|
||||
// AllPools returns all known pools with their current UpCount and Target.
|
||||
AllPools(ctx context.Context) ([]PoolStat, error)
|
||||
|
||||
// PoolCapacity returns (upCount, target) for the named pool.
|
||||
// Returns (0, 0, nil) when the pool is not found.
|
||||
PoolCapacity(ctx context.Context, tier, region string) (int, int, error)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Redis key constants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// capAlertPoolPrefix is the dedup key prefix for pool-watermark alerts.
|
||||
// Full key: sched:cap:pool:{tier}:{region} TTL = AlertSuppressMin
|
||||
capAlertPoolPrefix = "sched:cap:pool:"
|
||||
|
||||
// capAlertProbePrefix is the dedup key prefix for probe-lost-contact alerts.
|
||||
// Full key: sched:cap:probe:{probeId} TTL = AlertSuppressMin
|
||||
capAlertProbePrefix = "sched:cap:probe:"
|
||||
|
||||
// probeHBKeyPrefix is the heartbeat key prefix written by the probe package.
|
||||
// Full key: probe:hb:{probeId} TTL = 15 min (from probe.heartbeatTTL)
|
||||
probeHBKeyPrefix = "probe:hb:"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CapacityMonitor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// CapacityMonitorConfig holds all dependencies for CapacityMonitor.
|
||||
type CapacityMonitorConfig struct {
|
||||
RDB *redis.Client
|
||||
Pools PoolReader
|
||||
Notifier Notifier
|
||||
Gray *Grayscale // grayscale weight-ramp driver
|
||||
LC LifecycleService // for WriteAuditLog; may be nil
|
||||
CfgMgr *ConfigManager
|
||||
Clock Clock
|
||||
}
|
||||
|
||||
// CapacityMonitor checks pool watermarks and probe heartbeats, and drives
|
||||
// the grayscale weight ramp. It is called by the CapacityLoop (task 15H)
|
||||
// approximately every 1–2 minutes.
|
||||
//
|
||||
// Each RunOnce call:
|
||||
// 1. Checks pool fill-rates; emits a throttled alert per pool below threshold.
|
||||
// 2. Checks probe heartbeats for every configured probe ID; emits a throttled
|
||||
// 「探针失联」alert for each probe whose heartbeat key is absent.
|
||||
// 3. Calls Grayscale.Advance to advance any in-progress weight ramps.
|
||||
type CapacityMonitor struct {
|
||||
rdb *redis.Client
|
||||
pools PoolReader
|
||||
notifier Notifier
|
||||
gray *Grayscale
|
||||
lc LifecycleService
|
||||
cfgMgr *ConfigManager
|
||||
clock Clock
|
||||
}
|
||||
|
||||
// NewCapacityMonitor constructs a CapacityMonitor from the given config.
|
||||
// If Clock is nil, RealClock is used.
|
||||
func NewCapacityMonitor(cfg CapacityMonitorConfig) *CapacityMonitor {
|
||||
if cfg.Clock == nil {
|
||||
cfg.Clock = RealClock{}
|
||||
}
|
||||
return &CapacityMonitor{
|
||||
rdb: cfg.RDB,
|
||||
pools: cfg.Pools,
|
||||
notifier: cfg.Notifier,
|
||||
gray: cfg.Gray,
|
||||
lc: cfg.LC,
|
||||
cfgMgr: cfg.CfgMgr,
|
||||
clock: cfg.Clock,
|
||||
}
|
||||
}
|
||||
|
||||
// RunOnce executes one full capacity-monitoring cycle. Errors from individual
|
||||
// steps are logged but do not abort subsequent steps.
|
||||
func (m *CapacityMonitor) RunOnce(ctx context.Context) error {
|
||||
cfg := m.cfgMgr.Current()
|
||||
|
||||
// Step 1: pool watermark checks.
|
||||
if err := m.checkWatermarks(ctx, cfg); err != nil {
|
||||
slog.Error("capacity: watermark check error", "error", err)
|
||||
}
|
||||
|
||||
// Step 2: probe heartbeat checks.
|
||||
if err := m.checkProbeHeartbeats(ctx, cfg); err != nil {
|
||||
slog.Error("capacity: probe heartbeat check error", "error", err)
|
||||
}
|
||||
|
||||
// Step 3: advance grayscale weight ramps (Grayscale.Advance is 15E's
|
||||
// warm-up entry point; CapacityLoop drives it per the spec).
|
||||
if m.gray != nil {
|
||||
if err := m.gray.Advance(ctx); err != nil {
|
||||
slog.Error("capacity: grayscale advance error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkWatermarks iterates all known pools. For each pool whose fill-rate
|
||||
// (upCount/target) is below WatermarkThreshold, an alert is emitted — but
|
||||
// at most once per AlertSuppressMin to suppress repeated notifications.
|
||||
func (m *CapacityMonitor) checkWatermarks(ctx context.Context, cfg *SchedConfig) error {
|
||||
stats, err := m.pools.AllPools(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("capacity: list pools: %w", err)
|
||||
}
|
||||
|
||||
threshold := cfg.Capacity.WatermarkThreshold
|
||||
suppressTTL := time.Duration(cfg.Capacity.AlertSuppressMin) * time.Minute
|
||||
|
||||
for _, stat := range stats {
|
||||
if stat.Target <= 0 {
|
||||
continue // skip misconfigured pools with no target
|
||||
}
|
||||
fillRate := float64(stat.UpCount) / float64(stat.Target)
|
||||
if fillRate >= threshold {
|
||||
continue // pool is healthy
|
||||
}
|
||||
|
||||
// Throttle: at most one alert per pool per AlertSuppressMin.
|
||||
dedupKey := capAlertPoolPrefix + stat.Tier + ":" + stat.Region
|
||||
set, setErr := m.rdb.SetNX(ctx, dedupKey, "1", suppressTTL).Result()
|
||||
if setErr != nil {
|
||||
slog.Error("capacity: watermark dedup key error",
|
||||
"key", dedupKey, "error", setErr)
|
||||
continue
|
||||
}
|
||||
if !set {
|
||||
continue // alert already sent within suppression window
|
||||
}
|
||||
|
||||
poolID := "pool:" + stat.Tier + ":" + stat.Region
|
||||
reason := fmt.Sprintf(
|
||||
"capacity watermark below %.0f%%: %d/%d up (fill_rate=%.1f%%)",
|
||||
threshold*100, stat.UpCount, stat.Target, fillRate*100,
|
||||
)
|
||||
|
||||
slog.Warn("capacity: pool below watermark",
|
||||
"tier", stat.Tier,
|
||||
"region", stat.Region,
|
||||
"up", stat.UpCount,
|
||||
"target", stat.Target,
|
||||
"fill_rate_pct", fmt.Sprintf("%.1f", fillRate*100),
|
||||
)
|
||||
|
||||
if m.notifier != nil {
|
||||
if notifyErr := m.notifier.NotifyFault(ctx, poolID, reason); notifyErr != nil {
|
||||
slog.Error("capacity: notify watermark alert",
|
||||
"pool", poolID, "error", notifyErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkProbeHeartbeats verifies that every configured probe agent has a live
|
||||
// heartbeat key in Redis. A missing key means the probe has not reported
|
||||
// within the 15-minute heartbeat TTL (probe package constant heartbeatTTL).
|
||||
//
|
||||
// Semantics: absence of the key is "no recent data", NOT "node blocked". It
|
||||
// must NOT feed into any determination logic — only ops alerting.
|
||||
func (m *CapacityMonitor) checkProbeHeartbeats(ctx context.Context, cfg *SchedConfig) error {
|
||||
probeIDs := cfg.Capacity.ExpectedProbeIDs
|
||||
if len(probeIDs) == 0 {
|
||||
return nil // no probes configured; nothing to check
|
||||
}
|
||||
|
||||
suppressTTL := time.Duration(cfg.Capacity.AlertSuppressMin) * time.Minute
|
||||
|
||||
for _, pid := range probeIDs {
|
||||
hbKey := probeHBKeyPrefix + pid
|
||||
exists, err := m.rdb.Exists(ctx, hbKey).Result()
|
||||
if err != nil {
|
||||
slog.Error("capacity: probe heartbeat check error",
|
||||
"probe_id", pid, "error", err)
|
||||
continue
|
||||
}
|
||||
if exists > 0 {
|
||||
continue // heartbeat key present — probe is alive
|
||||
}
|
||||
|
||||
// Heartbeat key absent: throttled 「探针失联」alert.
|
||||
dedupKey := capAlertProbePrefix + pid
|
||||
set, setErr := m.rdb.SetNX(ctx, dedupKey, "1", suppressTTL).Result()
|
||||
if setErr != nil {
|
||||
slog.Error("capacity: probe alert dedup key error",
|
||||
"probe_id", pid, "error", setErr)
|
||||
continue
|
||||
}
|
||||
if !set {
|
||||
continue // alert already sent within suppression window
|
||||
}
|
||||
|
||||
probeNodeID := "probe:" + pid
|
||||
reason := "探针失联: no heartbeat received in last 15 min (no data, not a block signal)"
|
||||
|
||||
slog.Warn("capacity: probe lost contact",
|
||||
"probe_id", pid,
|
||||
"hb_key", hbKey,
|
||||
)
|
||||
|
||||
if m.notifier != nil {
|
||||
if notifyErr := m.notifier.NotifyFault(ctx, probeNodeID, reason); notifyErr != nil {
|
||||
slog.Error("capacity: notify probe lost contact",
|
||||
"probe_id", pid, "error", notifyErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package orchestrate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/scheduler/orchestrate"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utility: write a temporary YAML config file
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func writeTempYAML(t *testing.T, content string) (string, error) {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", "sched_config_*.yaml")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
t.Cleanup(func() { os.Remove(f.Name()) })
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
return "", err
|
||||
}
|
||||
f.Close()
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
// defaultCfgMgr returns a ConfigManager loaded with production defaults.
|
||||
func defaultCfgMgr(t *testing.T) *orchestrate.ConfigManager {
|
||||
t.Helper()
|
||||
m := orchestrate.NewConfigManager("", nil)
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatalf("ConfigManager.Load: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Pool below 70% watermark → alert emitted
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCapacityWatermarkAlert(t *testing.T) {
|
||||
rdb, _ := newTestRedis(t)
|
||||
pools := newMockPoolReader()
|
||||
// 6 out of 10 up = 60% < 70% → alert expected.
|
||||
pools.setPool("free", "hkg", 6, 10)
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
|
||||
RDB: rdb,
|
||||
Pools: pools,
|
||||
Notifier: notifier,
|
||||
CfgMgr: defaultCfgMgr(t),
|
||||
})
|
||||
|
||||
if err := cm.RunOnce(context.Background()); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
|
||||
if notifier.count() != 1 {
|
||||
t.Errorf("NotifyFault calls = %d; want 1 (watermark alert)", notifier.count())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Pool at exactly 70% → no alert
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCapacityWatermarkNoAlertAtThreshold(t *testing.T) {
|
||||
rdb, _ := newTestRedis(t)
|
||||
pools := newMockPoolReader()
|
||||
// 7/10 = 70% == threshold → healthy, no alert.
|
||||
pools.setPool("free", "hkg", 7, 10)
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
|
||||
RDB: rdb,
|
||||
Pools: pools,
|
||||
Notifier: notifier,
|
||||
CfgMgr: defaultCfgMgr(t),
|
||||
})
|
||||
|
||||
if err := cm.RunOnce(context.Background()); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
|
||||
if notifier.count() != 0 {
|
||||
t.Errorf("NotifyFault calls = %d; want 0 (fill rate = 70%% >= threshold)", notifier.count())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Repeated RunOnce within suppress window → only 1 alert
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCapacityWatermarkAlertSuppression(t *testing.T) {
|
||||
rdb, mr := newTestRedis(t)
|
||||
pools := newMockPoolReader()
|
||||
pools.setPool("free", "sin", 5, 10) // 50% < 70%
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
|
||||
RDB: rdb,
|
||||
Pools: pools,
|
||||
Notifier: notifier,
|
||||
CfgMgr: defaultCfgMgr(t),
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Three RunOnce calls within the 10-min suppress window.
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := cm.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if notifier.count() != 1 {
|
||||
t.Errorf("NotifyFault calls = %d; want 1 (suppressed repeats)", notifier.count())
|
||||
}
|
||||
|
||||
// Advance miniredis TTL past the 10-min suppress window.
|
||||
mr.FastForward(11 * time.Minute)
|
||||
|
||||
// After suppress window expires → another alert.
|
||||
if err := cm.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce after expire: %v", err)
|
||||
}
|
||||
if notifier.count() != 2 {
|
||||
t.Errorf("NotifyFault calls = %d; want 2 after suppress window expires", notifier.count())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Missing probe heartbeat → alert; present probe → no alert
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCapacityProbeHeartbeatMissingAlert(t *testing.T) {
|
||||
rdb, _ := newTestRedis(t)
|
||||
pools := newMockPoolReader()
|
||||
notifier := &mockNotifier{}
|
||||
|
||||
ctx := context.Background()
|
||||
// probe-b has a live heartbeat; probe-a does not.
|
||||
if err := rdb.Set(ctx, "probe:hb:probe-b", "1", 15*time.Minute).Err(); err != nil {
|
||||
t.Fatalf("seed probe-b heartbeat: %v", err)
|
||||
}
|
||||
|
||||
yamlContent := `
|
||||
capacity:
|
||||
expected_probe_ids:
|
||||
- probe-a
|
||||
- probe-b
|
||||
alert_suppress_min: 10
|
||||
`
|
||||
tmpFile, err := writeTempYAML(t, yamlContent)
|
||||
if err != nil {
|
||||
t.Fatalf("write temp YAML: %v", err)
|
||||
}
|
||||
cfgMgr := orchestrate.NewConfigManager(tmpFile, nil)
|
||||
if err := cfgMgr.Load(); err != nil {
|
||||
t.Fatalf("Load YAML config: %v", err)
|
||||
}
|
||||
|
||||
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
|
||||
RDB: rdb,
|
||||
Pools: pools,
|
||||
Notifier: notifier,
|
||||
CfgMgr: cfgMgr,
|
||||
})
|
||||
|
||||
if err := cm.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
|
||||
// Only probe-a should trigger an alert.
|
||||
if notifier.count() != 1 {
|
||||
t.Errorf("NotifyFault calls = %d; want 1 (only probe-a missing)", notifier.count())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Both probes alive → no alert
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCapacityProbeHeartbeatBothAlive(t *testing.T) {
|
||||
rdb, _ := newTestRedis(t)
|
||||
pools := newMockPoolReader()
|
||||
notifier := &mockNotifier{}
|
||||
|
||||
ctx := context.Background()
|
||||
rdb.Set(ctx, "probe:hb:probe-a", "1", 15*time.Minute)
|
||||
rdb.Set(ctx, "probe:hb:probe-b", "1", 15*time.Minute)
|
||||
|
||||
yamlContent := `
|
||||
capacity:
|
||||
expected_probe_ids:
|
||||
- probe-a
|
||||
- probe-b
|
||||
`
|
||||
tmpFile, err := writeTempYAML(t, yamlContent)
|
||||
if err != nil {
|
||||
t.Fatalf("write temp YAML: %v", err)
|
||||
}
|
||||
cfgMgr := orchestrate.NewConfigManager(tmpFile, nil)
|
||||
if err := cfgMgr.Load(); err != nil {
|
||||
t.Fatalf("Load YAML: %v", err)
|
||||
}
|
||||
|
||||
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
|
||||
RDB: rdb,
|
||||
Pools: pools,
|
||||
Notifier: notifier,
|
||||
CfgMgr: cfgMgr,
|
||||
})
|
||||
|
||||
if err := cm.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
|
||||
if notifier.count() != 0 {
|
||||
t.Errorf("NotifyFault calls = %d; want 0 (both probes alive)", notifier.count())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Multiple pools — only below-threshold pools alert
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCapacityMultiplePoolsSelectiveAlert(t *testing.T) {
|
||||
rdb, _ := newTestRedis(t)
|
||||
pools := newMockPoolReader()
|
||||
// hkg: 60% → alert
|
||||
pools.setPool("free", "hkg", 6, 10)
|
||||
// sin: 90% → no alert
|
||||
pools.setPool("free", "sin", 9, 10)
|
||||
// tyo: 50% → alert
|
||||
pools.setPool("free", "tyo", 5, 10)
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
|
||||
RDB: rdb,
|
||||
Pools: pools,
|
||||
Notifier: notifier,
|
||||
CfgMgr: defaultCfgMgr(t),
|
||||
})
|
||||
|
||||
if err := cm.RunOnce(context.Background()); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
|
||||
// Expect 2 alerts: hkg and tyo; sin is healthy.
|
||||
if n := notifier.count(); n != 2 {
|
||||
t.Errorf("NotifyFault calls = %d; want 2 (hkg + tyo)", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package orchestrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SchedConfig — master configuration tree
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// SchedConfig is the single source of truth for all scheduler thresholds.
|
||||
// It covers: 15D detection, 15F capacity/breaker, and 15E probe/grayscale.
|
||||
//
|
||||
// Values are layered in order:
|
||||
// 1. Defaults (DefaultSchedConfig).
|
||||
// 2. Optional YAML file (path set via SCHED_CONFIG_FILE env var, or passed
|
||||
// to NewConfigManager).
|
||||
// 3. Environment variable overrides (applied on top of YAML).
|
||||
//
|
||||
// The live config is accessed via ConfigManager.Current() — a lock-free
|
||||
// atomic.Pointer read. Hot reload is triggered by SIGHUP; each reload
|
||||
// computes a JSON diff and calls the provided AuditFn.
|
||||
type SchedConfig struct {
|
||||
// Detect holds 15D detection-engine thresholds.
|
||||
Detect DetectSection `yaml:"detect"`
|
||||
// Capacity holds 15F capacity-monitor and breaker thresholds.
|
||||
Capacity CapacitySection `yaml:"capacity"`
|
||||
// Probe holds 15E probe-window and grayscale-ramp settings.
|
||||
Probe ProbeSection `yaml:"probe"`
|
||||
}
|
||||
|
||||
// DetectSection mirrors detect.DetectConfig; task 15H bridges the two at
|
||||
// wiring time.
|
||||
type DetectSection struct {
|
||||
// DomesticFailNumerator / DomesticFailDenominator define the ISP-failure
|
||||
// fraction that triggers the suspect rule (default: 2/3).
|
||||
DomesticFailNumerator int `yaml:"domestic_fail_numerator"`
|
||||
DomesticFailDenominator int `yaml:"domestic_fail_denominator"`
|
||||
|
||||
// SuspectStreakMin is the number of consecutive failing cycles before a
|
||||
// node enters blocked_suspect (default: 2).
|
||||
SuspectStreakMin int `yaml:"suspect_streak_min"`
|
||||
|
||||
// TrafficDropThreshold is the minimum percentage drop in online connections
|
||||
// over 15 min that activates the traffic-warning rule (default: 80.0).
|
||||
TrafficDropThreshold float64 `yaml:"traffic_drop_threshold"`
|
||||
|
||||
// TrafficBaselineMin is the minimum current online-connection count for
|
||||
// the traffic-warning relaxation to apply (default: 20).
|
||||
TrafficBaselineMin int `yaml:"traffic_baseline_min"`
|
||||
|
||||
// ConfirmedStreakMin is the number of consecutive cycles in blocked_suspect
|
||||
// before promotion to blocked_confirmed (default: 6).
|
||||
ConfirmedStreakMin int `yaml:"confirmed_streak_min"`
|
||||
|
||||
// RecoverStreakMin is the number of consecutive passing cycles while in
|
||||
// blocked_suspect required to recover to up (default: 2).
|
||||
RecoverStreakMin int `yaml:"recover_streak_min"`
|
||||
|
||||
// SuspectWeight is the routing weight applied when a node first enters
|
||||
// blocked_suspect (default: 10).
|
||||
SuspectWeight int `yaml:"suspect_weight"`
|
||||
}
|
||||
|
||||
// CapacitySection holds 15F capacity-monitor and circuit-breaker thresholds.
|
||||
type CapacitySection struct {
|
||||
// WatermarkThreshold is the minimum pool fill-rate (upCount/target) before
|
||||
// an alert is emitted (default: 0.70 = 70%).
|
||||
WatermarkThreshold float64 `yaml:"watermark_threshold"`
|
||||
|
||||
// AlertSuppressMin is the minimum gap in minutes between repeated alerts
|
||||
// for the same pool or probe (default: 10 min).
|
||||
AlertSuppressMin int `yaml:"alert_suppress_min"`
|
||||
|
||||
// BreakerWindowMin is the sliding-window duration in minutes for the
|
||||
// circuit breaker (default: 60 min = 1 h).
|
||||
BreakerWindowMin int `yaml:"breaker_window_min"`
|
||||
|
||||
// BreakerFractionPct is the percentage of pool target capacity that, when
|
||||
// replaced within one window, trips the breaker (default: 30).
|
||||
BreakerFractionPct int `yaml:"breaker_fraction_pct"`
|
||||
|
||||
// BreakerMinN is the absolute lower bound for the breaker trip threshold
|
||||
// (default: 3). Overrides fraction when fraction yields a smaller number.
|
||||
BreakerMinN int `yaml:"breaker_min_n"`
|
||||
|
||||
// ManualAlertThresh is the consecutive probe-failure count for a single
|
||||
// node that triggers a 转人工 (escalate-to-human) alert. Should match
|
||||
// 15E's MaxAttempts (default: 3).
|
||||
ManualAlertThresh int `yaml:"manual_alert_thresh"`
|
||||
|
||||
// ExpectedProbeIDs is the list of probe-agent IDs that must maintain live
|
||||
// heartbeat keys in Redis. A missing key triggers a 「探针失联」alert.
|
||||
ExpectedProbeIDs []string `yaml:"expected_probe_ids"`
|
||||
}
|
||||
|
||||
// ProbeSection holds 15E probe-window and grayscale-ramp settings.
|
||||
type ProbeSection struct {
|
||||
// GrayscaleIntervalHours is the time in hours between successive grayscale
|
||||
// weight-ramp steps (default: 6 h).
|
||||
GrayscaleIntervalHours int `yaml:"grayscale_interval_hours"`
|
||||
|
||||
// ProbeTimeoutMin is the maximum time in minutes allowed for a node to
|
||||
// pass probing per attempt (default: 15 min).
|
||||
ProbeTimeoutMin int `yaml:"probe_timeout_min"`
|
||||
|
||||
// MaxAttempts is the maximum number of create+probe attempts per
|
||||
// replacement record before marking it failed (default: 3).
|
||||
MaxAttempts int `yaml:"max_attempts"`
|
||||
|
||||
// ProbeCyclesRequired is the number of consecutive passing probe ticks
|
||||
// needed before a new node is promoted to up (default: 2).
|
||||
ProbeCyclesRequired int `yaml:"probe_cycles_required"`
|
||||
}
|
||||
|
||||
// DefaultSchedConfig returns a SchedConfig pre-filled with production defaults.
|
||||
func DefaultSchedConfig() SchedConfig {
|
||||
return SchedConfig{
|
||||
Detect: DetectSection{
|
||||
DomesticFailNumerator: 2,
|
||||
DomesticFailDenominator: 3,
|
||||
SuspectStreakMin: 2,
|
||||
TrafficDropThreshold: 80.0,
|
||||
TrafficBaselineMin: 20,
|
||||
ConfirmedStreakMin: 6,
|
||||
RecoverStreakMin: 2,
|
||||
SuspectWeight: 10,
|
||||
},
|
||||
Capacity: CapacitySection{
|
||||
WatermarkThreshold: 0.70,
|
||||
AlertSuppressMin: 10,
|
||||
BreakerWindowMin: 60,
|
||||
BreakerFractionPct: 30,
|
||||
BreakerMinN: 3,
|
||||
ManualAlertThresh: 3,
|
||||
},
|
||||
Probe: ProbeSection{
|
||||
GrayscaleIntervalHours: 6,
|
||||
ProbeTimeoutMin: 15,
|
||||
MaxAttempts: 3,
|
||||
ProbeCyclesRequired: 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ConfigManager
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// AuditFn is called after each successful config reload with the old and new
|
||||
// configurations serialised as JSON strings. The caller is responsible for
|
||||
// deciding how to persist the diff (e.g. via LifecycleService.WriteAuditLog).
|
||||
// May be nil to skip auditing.
|
||||
type AuditFn func(ctx context.Context, oldJSON, newJSON string)
|
||||
|
||||
// ConfigManager holds the live SchedConfig and supports SIGHUP-triggered
|
||||
// hot reload. Config reads are lock-free (atomic.Pointer).
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// mgr := NewConfigManager("/etc/pangolin/sched.yaml", auditFn)
|
||||
// if err := mgr.Load(); err != nil { log.Fatal(err) }
|
||||
// mgr.WatchSIGHUP(ctx)
|
||||
// cfg := mgr.Current()
|
||||
type ConfigManager struct {
|
||||
path string // optional YAML file path (may be empty)
|
||||
cfg atomic.Pointer[SchedConfig]
|
||||
auditFn AuditFn
|
||||
}
|
||||
|
||||
// NewConfigManager creates a ConfigManager.
|
||||
//
|
||||
// path: optional path to the YAML config file. If SCHED_CONFIG_FILE is
|
||||
// set in the environment it overrides this argument.
|
||||
// auditFn: called on every reload when the config changes; may be nil.
|
||||
//
|
||||
// The manager is seeded with DefaultSchedConfig so Current() is never nil.
|
||||
func NewConfigManager(path string, fn AuditFn) *ConfigManager {
|
||||
if p := os.Getenv("SCHED_CONFIG_FILE"); p != "" {
|
||||
path = p
|
||||
}
|
||||
m := &ConfigManager{path: path, auditFn: fn}
|
||||
dflt := DefaultSchedConfig()
|
||||
m.cfg.Store(&dflt)
|
||||
return m
|
||||
}
|
||||
|
||||
// Current returns the live config. Never nil.
|
||||
func (m *ConfigManager) Current() *SchedConfig {
|
||||
return m.cfg.Load()
|
||||
}
|
||||
|
||||
// Load reads the config (YAML file if configured, then env overrides) and
|
||||
// atomically replaces the live config. Safe to call multiple times.
|
||||
func (m *ConfigManager) Load() error {
|
||||
next, err := loadSchedConfig(m.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.swap(context.Background(), next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WatchSIGHUP starts a goroutine that calls Reload whenever SIGHUP is
|
||||
// received. The goroutine stops when ctx is cancelled.
|
||||
func (m *ConfigManager) WatchSIGHUP(ctx context.Context) {
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGHUP)
|
||||
go func() {
|
||||
defer signal.Stop(ch)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ch:
|
||||
if err := m.Reload(ctx); err != nil {
|
||||
slog.Error("sched config: SIGHUP reload failed", "error", err)
|
||||
} else {
|
||||
slog.Info("sched config: reloaded via SIGHUP")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Reload re-reads the config and hot-swaps the live value. Idempotent.
|
||||
func (m *ConfigManager) Reload(ctx context.Context) error {
|
||||
next, err := loadSchedConfig(m.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.swap(ctx, next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// swap atomically replaces the live config and records a diff in the audit
|
||||
// log when the config actually changed.
|
||||
func (m *ConfigManager) swap(ctx context.Context, next *SchedConfig) {
|
||||
old := m.cfg.Swap(next)
|
||||
if m.auditFn == nil {
|
||||
return
|
||||
}
|
||||
oldJSON, _ := json.Marshal(old)
|
||||
newJSON, _ := json.Marshal(next)
|
||||
if string(oldJSON) == string(newJSON) {
|
||||
return // no change; skip audit
|
||||
}
|
||||
m.auditFn(ctx, string(oldJSON), string(newJSON))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Loading logic
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// loadSchedConfig builds a SchedConfig using the layering strategy:
|
||||
// defaults → YAML file → env-var overrides.
|
||||
func loadSchedConfig(path string) (*SchedConfig, error) {
|
||||
cfg := DefaultSchedConfig()
|
||||
|
||||
// Layer 2: optional YAML file.
|
||||
if path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("sched config: read %s: %w", path, err)
|
||||
}
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("sched config: parse %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: environment variable overrides.
|
||||
applySchedEnvOverrides(&cfg)
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// applySchedEnvOverrides overlays environment-variable values on top of cfg.
|
||||
// Only non-empty env vars are applied; empty vars preserve the current value.
|
||||
func applySchedEnvOverrides(cfg *SchedConfig) {
|
||||
// ── 15D detect ────────────────────────────────────────────────────────────
|
||||
if v := posIntEnv("DETECT_DOMESTIC_FAIL_NUM"); v > 0 {
|
||||
cfg.Detect.DomesticFailNumerator = v
|
||||
}
|
||||
if v := posIntEnv("DETECT_DOMESTIC_FAIL_DEN"); v > 0 {
|
||||
cfg.Detect.DomesticFailDenominator = v
|
||||
}
|
||||
if v := posIntEnv("DETECT_SUSPECT_STREAK_MIN"); v > 0 {
|
||||
cfg.Detect.SuspectStreakMin = v
|
||||
}
|
||||
if v := posFloatEnv("DETECT_TRAFFIC_DROP_THRESHOLD"); v > 0 {
|
||||
cfg.Detect.TrafficDropThreshold = v
|
||||
}
|
||||
if v := posIntEnv("DETECT_TRAFFIC_BASELINE_MIN"); v > 0 {
|
||||
cfg.Detect.TrafficBaselineMin = v
|
||||
}
|
||||
if v := posIntEnv("DETECT_CONFIRMED_STREAK_MIN"); v > 0 {
|
||||
cfg.Detect.ConfirmedStreakMin = v
|
||||
}
|
||||
if v := posIntEnv("DETECT_RECOVER_STREAK_MIN"); v > 0 {
|
||||
cfg.Detect.RecoverStreakMin = v
|
||||
}
|
||||
if v := posIntEnv("DETECT_SUSPECT_WEIGHT"); v > 0 {
|
||||
cfg.Detect.SuspectWeight = v
|
||||
}
|
||||
|
||||
// ── 15F capacity / breaker ────────────────────────────────────────────────
|
||||
if v := posFloatEnv("CAP_WATERMARK_THRESHOLD"); v > 0 {
|
||||
cfg.Capacity.WatermarkThreshold = v
|
||||
}
|
||||
if v := posIntEnv("CAP_ALERT_SUPPRESS_MIN"); v > 0 {
|
||||
cfg.Capacity.AlertSuppressMin = v
|
||||
}
|
||||
if v := posIntEnv("CAP_BREAKER_WINDOW_MIN"); v > 0 {
|
||||
cfg.Capacity.BreakerWindowMin = v
|
||||
}
|
||||
if v := posIntEnv("CAP_BREAKER_FRACTION_PCT"); v > 0 {
|
||||
cfg.Capacity.BreakerFractionPct = v
|
||||
}
|
||||
if v := posIntEnv("CAP_BREAKER_MIN_N"); v > 0 {
|
||||
cfg.Capacity.BreakerMinN = v
|
||||
}
|
||||
if v := posIntEnv("CAP_MANUAL_ALERT_THRESH"); v > 0 {
|
||||
cfg.Capacity.ManualAlertThresh = v
|
||||
}
|
||||
if v := os.Getenv("CAP_EXPECTED_PROBE_IDS"); v != "" {
|
||||
cfg.Capacity.ExpectedProbeIDs = splitCSVEnv(v)
|
||||
}
|
||||
|
||||
// ── 15E probe / grayscale ─────────────────────────────────────────────────
|
||||
if v := posIntEnv("PROBE_GRAYSCALE_INTERVAL_H"); v > 0 {
|
||||
cfg.Probe.GrayscaleIntervalHours = v
|
||||
}
|
||||
if v := posIntEnv("PROBE_TIMEOUT_MIN"); v > 0 {
|
||||
cfg.Probe.ProbeTimeoutMin = v
|
||||
}
|
||||
if v := posIntEnv("PROBE_MAX_ATTEMPTS"); v > 0 {
|
||||
cfg.Probe.MaxAttempts = v
|
||||
}
|
||||
if v := posIntEnv("PROBE_CYCLES_REQUIRED"); v > 0 {
|
||||
cfg.Probe.ProbeCyclesRequired = v
|
||||
}
|
||||
}
|
||||
|
||||
// posIntEnv reads key as a positive integer. Returns 0 if the env var is
|
||||
// unset, empty, zero, or non-parseable (logs a warning for parse errors).
|
||||
func posIntEnv(key string) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 {
|
||||
if err != nil {
|
||||
slog.Warn("sched config: invalid integer env var", "key", key, "value", v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// posFloatEnv reads key as a positive float64. Returns 0 on error.
|
||||
func posFloatEnv(key string) float64 {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil || f <= 0 {
|
||||
if err != nil {
|
||||
slog.Warn("sched config: invalid float env var", "key", key, "value", v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// splitCSVEnv splits a comma-separated env-var value, trimming whitespace and
|
||||
// dropping empty fields.
|
||||
func splitCSVEnv(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := parts[:0]
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package orchestrate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/scheduler/orchestrate"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Default values
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestDefaultSchedConfig(t *testing.T) {
|
||||
cfg := orchestrate.DefaultSchedConfig()
|
||||
|
||||
// Spot-check a representative value from each section.
|
||||
if cfg.Detect.DomesticFailNumerator != 2 {
|
||||
t.Errorf("Detect.DomesticFailNumerator = %d; want 2", cfg.Detect.DomesticFailNumerator)
|
||||
}
|
||||
if cfg.Capacity.WatermarkThreshold != 0.70 {
|
||||
t.Errorf("Capacity.WatermarkThreshold = %f; want 0.70", cfg.Capacity.WatermarkThreshold)
|
||||
}
|
||||
if cfg.Capacity.BreakerMinN != 3 {
|
||||
t.Errorf("Capacity.BreakerMinN = %d; want 3", cfg.Capacity.BreakerMinN)
|
||||
}
|
||||
if cfg.Probe.GrayscaleIntervalHours != 6 {
|
||||
t.Errorf("Probe.GrayscaleIntervalHours = %d; want 6", cfg.Probe.GrayscaleIntervalHours)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: YAML file overrides defaults
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestConfigManagerYAMLOverrides(t *testing.T) {
|
||||
yaml := `
|
||||
detect:
|
||||
suspect_streak_min: 5
|
||||
confirmed_streak_min: 12
|
||||
capacity:
|
||||
watermark_threshold: 0.60
|
||||
breaker_min_n: 5
|
||||
expected_probe_ids:
|
||||
- pb-1
|
||||
- pb-2
|
||||
probe:
|
||||
grayscale_interval_hours: 8
|
||||
max_attempts: 4
|
||||
`
|
||||
tmpFile, err := writeTempYAML(t, yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("writeTempYAML: %v", err)
|
||||
}
|
||||
|
||||
m := orchestrate.NewConfigManager(tmpFile, nil)
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
cfg := m.Current()
|
||||
|
||||
if cfg.Detect.SuspectStreakMin != 5 {
|
||||
t.Errorf("Detect.SuspectStreakMin = %d; want 5", cfg.Detect.SuspectStreakMin)
|
||||
}
|
||||
if cfg.Detect.ConfirmedStreakMin != 12 {
|
||||
t.Errorf("Detect.ConfirmedStreakMin = %d; want 12", cfg.Detect.ConfirmedStreakMin)
|
||||
}
|
||||
if cfg.Capacity.WatermarkThreshold != 0.60 {
|
||||
t.Errorf("Capacity.WatermarkThreshold = %f; want 0.60", cfg.Capacity.WatermarkThreshold)
|
||||
}
|
||||
if cfg.Capacity.BreakerMinN != 5 {
|
||||
t.Errorf("Capacity.BreakerMinN = %d; want 5", cfg.Capacity.BreakerMinN)
|
||||
}
|
||||
if len(cfg.Capacity.ExpectedProbeIDs) != 2 {
|
||||
t.Errorf("ExpectedProbeIDs count = %d; want 2", len(cfg.Capacity.ExpectedProbeIDs))
|
||||
} else {
|
||||
if cfg.Capacity.ExpectedProbeIDs[0] != "pb-1" || cfg.Capacity.ExpectedProbeIDs[1] != "pb-2" {
|
||||
t.Errorf("ExpectedProbeIDs = %v; want [pb-1 pb-2]", cfg.Capacity.ExpectedProbeIDs)
|
||||
}
|
||||
}
|
||||
if cfg.Probe.GrayscaleIntervalHours != 8 {
|
||||
t.Errorf("Probe.GrayscaleIntervalHours = %d; want 8", cfg.Probe.GrayscaleIntervalHours)
|
||||
}
|
||||
if cfg.Probe.MaxAttempts != 4 {
|
||||
t.Errorf("Probe.MaxAttempts = %d; want 4", cfg.Probe.MaxAttempts)
|
||||
}
|
||||
|
||||
// Unset fields must retain defaults.
|
||||
if cfg.Detect.DomesticFailNumerator != 2 {
|
||||
t.Errorf("Detect.DomesticFailNumerator should be default 2; got %d", cfg.Detect.DomesticFailNumerator)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Environment variable overrides YAML
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestConfigManagerEnvOverridesYAML(t *testing.T) {
|
||||
yaml := `
|
||||
detect:
|
||||
suspect_streak_min: 5
|
||||
capacity:
|
||||
breaker_min_n: 7
|
||||
`
|
||||
tmpFile, err := writeTempYAML(t, yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("writeTempYAML: %v", err)
|
||||
}
|
||||
|
||||
// Set env vars AFTER writing YAML to ensure they override.
|
||||
t.Setenv("DETECT_SUSPECT_STREAK_MIN", "9")
|
||||
t.Setenv("CAP_BREAKER_MIN_N", "15")
|
||||
|
||||
m := orchestrate.NewConfigManager(tmpFile, nil)
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
cfg := m.Current()
|
||||
|
||||
// Env var should override YAML value.
|
||||
if cfg.Detect.SuspectStreakMin != 9 {
|
||||
t.Errorf("Detect.SuspectStreakMin = %d; want 9 (env override)", cfg.Detect.SuspectStreakMin)
|
||||
}
|
||||
if cfg.Capacity.BreakerMinN != 15 {
|
||||
t.Errorf("Capacity.BreakerMinN = %d; want 15 (env override)", cfg.Capacity.BreakerMinN)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: SCHED_CONFIG_FILE env var overrides path argument
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestConfigManagerSCHED_CONFIG_FILE(t *testing.T) {
|
||||
yaml := `
|
||||
capacity:
|
||||
breaker_window_min: 120
|
||||
`
|
||||
tmpFile, err := writeTempYAML(t, yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("writeTempYAML: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("SCHED_CONFIG_FILE", tmpFile)
|
||||
|
||||
// Pass a wrong path as arg; env var should win.
|
||||
m := orchestrate.NewConfigManager("/nonexistent/path.yaml", nil)
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
if cfg := m.Current(); cfg.Capacity.BreakerWindowMin != 120 {
|
||||
t.Errorf("BreakerWindowMin = %d; want 120 (read from SCHED_CONFIG_FILE)", cfg.Capacity.BreakerWindowMin)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Reload changes values and triggers audit callback with diff
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestConfigManagerReloadWithAudit(t *testing.T) {
|
||||
yaml1 := `
|
||||
capacity:
|
||||
watermark_threshold: 0.70
|
||||
breaker_min_n: 3
|
||||
`
|
||||
tmpFile, err := writeTempYAML(t, yaml1)
|
||||
if err != nil {
|
||||
t.Fatalf("writeTempYAML: %v", err)
|
||||
}
|
||||
|
||||
var auditCalls []struct{ old, new string }
|
||||
auditFn := func(ctx context.Context, old, new string) {
|
||||
auditCalls = append(auditCalls, struct{ old, new string }{old, new})
|
||||
}
|
||||
|
||||
m := orchestrate.NewConfigManager(tmpFile, orchestrate.AuditFn(auditFn))
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatalf("initial Load: %v", err)
|
||||
}
|
||||
|
||||
initialThreshold := m.Current().Capacity.WatermarkThreshold
|
||||
|
||||
// Rewrite the YAML with a changed value.
|
||||
yaml2 := `
|
||||
capacity:
|
||||
watermark_threshold: 0.55
|
||||
breaker_min_n: 5
|
||||
`
|
||||
if err := os.WriteFile(tmpFile, []byte(yaml2), 0o644); err != nil {
|
||||
t.Fatalf("rewrite YAML: %v", err)
|
||||
}
|
||||
|
||||
// Reload should pick up the new values.
|
||||
if err := m.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
|
||||
newThreshold := m.Current().Capacity.WatermarkThreshold
|
||||
|
||||
if initialThreshold == newThreshold {
|
||||
t.Errorf("WatermarkThreshold not updated after reload: both = %f", newThreshold)
|
||||
}
|
||||
if newThreshold != 0.55 {
|
||||
t.Errorf("WatermarkThreshold after reload = %f; want 0.55", newThreshold)
|
||||
}
|
||||
if m.Current().Capacity.BreakerMinN != 5 {
|
||||
t.Errorf("BreakerMinN after reload = %d; want 5", m.Current().Capacity.BreakerMinN)
|
||||
}
|
||||
|
||||
// Audit callback must have been invoked once (old != new).
|
||||
if len(auditCalls) != 1 {
|
||||
t.Errorf("audit callback count = %d; want 1", len(auditCalls))
|
||||
} else {
|
||||
if auditCalls[0].old == auditCalls[0].new {
|
||||
t.Error("audit callback old == new; diff expected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Reload with no change → audit NOT called (diff is empty)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestConfigManagerReloadNoChangeSkipsAudit(t *testing.T) {
|
||||
yaml := `
|
||||
capacity:
|
||||
watermark_threshold: 0.75
|
||||
`
|
||||
tmpFile, err := writeTempYAML(t, yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("writeTempYAML: %v", err)
|
||||
}
|
||||
|
||||
auditCount := 0
|
||||
auditFn := func(_ context.Context, _, _ string) { auditCount++ }
|
||||
|
||||
m := orchestrate.NewConfigManager(tmpFile, orchestrate.AuditFn(auditFn))
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatalf("initial Load: %v", err)
|
||||
}
|
||||
|
||||
// Reload without changing the YAML file.
|
||||
if err := m.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
|
||||
// No diff → audit should NOT be called on the second load.
|
||||
// (First Load always swaps from defaults to YAML; that may be different.)
|
||||
// What we really check: the reload didn't trigger another audit if nothing changed.
|
||||
// The auditFn may have been called 1 time (from Load) but NOT from Reload.
|
||||
//
|
||||
// Actually, Load also calls swap. If the defaults differ from the YAML,
|
||||
// the initial Load increments auditCount. Then Reload sees no diff → no extra call.
|
||||
countAfterReload := auditCount
|
||||
|
||||
// Reload again — still unchanged → same count.
|
||||
if err := m.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("second Reload: %v", err)
|
||||
}
|
||||
if auditCount != countAfterReload {
|
||||
t.Errorf("audit called %d times after second reload; expected 0 extra calls (no diff)", auditCount-countAfterReload)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: comma-separated env var for ExpectedProbeIDs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestConfigManagerProbeIDsFromEnv(t *testing.T) {
|
||||
t.Setenv("CAP_EXPECTED_PROBE_IDS", "alpha, beta , gamma")
|
||||
|
||||
m := orchestrate.NewConfigManager("", nil)
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
ids := m.Current().Capacity.ExpectedProbeIDs
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("ExpectedProbeIDs = %v; want 3 entries", ids)
|
||||
}
|
||||
want := []string{"alpha", "beta", "gamma"}
|
||||
for i, w := range want {
|
||||
if ids[i] != w {
|
||||
t.Errorf("ids[%d] = %q; want %q", i, ids[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user