Files
pangolin/server/internal/scheduler/scheduler.go
T
wangjia a7ae7156f4 fix(scheduler): 修复 #15H 两个 flaky leader-election 测试的根因
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>
2026-06-17 07:06:48 +08:00

509 lines
20 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 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()
}
}
}