Files
pangolin/server/internal/scheduler/scheduler.go
T
wangjia ebc9c1e702 feat(scheduler): 装配 scheduler — 三 loop + Redis 选主 + 优雅退出 [tsk_rtYVDLkmc5mo]
## 内容

### server/internal/scheduler/deps.go
新增 scheduler 级接口文件,定义所有外部依赖接口:
- DetectEngine / OrchestrateReplacer / OrchestrateGrayscale(15D/15E)
- CapacityService + StubCapacityService(15F 存根)
- ProbeStateReader(15A)、ThirdPartyProber / TargetProvider(15C)
- Notifier + LogNotifier(15G)、StaticTargetProvider

### server/internal/scheduler/scheduler.go
Scheduler 主体实现:
- Config 结构体:注入所有依赖 + 可配置的选主时间参数(方便测试)
- Run(ctx) = 安装 SIGTERM/SIGINT 信号 + Start(ctx)
- Start(ctx) = 启动三 loop goroutine + 可选 15C 采集 goroutine + 30s 优雅退出
- runLoop: 非主实例每 5s 抢租约(SET NX PX 30000)
- runLeaderLoop: 主实例按 interval tick,defer 释放租约
- keepLease: 每 10s Lua 校验后续租(PEXPIRE),失败即退出
- releaseLease: Lua 校验后 DEL,加速接管
- capacityTick: 15F check → 15E grayscale.Advance → 15F decay → 探针失联告警
- runThirdPartyProber: 无主从约束全副本跑(理由注释在代码中)

### server/internal/scheduler/wiring.go
BuildStubConfig(): 用存根 LC/Prov 构建可运行的 Config,
供 server main 和集成测试使用;待 #5/#14 就绪后替换真实实现。

### server/internal/scheduler/scheduler_test.go (go test -race 全通过)
- TestLeaderElection: 两实例竞争,任意时刻只有一个在 tick
- TestFollowerTakeover: 主实例退出后备实例在 ≤LeaseTTL 内接管
- TestGracefulShutdown: ctx 取消后三个 leader key 被主动 DEL
- TestCapacityTickProbeDisconnect: 失联探针触发 Notifier
- TestE2EMockScenario: 端到端 mock 剧本
  up→suspect(降权10)→confirmed→down + 替换队列 →
  CreateNode→probing→activating(weight10+BumpVersion+grayscale)→
  draining_old→done,全链 node_events/audit_log 对账

### server/cmd/server/main.go
- 新增 SCHED_ENABLED=true 开关,灰度启动 scheduler
- 复用 REDIS_ADDR/REDIS_PASSWORD,probe.Store 共享给 scheduler
- 通过 signal.NotifyContext 传递 SIGTERM,确保优雅退出

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 22:45:56 +08:00

502 lines
19 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()
if ctx.Err() != nil {
return // context cancelled — don't log spurious errors
}
if err != nil {
slog.Error("scheduler: leader setnx error", "loop", name, "error", err)
} else if ok {
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()
}
}
}