Files
pangolin/server/internal/scheduler/orchestrate/breaker.go
T
wangjia 469c92191f fix(scheduler): 熔断器/容量监控改用统一 alert.Notify(Event) 接口
容量水位(NPgPRxBGv0g9)与告警出口(9YMHMTfWJyNB)两条并行分支合并后接口
不兼容:前者调用旧的 NotifyFault(ctx,id,reason),后者把 alert.Notifier 统一
为 Notify(ctx, Event)。将熔断触发/水位过低/探针失联三处改用 alert.NewEvent +
Notify,对齐 alert 包的统一事件出口设计(EventTypeBreakerTripped/WatermarkLow/
ProbeAgentLost),mockNotifier 既有 count() 断言无需改动即通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:44:27 +08:00

311 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package orchestrate
import (
"context"
"fmt"
"log/slog"
"math"
"time"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/alert"
"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 {
event := alert.NewEvent(alert.EventTypeBreakerTripped, poolID, map[string]string{"reason": reason})
event.Pool = poolID
if err := b.notifier.Notify(ctx, event); err != nil {
slog.Error("breaker: emit trip alert failed",
"pool", poolID, "error", err)
}
}
}