469c92191f
容量水位(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>
311 lines
11 KiB
Go
311 lines
11 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|