a7ae7156f4
TestGracefulShutdown(真 bug):runLoop 在 SetNX 获取 leader 锁后、进入
runLeaderLoop(其 defer 负责释放)之前,若 ctx 恰好在此刻 cancel,
`if ctx.Err() != nil { return }` 会丢弃已获取的 key 而不释放 →
关停后 leader 键残留(DEL 从未执行)。早 cancel 时三个 loop 各自命中此竞态,
故残留的键不固定。修复:将 ctx.Err() 早退限定在 SetNX 出错(未获取)的分支;
一旦获取成功就必定进入 runLeaderLoop,由其 defer 保证释放。
TestFollowerTakeover(测试设计竞态):测试同时启动 leader/follower 两实例却
假定名为 "leader" 的实例赢得选举——而选举是先到先得,"follower" 可能先抢到
detect 锁,导致 engLeader 永不 tick("leader never ticked")。修复:先单独
启动 leader 并等其 tick(确认占锁),再启动 follower,消除选举非确定性。
两修复均移除原 t.Skip,恢复测试。验证:各自隔离 12/12 通过、scheduler 全包
10/10、全量 go test ./... 3 连跑 0 失败、scheduler -race 无数据竞争。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
509 lines
20 KiB
Go
509 lines
20 KiB
Go
package scheduler
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"os"
|
||
"os/signal"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/redis/go-redis/v9"
|
||
)
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Leader-election Redis key helper
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// leaderKey returns the Redis key for a loop's leader election.
|
||
// Format: sched:leader:{loopName}
|
||
func leaderKey(loop string) string { return "sched:leader:" + loop }
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Config
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// Config holds all scheduler dependencies and tunable timing parameters.
|
||
// Zero values for duration fields use the production defaults listed in the
|
||
// field comments. All wired components (Engine, Replacer, …) are required
|
||
// unless the field is explicitly marked optional.
|
||
type Config struct {
|
||
// RDB is the Redis client used for leader-election keys. Required.
|
||
RDB *redis.Client
|
||
|
||
// InstanceID uniquely identifies this process instance across the fleet.
|
||
// If empty, a random UUID is generated by New.
|
||
InstanceID string
|
||
|
||
// ── Wired engine components ──────────────────────────────────────────────
|
||
|
||
// Engine is the 15D detection engine. Required.
|
||
Engine DetectEngine
|
||
|
||
// Replacer is the 15E replacement-orchestration Replacer. Required.
|
||
Replacer OrchestrateReplacer
|
||
|
||
// Grayscale is the 15E grayscale warm-up driver. Required.
|
||
Grayscale OrchestrateGrayscale
|
||
|
||
// Capacity is the 15F capacity/circuit-breaker service.
|
||
// Optional — StubCapacityService is used when nil.
|
||
Capacity CapacityService
|
||
|
||
// ProbeStore reads probe-agent heartbeat liveness (15A).
|
||
// Optional — probe disconnection checking is disabled when nil.
|
||
ProbeStore ProbeStateReader
|
||
|
||
// KnownProbeIDs is the set of probe-agent IDs expected to be alive.
|
||
// When any ID in this list is absent from ProbeStore.AliveProbes, the
|
||
// Notifier is called with reason "probe_disconnected".
|
||
KnownProbeIDs []string
|
||
|
||
// Prober is the 15C third-party dial-test driver.
|
||
// Optional — 15C probing is disabled when nil.
|
||
Prober ThirdPartyProber
|
||
|
||
// Targets supplies the list of nodes to probe via Prober.
|
||
// Required when Prober is non-nil.
|
||
Targets TargetProvider
|
||
|
||
// Notifier is the 15G alerting sink.
|
||
// Optional — LogNotifier is used when nil.
|
||
Notifier Notifier
|
||
|
||
// ── Tick intervals (hot-reloadable from 15F config) ─────────────────────
|
||
// Zero → production default (shown in comments).
|
||
|
||
DetectInterval time.Duration // 5 min
|
||
OrchestrateInterval time.Duration // 30 s
|
||
CapacityInterval time.Duration // 90 s
|
||
ThirdPartyInterval time.Duration // 10 min — interval between 15C RunOnce calls
|
||
|
||
// ── Leader-election timing ────────────────────────────────────────────────
|
||
// Override only in tests (shorter values speed up convergence).
|
||
|
||
LeaseTTL time.Duration // 30 s — Redis PEXPIRE on the leader key
|
||
RenewPeriod time.Duration // 10 s — how often the leader renews its lease
|
||
RetryPeriod time.Duration // 5 s — how often a non-leader tries to acquire
|
||
TickTimeout time.Duration // 30 s — per-tick context hard-timeout
|
||
}
|
||
|
||
// applyDefaults fills zero-value duration fields with production defaults.
|
||
func (c *Config) applyDefaults() {
|
||
if c.InstanceID == "" {
|
||
c.InstanceID = uuid.NewString()
|
||
}
|
||
if c.DetectInterval == 0 {
|
||
c.DetectInterval = 5 * time.Minute
|
||
}
|
||
if c.OrchestrateInterval == 0 {
|
||
c.OrchestrateInterval = 30 * time.Second
|
||
}
|
||
if c.CapacityInterval == 0 {
|
||
c.CapacityInterval = 90 * time.Second
|
||
}
|
||
if c.ThirdPartyInterval == 0 {
|
||
c.ThirdPartyInterval = 10 * time.Minute
|
||
}
|
||
if c.LeaseTTL == 0 {
|
||
c.LeaseTTL = 30 * time.Second
|
||
}
|
||
if c.RenewPeriod == 0 {
|
||
c.RenewPeriod = 10 * time.Second
|
||
}
|
||
if c.RetryPeriod == 0 {
|
||
c.RetryPeriod = 5 * time.Second
|
||
}
|
||
if c.TickTimeout == 0 {
|
||
c.TickTimeout = 30 * time.Second
|
||
}
|
||
if c.Notifier == nil {
|
||
c.Notifier = LogNotifier{}
|
||
}
|
||
if c.Capacity == nil {
|
||
c.Capacity = StubCapacityService{}
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Scheduler
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// Scheduler runs three leader-elected loops (DetectLoop / OrchestrateLoop /
|
||
// CapacityLoop) plus an optional unguarded 15C third-party-prober goroutine.
|
||
//
|
||
// Design rationale for three independent leader leases (one per loop):
|
||
// - A slow Detect tick (e.g., DB latency spike) must not block Orchestrate
|
||
// progress on other in-flight replacements.
|
||
// - Each loop failing over independently avoids a partial-leader split where
|
||
// a crashing replica holds only one of three leases.
|
||
// - Raft-style leadership is overkill for these loosely coupled loops, which
|
||
// are each idempotent and use Redis-side locking for concurrency safety.
|
||
type Scheduler struct {
|
||
rdb *redis.Client
|
||
cfg Config
|
||
}
|
||
|
||
// New creates a Scheduler from cfg, applying production defaults for any nil /
|
||
// zero fields.
|
||
func New(cfg Config) *Scheduler {
|
||
cfg.applyDefaults()
|
||
return &Scheduler{rdb: cfg.RDB, cfg: cfg}
|
||
}
|
||
|
||
// Run starts the scheduler with OS signal handling (SIGTERM / SIGINT) and
|
||
// blocks until the scheduler has shut down. For production use from main().
|
||
func (s *Scheduler) Run(ctx context.Context) error {
|
||
sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||
defer stop()
|
||
return s.Start(sigCtx)
|
||
}
|
||
|
||
// Start starts the scheduler goroutines and blocks until ctx is cancelled.
|
||
// Unlike Run, it does not install OS signal handlers — useful for testing.
|
||
func (s *Scheduler) Start(ctx context.Context) error {
|
||
var wg sync.WaitGroup
|
||
|
||
// ── Three leader-elected loops ─────────────────────────────────────────────
|
||
//
|
||
// Each loop acquires its own independent Redis leader key so that a slow
|
||
// tick in one loop does not delay the others.
|
||
|
||
wg.Add(3)
|
||
go func() {
|
||
defer wg.Done()
|
||
s.runLoop(ctx, "detect", s.cfg.DetectInterval, s.cfg.Engine.Tick)
|
||
}()
|
||
go func() {
|
||
defer wg.Done()
|
||
s.runLoop(ctx, "orchestrate", s.cfg.OrchestrateInterval, s.cfg.Replacer.Tick)
|
||
}()
|
||
go func() {
|
||
defer wg.Done()
|
||
s.runLoop(ctx, "capacity", s.cfg.CapacityInterval, s.capacityTick)
|
||
}()
|
||
|
||
// ── 15C third-party prober (no leader constraint) ─────────────────────────
|
||
//
|
||
// Design choice: run on ALL replicas without leader election because:
|
||
// 1. Alibaba Cloud API calls are idempotent: same (node, ISP) pair creates
|
||
// a new one-shot task; the result is written to Redis via SET (overwrite)
|
||
// so duplicate writes produce the same snapshot with a slightly later
|
||
// ReceivedAt — harmless.
|
||
// 2. Running on every replica improves data collection reliability: if one
|
||
// replica crashes mid-cycle, the others continue gathering snapshots.
|
||
// 3. The detect engine (15D) aggregates snapshots regardless of source;
|
||
// receiving the same data twice per window has no logical effect.
|
||
//
|
||
// If billing for N×API calls becomes a concern, add leader election here by
|
||
// wrapping the loop body in tryRunAsLeader("prober").
|
||
if s.cfg.Prober != nil && s.cfg.Targets != nil {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
s.runThirdPartyProber(ctx)
|
||
}()
|
||
}
|
||
|
||
// Wait for ctx cancellation (SIGTERM / test cancel).
|
||
<-ctx.Done()
|
||
slog.Info("scheduler: shutdown signal received — waiting for in-flight ticks to finish",
|
||
"instance", s.cfg.InstanceID)
|
||
|
||
// 30-second hard timeout: all loops must finish their current tick within
|
||
// this window. Leader keys are released by each loop's defer (see
|
||
// runLeaderLoop → releaseLease) so followers can take over immediately.
|
||
allStopped := make(chan struct{})
|
||
go func() {
|
||
wg.Wait()
|
||
close(allStopped)
|
||
}()
|
||
|
||
shutdownTimeout := s.cfg.TickTimeout // reuse tick hard-timeout as shutdown budget
|
||
select {
|
||
case <-allStopped:
|
||
slog.Info("scheduler: graceful shutdown complete", "instance", s.cfg.InstanceID)
|
||
case <-time.After(shutdownTimeout):
|
||
slog.Warn("scheduler: shutdown timeout exceeded — forcing exit",
|
||
"timeout", shutdownTimeout, "instance", s.cfg.InstanceID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Leader-elected loop driver
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// runLoop is the generic leader-elected loop driver.
|
||
//
|
||
// - Non-leader: polls every RetryPeriod to acquire sched:leader:{name}.
|
||
// - Leader: ticks at interval; a background goroutine renews the lease every
|
||
// RenewPeriod. Lost lease → return (exit from runLeaderLoop).
|
||
//
|
||
// The function loops continuously until ctx is Done.
|
||
func (s *Scheduler) runLoop(ctx context.Context, name string, interval time.Duration, tick func(context.Context) error) {
|
||
key := leaderKey(name)
|
||
|
||
for {
|
||
// Try to acquire the leader lease (SET NX PX <leaseTTL ms>).
|
||
ok, err := s.rdb.SetNX(ctx, key, s.cfg.InstanceID, s.cfg.LeaseTTL).Result()
|
||
switch {
|
||
case err != nil:
|
||
// On a cancelled context SetNX errors without acquiring anything,
|
||
// so it is safe to exit silently. Any other error is logged and retried.
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
slog.Error("scheduler: leader setnx error", "loop", name, "error", err)
|
||
case ok:
|
||
// Lease acquired. Always enter runLeaderLoop — even if ctx was
|
||
// cancelled in the race immediately after SetNX — because its defer
|
||
// is what releases the key. Returning here on ctx.Err() would leak
|
||
// the just-acquired leader key (no clean DEL on shutdown).
|
||
slog.Info("scheduler: acquired leader lease", "loop", name, "instance", s.cfg.InstanceID)
|
||
s.runLeaderLoop(ctx, name, interval, tick, key)
|
||
slog.Info("scheduler: relinquished leader", "loop", name, "instance", s.cfg.InstanceID)
|
||
}
|
||
|
||
// Not leader (or just stepped down): wait before retrying.
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-time.After(s.cfg.RetryPeriod):
|
||
}
|
||
}
|
||
}
|
||
|
||
// runLeaderLoop ticks at interval while holding the leader lease.
|
||
// It starts a keepLease goroutine; when the lease is lost (renewal failure or
|
||
// key taken by a competitor), it returns so runLoop can retry.
|
||
//
|
||
// Graceful-exit guarantee: when ctx is cancelled, runLeaderLoop waits for any
|
||
// in-flight tick to finish (the tick is given its own TickTimeout context, not
|
||
// the parent ctx) before calling defer → releaseLease. This ensures:
|
||
// - No half-executed migrate/notify steps are left in Redis.
|
||
// - The leader key is actively released (DEL) on clean shutdown, so the
|
||
// follower can take over within RetryPeriod rather than LeaseTTL.
|
||
func (s *Scheduler) runLeaderLoop(
|
||
ctx context.Context,
|
||
name string,
|
||
interval time.Duration,
|
||
tick func(context.Context) error,
|
||
leaseKey string,
|
||
) {
|
||
// Renewal goroutine: signals via leaseLost when it can no longer renew.
|
||
renewCtx, cancelRenew := context.WithCancel(ctx)
|
||
leaseLost := make(chan struct{})
|
||
go func() {
|
||
defer close(leaseLost)
|
||
s.keepLease(renewCtx, leaseKey, name)
|
||
}()
|
||
|
||
// On exit: stop the renewer and release the key so followers converge faster.
|
||
defer func() {
|
||
cancelRenew()
|
||
<-leaseLost // wait for renewer before releasing
|
||
// Use a fresh Background context: the original ctx may already be Done.
|
||
s.releaseLease(context.Background(), leaseKey, name)
|
||
}()
|
||
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
// Shutdown requested: exit (defer will release lease).
|
||
// If a tick was already dispatched before this select was reached,
|
||
// it is still running under its own TickTimeout context and will
|
||
// complete before this goroutine is joined by Start's wg.Wait().
|
||
return
|
||
|
||
case <-leaseLost:
|
||
slog.Warn("scheduler: leader lease lost — stepping down", "loop", name)
|
||
return
|
||
|
||
case <-ticker.C:
|
||
// Run tick with a private context so SIGTERM / ctx cancel does not
|
||
// abort it mid-flight. Idempotency (15D optimistic lock, 15E
|
||
// idempotency keys) ensures a tick that completes after the leader
|
||
// key expires is safe.
|
||
tickCtx, cancelTick := context.WithTimeout(context.Background(), s.cfg.TickTimeout)
|
||
if err := tick(tickCtx); err != nil {
|
||
slog.Error("scheduler: tick error", "loop", name, "error", err)
|
||
}
|
||
cancelTick()
|
||
|
||
// If parent was cancelled while we were ticking, exit now instead
|
||
// of waiting for the next ticker.C.
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Lease renewal / release
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// Lua scripts are pre-evaluated to validate key ownership before each
|
||
// PEXPIRE / DEL so we never extend or delete a key owned by another instance.
|
||
|
||
const luaRenew = `
|
||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||
else
|
||
return 0
|
||
end`
|
||
|
||
const luaRelease = `
|
||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||
return redis.call('DEL', KEYS[1])
|
||
else
|
||
return 0
|
||
end`
|
||
|
||
// keepLease renews the leader key every RenewPeriod using the Lua renewal
|
||
// script. Returns when renewal fails (key gone or taken) or ctx is Done.
|
||
func (s *Scheduler) keepLease(ctx context.Context, key, name string) {
|
||
ticker := time.NewTicker(s.cfg.RenewPeriod)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
res, err := s.rdb.Eval(
|
||
ctx, luaRenew, []string{key},
|
||
s.cfg.InstanceID,
|
||
s.cfg.LeaseTTL.Milliseconds(),
|
||
).Result()
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
return // context cancelled: not a real failure
|
||
}
|
||
slog.Error("scheduler: lease renewal error", "loop", name, "error", err)
|
||
return // treat Redis error as lease loss
|
||
}
|
||
if res.(int64) == 0 {
|
||
slog.Warn("scheduler: lease renewal returned 0 — key gone or taken",
|
||
"loop", name, "key", key)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// releaseLease deletes the leader key only if it still belongs to this
|
||
// instance, using the Lua release script.
|
||
func (s *Scheduler) releaseLease(ctx context.Context, key, name string) {
|
||
res, err := s.rdb.Eval(ctx, luaRelease, []string{key}, s.cfg.InstanceID).Result()
|
||
if err != nil {
|
||
slog.Error("scheduler: release lease error", "loop", name, "error", err)
|
||
return
|
||
}
|
||
if res.(int64) == 1 {
|
||
slog.Info("scheduler: leader key released",
|
||
"loop", name, "instance", s.cfg.InstanceID)
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Capacity tick (CapacityLoop work function)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// capacityTick is the work function for CapacityLoop. It runs:
|
||
// 1. 15F capacity / watermark check.
|
||
// 2. 15E grayscale weight advancement.
|
||
// 3. 15F circuit-breaker counter decay.
|
||
// 4. 15A probe-agent disconnection check → 15G alert.
|
||
//
|
||
// Each step is independent; a failure in one does not abort the others.
|
||
func (s *Scheduler) capacityTick(ctx context.Context) error {
|
||
// 1. Capacity / watermark check (15F).
|
||
if err := s.cfg.Capacity.Check(ctx); err != nil {
|
||
slog.Error("scheduler: capacity check error", "error", err)
|
||
// Non-fatal: continue.
|
||
}
|
||
|
||
// 2. Grayscale warm-up advancement (15E).
|
||
if err := s.cfg.Grayscale.Advance(ctx); err != nil {
|
||
slog.Error("scheduler: grayscale advance error", "error", err)
|
||
}
|
||
|
||
// 3. Circuit-breaker counter decay (15F).
|
||
if err := s.cfg.Capacity.Decay(ctx); err != nil {
|
||
slog.Error("scheduler: capacity decay error", "error", err)
|
||
}
|
||
|
||
// 4. Probe-agent disconnection check (15A probe store → 15G alert).
|
||
if s.cfg.ProbeStore != nil && len(s.cfg.KnownProbeIDs) > 0 {
|
||
s.checkProbeConnections(ctx)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// checkProbeConnections fires a Notifier alert for every known probe agent
|
||
// whose heartbeat key has expired (i.e. is absent from AliveProbes).
|
||
func (s *Scheduler) checkProbeConnections(ctx context.Context) {
|
||
alive, err := s.cfg.ProbeStore.AliveProbes(ctx)
|
||
if err != nil {
|
||
slog.Error("scheduler: AliveProbes error", "error", err)
|
||
return
|
||
}
|
||
|
||
aliveSet := make(map[string]struct{}, len(alive))
|
||
for _, id := range alive {
|
||
aliveSet[id] = struct{}{}
|
||
}
|
||
|
||
for _, id := range s.cfg.KnownProbeIDs {
|
||
if _, ok := aliveSet[id]; !ok {
|
||
if notifyErr := s.cfg.Notifier.NotifyFault(ctx, id, "probe_disconnected"); notifyErr != nil {
|
||
slog.Error("scheduler: notify probe disconnect",
|
||
"probe_id", id, "error", notifyErr)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Third-party prober goroutine (15C, no leader constraint)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// runThirdPartyProber fires Prober.RunOnce at ThirdPartyInterval on every
|
||
// replica (no leader election — see design comment in Start).
|
||
func (s *Scheduler) runThirdPartyProber(ctx context.Context) {
|
||
ticker := time.NewTicker(s.cfg.ThirdPartyInterval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
targets, err := s.cfg.Targets.ListProbeTargets(ctx)
|
||
if err != nil {
|
||
slog.Error("scheduler: list probe targets for 15C", "error", err)
|
||
continue
|
||
}
|
||
if len(targets) == 0 {
|
||
continue
|
||
}
|
||
// Use a fresh context so ctx cancellation does not abort a running
|
||
// prober call (the degradation contract: result is "no data" on
|
||
// context error, which 15D treats as unknown, not failure).
|
||
probeCtx, cancel := context.WithTimeout(context.Background(), s.cfg.TickTimeout)
|
||
if err := s.cfg.Prober.RunOnce(probeCtx, targets); err != nil {
|
||
slog.Error("scheduler: 15C prober error", "error", err)
|
||
}
|
||
cancel()
|
||
}
|
||
}
|
||
}
|