From ebc9c1e70255a19bf5f25c4943ecd855a8331a37 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 15 Jun 2026 22:45:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(scheduler):=20=E8=A3=85=E9=85=8D=20schedul?= =?UTF-8?q?er=20=E2=80=94=20=E4=B8=89=20loop=20+=20Redis=20=E9=80=89?= =?UTF-8?q?=E4=B8=BB=20+=20=E4=BC=98=E9=9B=85=E9=80=80=E5=87=BA=20[tsk=5Fr?= =?UTF-8?q?tYVDLkmc5mo]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 内容 ### 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 --- server/cmd/server/main.go | 50 ++ server/internal/scheduler/deps.go | 136 ++++ server/internal/scheduler/scheduler.go | 501 ++++++++++++ server/internal/scheduler/scheduler_test.go | 809 ++++++++++++++++++++ server/internal/scheduler/wiring.go | 113 +++ 5 files changed, 1609 insertions(+) create mode 100644 server/internal/scheduler/deps.go create mode 100644 server/internal/scheduler/scheduler.go create mode 100644 server/internal/scheduler/scheduler_test.go create mode 100644 server/internal/scheduler/wiring.go diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 999898b..30375a3 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -10,6 +10,8 @@ import ( "net" "net/http" "os" + "os/signal" + "syscall" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -24,6 +26,7 @@ import ( "github.com/wangjia/pangolin/server/internal/nodes" agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" "github.com/wangjia/pangolin/server/internal/redisutil" + "github.com/wangjia/pangolin/server/internal/scheduler" "github.com/wangjia/pangolin/server/internal/scheduler/probe" ) @@ -66,6 +69,7 @@ func main() { // If PROBE_SECRETS is empty the route is not registered and the server // starts normally without the probe ingest endpoint. probeSecretsJSON := os.Getenv("PROBE_SECRETS") + var sharedProbeStore *probe.Store // may be re-used by the scheduler if probeSecretsJSON != "" { redisAddr := getenvDefault("REDIS_ADDR", "127.0.0.1:6379") rdb, err := redisutil.New(redisAddr, os.Getenv("REDIS_PASSWORD"), 0) @@ -78,12 +82,58 @@ func main() { } reg := probe.NewMapRegistry(secretMap) st := probe.NewStore(rdb) + sharedProbeStore = st h := probe.NewIngestHandler(reg, st) r.Post("/probe/report", h.ServeHTTP) log.Printf("probe ingest route registered (%d probe(s))", len(secretMap)) } } + // ─── Scheduler (optional) ───────────────────────────────────────────────── + // + // Set SCHED_ENABLED=true to start the three leader-elected scheduler loops + // (DetectLoop / OrchestrateLoop / CapacityLoop) in this process alongside the + // HTTP and gRPC servers. + // + // Dependencies: + // REDIS_ADDR, REDIS_PASSWORD – shared with the probe route (new client if + // the probe route is disabled). + // + // Rollback: set SCHED_ENABLED=false (or unset it) and restart; all other + // server functionality is unaffected. + if os.Getenv("SCHED_ENABLED") == "true" { + redisAddr := getenvDefault("REDIS_ADDR", "127.0.0.1:6379") + schedRDB, err := redisutil.New(redisAddr, os.Getenv("REDIS_PASSWORD"), 0) + if err != nil { + log.Printf("scheduler: redis connect failed (%v) — scheduler disabled", err) + } else { + // Use the shared probe store if the probe route is already wired up; + // otherwise create a standalone store backed by the scheduler Redis client. + ps := sharedProbeStore + if ps == nil { + ps = probe.NewStore(schedRDB) + } + cfg := scheduler.BuildStubConfig(schedRDB, ps) + // TODO(#5): replace stub lifecycle with real LifecycleService + // TODO(#14): replace stub provision with real ProvisionService + sched := scheduler.New(cfg) + + // The scheduler installs its own SIGTERM/SIGINT handler via Run(). + // We also respect the Go context tree: sigCtx cancels when the process + // receives a signal, giving the scheduler up to TickTimeout (30 s) to + // finish any in-flight tick before the OS kills the process. + sigCtx, stopSig := signal.NotifyContext(context.Background(), + os.Interrupt, syscall.SIGTERM) + go func() { + defer stopSig() + if err := sched.Start(sigCtx); err != nil { + slog.Error("scheduler: Start error", "error", err) + } + }() + log.Printf("scheduler started (SCHED_ENABLED=true, stub lifecycle/provision)") + } + } + // Mount all /v1/... routes. HandlerFromMuxWithBaseURL registers every route // from the OpenAPI spec onto the provided chi router with the given prefix, // so the router itself is the http.Handler we serve. diff --git a/server/internal/scheduler/deps.go b/server/internal/scheduler/deps.go new file mode 100644 index 0000000..dbd4151 --- /dev/null +++ b/server/internal/scheduler/deps.go @@ -0,0 +1,136 @@ +// Package scheduler wires together the 15D detection engine, 15E orchestration +// engine, 15F capacity service, 15A probe store, and 15G notifier into three +// leader-elected goroutine loops (DetectLoop / OrchestrateLoop / CapacityLoop), +// plus an optional unguarded 15C third-party prober goroutine. +// +// This file declares the narrow dependency interfaces used by Scheduler. Real +// implementations satisfy these interfaces; test mocks do too. +package scheduler + +import ( + "context" + "log/slog" + + "github.com/wangjia/pangolin/server/internal/scheduler/probe" +) + +// ───────────────────────────────────────────────────────────────────────────── +// 15D – detection engine +// ───────────────────────────────────────────────────────────────────────────── + +// DetectEngine is the 15D interface. *detect.Engine satisfies it. +// Tick is called by DetectLoop on every 5-minute cycle. +type DetectEngine interface { + Tick(ctx context.Context) error +} + +// ───────────────────────────────────────────────────────────────────────────── +// 15E – orchestration engine +// ───────────────────────────────────────────────────────────────────────────── + +// OrchestrateReplacer is the 15E replacement-orchestration interface. +// *orchestrate.Replacer satisfies it. +// Tick is called by OrchestrateLoop every 30 seconds. +type OrchestrateReplacer interface { + Tick(ctx context.Context) error +} + +// OrchestrateGrayscale is the 15E grayscale warm-up interface. +// *orchestrate.Grayscale satisfies it. +// Advance is called by CapacityLoop every 1–2 minutes. +type OrchestrateGrayscale interface { + Advance(ctx context.Context) error +} + +// ───────────────────────────────────────────────────────────────────────────── +// 15F – capacity service (stub until task 15F is delivered) +// ───────────────────────────────────────────────────────────────────────────── + +// CapacityService is the 15F interface for watermark/quota checking and +// circuit-breaker time-decay. Replace StubCapacityService with the real +// 15F implementation once that task is complete. +type CapacityService interface { + // Check verifies that the node-pool watermark and per-region quotas are + // within acceptable bounds. Non-fatal: errors are logged, not returned. + Check(ctx context.Context) error + + // Decay applies time-based decay to circuit-breaker event counters so that + // temporary bursts do not permanently block replacements. + Decay(ctx context.Context) error +} + +// StubCapacityService is a no-op CapacityService used until task 15F is ready. +type StubCapacityService struct{} + +// Check implements CapacityService (always succeeds). +func (StubCapacityService) Check(_ context.Context) error { return nil } + +// Decay implements CapacityService (always succeeds). +func (StubCapacityService) Decay(_ context.Context) error { return nil } + +// ───────────────────────────────────────────────────────────────────────────── +// 15A – probe state reader +// ───────────────────────────────────────────────────────────────────────────── + +// ProbeStateReader reads the heartbeat liveness of probe agents from the 15A +// Redis store. *probe.Store satisfies it (via AliveProbes). +type ProbeStateReader interface { + // AliveProbes returns the IDs of probe agents that have sent a heartbeat + // within the last heartbeatTTL window (15 min). An empty result means + // "no recent heartbeats", not "all probes are down". + AliveProbes(ctx context.Context) ([]string, error) +} + +// ───────────────────────────────────────────────────────────────────────────── +// 15C – third-party prober +// ───────────────────────────────────────────────────────────────────────────── + +// ThirdPartyProber is the 15C interface. +// *probe.AliyunSyntheticAgent satisfies it. +type ThirdPartyProber interface { + // RunOnce issues a full multi-ISP probe cycle for all targets and writes + // results to Redis via the 15A probe store. + RunOnce(ctx context.Context, targets []probe.ProbeTarget) error +} + +// TargetProvider supplies the current list of active nodes to be probed via +// the 15C third-party service. Typically backed by a DB query; a static +// implementation is available as StaticTargetProvider. +type TargetProvider interface { + ListProbeTargets(ctx context.Context) ([]probe.ProbeTarget, error) +} + +// StaticTargetProvider returns a fixed, pre-configured list of probe targets. +// Useful for initial deployment and integration tests before a DB-backed +// provider is available. +type StaticTargetProvider struct { + Targets []probe.ProbeTarget +} + +// ListProbeTargets implements TargetProvider. +func (p StaticTargetProvider) ListProbeTargets(_ context.Context) ([]probe.ProbeTarget, error) { + return p.Targets, nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// 15G – notifier +// ───────────────────────────────────────────────────────────────────────────── + +// Notifier is the 15G alerting interface. The real implementation sends a +// Telegram (or similar) alert; LogNotifier is the stub. +type Notifier interface { + NotifyFault(ctx context.Context, nodeID, reason string) error +} + +// LogNotifier is a Notifier stub that writes to slog. +// Used when no real 15G notifier is wired. +type LogNotifier struct{} + +// NotifyFault implements Notifier. +func (LogNotifier) NotifyFault(_ context.Context, nodeID, reason string) error { + slog.Warn("scheduler: node fault — manual review required", + "node_id", nodeID, + "reason", reason, + ) + return nil +} diff --git a/server/internal/scheduler/scheduler.go b/server/internal/scheduler/scheduler.go new file mode 100644 index 0000000..993e952 --- /dev/null +++ b/server/internal/scheduler/scheduler.go @@ -0,0 +1,501 @@ +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 ). + 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() + } + } +} diff --git a/server/internal/scheduler/scheduler_test.go b/server/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..974c41a --- /dev/null +++ b/server/internal/scheduler/scheduler_test.go @@ -0,0 +1,809 @@ +package scheduler_test + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + + "github.com/wangjia/pangolin/server/internal/scheduler" + "github.com/wangjia/pangolin/server/internal/scheduler/detect" + "github.com/wangjia/pangolin/server/internal/scheduler/orchestrate" + "github.com/wangjia/pangolin/server/internal/scheduler/probe" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Shared test helpers +// ───────────────────────────────────────────────────────────────────────────── + +func newTestRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) { + t.Helper() + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + return rdb, mr +} + +// testIntervals returns Config timing values suitable for fast unit tests. +func testIntervals() (leaseTTL, renew, retry, tick, interval time.Duration) { + return 200 * time.Millisecond, // LeaseTTL + 50 * time.Millisecond, // RenewPeriod + 50 * time.Millisecond, // RetryPeriod + 5 * time.Second, // TickTimeout + 80 * time.Millisecond // loop interval +} + +// counterTick returns a tick function that atomically increments *n. +func counterTick(n *int64) func(context.Context) error { + return func(_ context.Context) error { + atomic.AddInt64(n, 1) + return nil + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Stub implementations for scheduler.Config fields +// ───────────────────────────────────────────────────────────────────────────── + +// stubEngine implements scheduler.DetectEngine. +type stubEngine struct{ count int64 } + +func (e *stubEngine) Tick(_ context.Context) error { atomic.AddInt64(&e.count, 1); return nil } + +// stubReplacer implements scheduler.OrchestrateReplacer. +type stubReplacer struct{ count int64 } + +func (r *stubReplacer) Tick(_ context.Context) error { atomic.AddInt64(&r.count, 1); return nil } + +// stubGrayscale implements scheduler.OrchestrateGrayscale. +type stubGrayscale struct{ count int64 } + +func (g *stubGrayscale) Advance(_ context.Context) error { + atomic.AddInt64(&g.count, 1) + return nil +} + +// stubProbeStore implements scheduler.ProbeStateReader. +type stubProbeStore struct { + mu sync.Mutex + alive []string +} + +func (s *stubProbeStore) AliveProbes(_ context.Context) ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string{}, s.alive...), nil +} + +func (s *stubProbeStore) setAlive(ids ...string) { + s.mu.Lock(); defer s.mu.Unlock() + s.alive = ids +} + +// recordingNotifier captures NotifyFault calls. +type recordingNotifier struct { + mu sync.Mutex + calls []string // nodeID values +} + +func (r *recordingNotifier) NotifyFault(_ context.Context, nodeID, _ string) error { + r.mu.Lock(); defer r.mu.Unlock() + r.calls = append(r.calls, nodeID) + return nil +} + +func (r *recordingNotifier) callCount() int { + r.mu.Lock(); defer r.mu.Unlock() + return len(r.calls) +} + +func (r *recordingNotifier) called(nodeID string) bool { + r.mu.Lock(); defer r.mu.Unlock() + for _, id := range r.calls { + if id == nodeID { return true } + } + return false +} + +// ───────────────────────────────────────────────────────────────────────────── +// TestLeaderElection: only one of two competing instances runs ticks +// ───────────────────────────────────────────────────────────────────────────── + +func TestLeaderElection(t *testing.T) { + rdb, _ := newTestRedis(t) + leaseTTL, renew, retry, tickTout, interval := testIntervals() + + var tickA, tickB int64 + makeEngine := func(n *int64) *stubEngine { + return &stubEngine{} + } + _ = makeEngine + + buildCfg := func(id string, engine *stubEngine, replacer *stubReplacer, gray *stubGrayscale) scheduler.Config { + return scheduler.Config{ + RDB: rdb, + InstanceID: id, + Engine: engine, + Replacer: replacer, + Grayscale: gray, + DetectInterval: interval, + OrchestrateInterval: interval, + CapacityInterval: interval, + LeaseTTL: leaseTTL, + RenewPeriod: renew, + RetryPeriod: retry, + TickTimeout: tickTout, + } + } + + engA := &stubEngine{} + repA := &stubReplacer{} + gryA := &stubGrayscale{} + schedA := scheduler.New(buildCfg("inst-A", engA, repA, gryA)) + + engB := &stubEngine{} + repB := &stubReplacer{} + gryB := &stubGrayscale{} + schedB := scheduler.New(buildCfg("inst-B", engB, repB, gryB)) + + ctxA, cancelA := context.WithCancel(context.Background()) + ctxB, cancelB := context.WithCancel(context.Background()) + defer cancelA() + defer cancelB() + + // Run both schedulers; they compete for "detect" / "orchestrate" / "capacity" leases. + var wgA, wgB sync.WaitGroup + wgA.Add(1) + go func() { defer wgA.Done(); _ = schedA.Start(ctxA) }() + wgB.Add(1) + go func() { defer wgB.Done(); _ = schedB.Start(ctxB) }() + + // Let them run for a few tick intervals. + time.Sleep(500 * time.Millisecond) + + // Collect tick counts. + tickA = atomic.LoadInt64(&engA.count) + tickB = atomic.LoadInt64(&engB.count) + t.Logf("after 500ms: tickA=%d tickB=%d", tickA, tickB) + + // At most one instance should have run detect ticks. + if tickA > 0 && tickB > 0 { + t.Errorf("both inst-A and inst-B ran detect ticks — leader election broken (A=%d B=%d)", tickA, tickB) + } + if tickA == 0 && tickB == 0 { + t.Error("neither instance ran any detect ticks — scheduler not ticking") + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// TestFollowerTakeover: follower takes over after leader's context is cancelled +// ───────────────────────────────────────────────────────────────────────────── + +func TestFollowerTakeover(t *testing.T) { + rdb, mr := newTestRedis(t) + leaseTTL, renew, retry, tickTout, interval := testIntervals() + + buildCfg := func(id string) scheduler.Config { + return scheduler.Config{ + RDB: rdb, + InstanceID: id, + Engine: &stubEngine{}, + Replacer: &stubReplacer{}, + Grayscale: &stubGrayscale{}, + DetectInterval: interval, + OrchestrateInterval: interval, + CapacityInterval: interval, + LeaseTTL: leaseTTL, + RenewPeriod: renew, + RetryPeriod: retry, + TickTimeout: tickTout, + } + } + + // Use a simple counting tick for "detect" loop wired via Engine. + var ticksLeader, ticksFollower int64 + + engLeader := &counterEngine{n: &ticksLeader} + engFollower := &counterEngine{n: &ticksFollower} + + cfgLeader := buildCfg("leader") + cfgLeader.Engine = engLeader + cfgFollower := buildCfg("follower") + cfgFollower.Engine = engFollower + + schedLeader := scheduler.New(cfgLeader) + schedFollower := scheduler.New(cfgFollower) + + ctxLeader, cancelLeader := context.WithCancel(context.Background()) + ctxFollower, cancelFollower := context.WithCancel(context.Background()) + defer cancelFollower() + + var wgLeader sync.WaitGroup + wgLeader.Add(1) + go func() { defer wgLeader.Done(); _ = schedLeader.Start(ctxLeader) }() + + go func() { _ = schedFollower.Start(ctxFollower) }() + + // Wait for leader to tick at least once. + for i := 0; i < 50; i++ { + if atomic.LoadInt64(&ticksLeader) > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + if atomic.LoadInt64(&ticksLeader) == 0 { + t.Fatal("leader never ticked") + } + + prevFollower := atomic.LoadInt64(&ticksFollower) + + // Stop leader — its defer releases the leader key immediately. + cancelLeader() + wgLeader.Wait() + + // Fast-forward miniredis clock to expire the lease (belt-and-suspenders for + // cases where release didn't fire, e.g. kill -9 simulation). + mr.FastForward(leaseTTL + 10*time.Millisecond) + + // Follower should take over within RetryPeriod. + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if atomic.LoadInt64(&ticksFollower) > prevFollower { + break + } + time.Sleep(20 * time.Millisecond) + } + + if atomic.LoadInt64(&ticksFollower) <= prevFollower { + t.Errorf("follower did not take over after leader stopped (leader=%d follower=%d→%d)", + atomic.LoadInt64(&ticksLeader), + prevFollower, atomic.LoadInt64(&ticksFollower)) + } +} + +// counterEngine is an Engine whose Tick increments *n — used in takeover test. +type counterEngine struct{ n *int64 } + +func (e *counterEngine) Tick(_ context.Context) error { atomic.AddInt64(e.n, 1); return nil } + +// ───────────────────────────────────────────────────────────────────────────── +// TestGracefulShutdown: leader key is released on SIGTERM-equivalent ctx cancel +// ───────────────────────────────────────────────────────────────────────────── + +func TestGracefulShutdown(t *testing.T) { + rdb, _ := newTestRedis(t) + leaseTTL, renew, retry, tickTout, interval := testIntervals() + + sched := scheduler.New(scheduler.Config{ + RDB: rdb, + InstanceID: "shutdown-test", + Engine: &stubEngine{}, + Replacer: &stubReplacer{}, + Grayscale: &stubGrayscale{}, + DetectInterval: interval, + OrchestrateInterval: interval, + CapacityInterval: interval, + LeaseTTL: leaseTTL, + RenewPeriod: renew, + RetryPeriod: retry, + TickTimeout: tickTout, + }) + + ctx, cancel := context.WithCancel(context.Background()) + + var wg sync.WaitGroup + wg.Add(1) + go func() { defer wg.Done(); _ = sched.Start(ctx) }() + + // Wait until at least one leader key is acquired. + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + v, _ := rdb.Exists(context.Background(), "sched:leader:detect").Result() + if v > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + // Signal shutdown — equivalent to SIGTERM. + cancel() + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("scheduler did not stop within 2s") + } + + // Verify all three leader keys are gone (released by the scheduler, not expired). + ctx2 := context.Background() + for _, loop := range []string{"detect", "orchestrate", "capacity"} { + key := "sched:leader:" + loop + v, err := rdb.Exists(ctx2, key).Result() + if err != nil { + t.Fatalf("EXISTS %s: %v", key, err) + } + if v != 0 { + t.Errorf("leader key %q not released on shutdown", key) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// TestCapacityTickProbeDisconnect: disconnected probe triggers Notifier +// ───────────────────────────────────────────────────────────────────────────── + +func TestCapacityTickProbeDisconnect(t *testing.T) { + rdb, _ := newTestRedis(t) + leaseTTL, renew, retry, tickTout, interval := testIntervals() + + probeStore := &stubProbeStore{} + notifier := &recordingNotifier{} + + // "probe-sg-01" is known but absent from alive set → should fire alert. + probeStore.setAlive("probe-jp-01") // only jp is alive + + sched := scheduler.New(scheduler.Config{ + RDB: rdb, + InstanceID: "capacity-test", + Engine: &stubEngine{}, + Replacer: &stubReplacer{}, + Grayscale: &stubGrayscale{}, + DetectInterval: interval, + OrchestrateInterval: interval, + CapacityInterval: interval, + LeaseTTL: leaseTTL, + RenewPeriod: renew, + RetryPeriod: retry, + TickTimeout: tickTout, + ProbeStore: probeStore, + KnownProbeIDs: []string{"probe-sg-01", "probe-jp-01"}, + Notifier: notifier, + }) + + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + wg.Add(1) + go func() { defer wg.Done(); _ = sched.Start(ctx) }() + + // Wait for the capacity tick to fire at least once. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if notifier.callCount() > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + cancel() + wg.Wait() + + if !notifier.called("probe-sg-01") { + t.Errorf("expected NotifyFault for probe-sg-01 (disconnected), but it was not called") + } + if notifier.called("probe-jp-01") { + t.Errorf("unexpected NotifyFault for probe-jp-01 (it is alive)") + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// End-to-end mock scenario +// +// Verifies the complete pipeline: blocked node detection → replacement +// orchestration → new node activated → grayscale started. +// +// Mock wiring: +// - #5 LifecycleService → detect.MockLifecycle + mockOrchestrateLC +// - #14 ProvisionService → mockProvision +// - probe store → mockSnapshotter (inject fake probe data) +// ───────────────────────────────────────────────────────────────────────────── + +const e2eNode = "node-sg-001" + +func TestE2EMockScenario(t *testing.T) { + ctx := context.Background() + rdb, _ := newTestRedis(t) + + // ── 15D: detection engine ────────────────────────────────────────────────── + + detectLC := detect.NewMockLifecycle([]detect.NodeInfo{ + {ID: e2eNode, Status: detect.StatusUp, Weight: 100}, + }) + snapper := &mockSnapshotter{data: make(map[string]map[string]probe.ProbeSnapshot)} + detectStreaks := detect.NewStreakStore(rdb) + detectCfg := detect.DefaultConfig() // SuspectStreakMin=2, ConfirmedStreakMin=6 + engine := detect.NewEngine(snapper, detectLC, detectStreaks, rdb, nil, &detectCfg) + + // ── 15E: orchestration engine ────────────────────────────────────────────── + + orchLC := newMockOrchestrateLC() + orchLC.addNode(&orchestrate.NodeInfo{ + ID: e2eNode, + Tier: "premium", + Region: "ap-southeast-1", + Role: "vpn", + }) + prov := newMockProvision(orchestrate.ProviderInfo{ID: "vultr"}) + replacer := orchestrate.NewReplacer(orchestrate.Config{ + RDB: rdb, + Prov: prov, + LC: orchLC, + Snaps: snapper, // probe.Store implements both ProbeSnapshotter interfaces + Breaker: orchestrate.StubBreaker{}, + Notifier: orchestrate.LogNotifier{}, + Clock: orchestrate.RealClock{}, + }) + grayscale := orchestrate.NewGrayscale(rdb, orchLC, nil) + + // ── Phase 1: Blocked-node detection ─────────────────────────────────────── + // + // Inject: 2/3 domestic ISPs fail, overseas OK → GFW-block pattern. + + blockSnaps := snapsForNode( + cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK(), + ) + snapper.setNode(e2eNode, blockSnaps) + + // 2 detect ticks → up → blocked_suspect, weight → 10. + for i := 0; i < 2; i++ { + if err := engine.Tick(ctx); err != nil { + t.Fatalf("detect tick %d: %v", i, err) + } + } + if got := detectLC.NodeStatus(e2eNode); got != detect.StatusBlockedSuspect { + t.Fatalf("after 2 ticks: status=%q want blocked_suspect", got) + } + if got := detectLC.NodeWeight(e2eNode); got != detectCfg.SuspectWeight { + t.Errorf("suspect weight=%d want %d", got, detectCfg.SuspectWeight) + } + + // 6 more detect ticks → blocked_confirmed → down, replace queue populated. + for i := 0; i < detectCfg.ConfirmedStreakMin; i++ { + if err := engine.Tick(ctx); err != nil { + t.Fatalf("confirm tick %d: %v", i, err) + } + } + if got := detectLC.NodeStatus(e2eNode); got != detect.StatusDown { + t.Fatalf("after 8 ticks: status=%q want down", got) + } + + // Verify replace queue. + qlen, _ := rdb.LLen(ctx, "detect:replace:queue").Result() + if qlen != 1 { + t.Fatalf("replace queue length=%d want 1", qlen) + } + raw, _ := rdb.LIndex(ctx, "detect:replace:queue", 0).Result() + var qentry struct { + NodeID string `json:"nodeId"` + ReplacementUUID string `json:"replacementUuid"` + } + if err := json.Unmarshal([]byte(raw), &qentry); err != nil { + t.Fatalf("unmarshal queue entry: %v", err) + } + if qentry.NodeID != e2eNode { + t.Errorf("queue nodeId=%q want %q", qentry.NodeID, e2eNode) + } + repUUID := qentry.ReplacementUUID + + // ── Phase 2: Replacement orchestration ──────────────────────────────────── + // + // Orchestrate ticks drive the state machine: pending → creating → probing + // → activating → draining_old → done. + + // Tick 1: drainQueue (pending) + stepPending (→ creating). + if err := replacer.Tick(ctx); err != nil { + t.Fatalf("orch tick 1: %v", err) + } + + // Tick 2: stepCreating → CreateNode → phase=probing. + if err := replacer.Tick(ctx); err != nil { + t.Fatalf("orch tick 2: %v", err) + } + if prov.createCount() != 1 { + t.Fatalf("expected 1 CreateNode call after 2 orch ticks, got %d", prov.createCount()) + } + newNodeID := prov.lastCreatedID() + if newNodeID == "" { + t.Fatal("CreateNode returned empty nodeID") + } + t.Logf("replacement node created: %s", newNodeID) + + // Inject healthy probe data for the new node. + goodSnaps := snapsForNode( + cnOK("ChinaTelecom"), cnOK("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK(), + ) + snapper.setNode(newNodeID, goodSnaps) + + // Tick 3: stepProbing → probeStreak=1 (need 2). + if err := replacer.Tick(ctx); err != nil { + t.Fatalf("orch tick 3: %v", err) + } + + // Tick 4: stepProbing → probeStreak=2 ≥ ProbeCyclesRequired → phase=activating. + if err := replacer.Tick(ctx); err != nil { + t.Fatalf("orch tick 4: %v", err) + } + + // Tick 5: stepActivating → SetWeight(10) + probing→up + BumpVersion + startGrayscale. + if err := replacer.Tick(ctx); err != nil { + t.Fatalf("orch tick 5: %v", err) + } + + // Tick 6: stepDrainingOld → DestroyNode(old) + breaker.Record + phase=done + audit. + if err := replacer.Tick(ctx); err != nil { + t.Fatalf("orch tick 6: %v", err) + } + + // ── Phase 3: Assertions ─────────────────────────────────────────────────── + + // 1. Detect lifecycle events: up→suspect, suspect→confirmed, confirmed→down. + events := detectLC.Events() + wantEvents := [][2]detect.NodeStatus{ + {detect.StatusUp, detect.StatusBlockedSuspect}, + {detect.StatusBlockedSuspect, detect.StatusBlockedConfirmed}, + {detect.StatusBlockedConfirmed, detect.StatusDown}, + } + if len(events) != len(wantEvents) { + t.Errorf("detect events count=%d want %d: %v", len(events), len(wantEvents), events) + } else { + for i, ev := range events { + if ev.From != wantEvents[i][0] || ev.To != wantEvents[i][1] { + t.Errorf("detect event[%d]: %q→%q want %q→%q", + i, ev.From, ev.To, wantEvents[i][0], wantEvents[i][1]) + } + } + } + + // 2. Version was bumped (directory version bump for client refetch). + if orchLC.version() == 0 { + t.Error("BumpVersion not called — directory version not bumped") + } + + // 3. Old node was destroyed. + if prov.destroyCount() == 0 { + t.Error("DestroyNode not called for old node") + } + + // 4. Orchestrate audit log has replacement_done entry. + if !orchLC.hasAudit("orchestrate|replacement_done|node:" + e2eNode) { + t.Errorf("missing audit log entry for replacement_done; entries: %v", orchLC.auditEntries()) + } + + // 5. Grayscale key exists for new node (sched:gray:{newNodeID}). + grayKey := "sched:gray:" + newNodeID + if exists, _ := rdb.Exists(ctx, grayKey).Result(); exists == 0 { + t.Errorf("grayscale key %q not created after activation", grayKey) + } + + // 6. ReplaceRecord is in terminal phase=done. + recKey := "sched:replace:" + repUUID + recRaw, err := rdb.Get(ctx, recKey).Result() + if err != nil { + t.Fatalf("replace record not found: %v", err) + } + var rec struct{ Phase string `json:"phase"` } + if err := json.Unmarshal([]byte(recRaw), &rec); err != nil { + t.Fatalf("unmarshal record: %v", err) + } + if rec.Phase != "done" { + t.Errorf("replace record phase=%q want done", rec.Phase) + } + + // 7. Grayscale.Advance: call it manually with a fast clock to test weight ramp. + // (Production ramp interval is 6 h; we call it directly here.) + _ = grayscale // advance is tested indirectly via the grayKey existence check above. +} + +// ───────────────────────────────────────────────────────────────────────────── +// TestSCHED_ENABLED guard: scheduler Config must accept nil Prober/Targets +// ───────────────────────────────────────────────────────────────────────────── + +func TestNilProberDoesNotPanic(t *testing.T) { + rdb, _ := newTestRedis(t) + sched := scheduler.New(scheduler.Config{ + RDB: rdb, + InstanceID: "nil-prober-test", + Engine: &stubEngine{}, + Replacer: &stubReplacer{}, + Grayscale: &stubGrayscale{}, + DetectInterval: 80 * time.Millisecond, + OrchestrateInterval: 80 * time.Millisecond, + CapacityInterval: 80 * time.Millisecond, + LeaseTTL: 200 * time.Millisecond, + RenewPeriod: 50 * time.Millisecond, + RetryPeriod: 50 * time.Millisecond, + TickTimeout: 5 * time.Second, + // Prober and Targets intentionally nil + }) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + // Must not panic. + if err := sched.Start(ctx); err != nil { + t.Errorf("Start returned error: %v", err) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Mock implementations for the e2e scenario +// ───────────────────────────────────────────────────────────────────────────── + +// ── mockSnapshotter ─────────────────────────────────────────────────────────── +// +// Satisfies both detect.ProbeSnapshotter and orchestrate.ProbeSnapshotter +// (identical interface signatures). + +type mockSnapshotter struct { + mu sync.Mutex + data map[string]map[string]probe.ProbeSnapshot +} + +func (m *mockSnapshotter) SnapshotsByNode(_ context.Context, nodeID string) (map[string]probe.ProbeSnapshot, error) { + m.mu.Lock(); defer m.mu.Unlock() + if snaps, ok := m.data[nodeID]; ok { + return snaps, nil + } + return nil, nil +} + +func (m *mockSnapshotter) setNode(nodeID string, snaps map[string]probe.ProbeSnapshot) { + m.mu.Lock(); defer m.mu.Unlock() + m.data[nodeID] = snaps +} + +// ── mockOrchestrateLC ───────────────────────────────────────────────────────── +// +// Implements orchestrate.LifecycleService for the e2e test. + +type mockOrchestrateLC struct { + mu sync.Mutex + nodes map[string]*orchestrate.NodeInfo + weights map[string]int + ver int64 + auditLogs []string + trans []orchTransEvent +} + +type orchTransEvent struct{ NodeID, From, To string } + +func newMockOrchestrateLC() *mockOrchestrateLC { + return &mockOrchestrateLC{ + nodes: make(map[string]*orchestrate.NodeInfo), + weights: make(map[string]int), + } +} + +func (m *mockOrchestrateLC) addNode(n *orchestrate.NodeInfo) { + m.mu.Lock(); defer m.mu.Unlock() + cp := *n; m.nodes[n.ID] = &cp +} + +func (m *mockOrchestrateLC) GetNode(_ context.Context, nodeID string) (*orchestrate.NodeInfo, error) { + m.mu.Lock(); defer m.mu.Unlock() + n, ok := m.nodes[nodeID] + if !ok { return nil, nil } + cp := *n; return &cp, nil +} + +func (m *mockOrchestrateLC) TransitionStatus(_ context.Context, nodeID, from, to string, _ map[string]any) (int, error) { + m.mu.Lock(); defer m.mu.Unlock() + m.trans = append(m.trans, orchTransEvent{nodeID, from, to}) + return 1, nil +} + +func (m *mockOrchestrateLC) SetWeight(_ context.Context, nodeID string, weight int) error { + m.mu.Lock(); defer m.mu.Unlock() + m.weights[nodeID] = weight + return nil +} + +func (m *mockOrchestrateLC) BumpVersion(_ context.Context) error { + m.mu.Lock(); defer m.mu.Unlock() + m.ver++; return nil +} + +func (m *mockOrchestrateLC) WriteAuditLog(_ context.Context, actor, action, target, _ string) error { + m.mu.Lock(); defer m.mu.Unlock() + m.auditLogs = append(m.auditLogs, actor+"|"+action+"|"+target) + return nil +} + +func (m *mockOrchestrateLC) version() int64 { + m.mu.Lock(); defer m.mu.Unlock(); return m.ver +} + +func (m *mockOrchestrateLC) hasAudit(entry string) bool { + m.mu.Lock(); defer m.mu.Unlock() + for _, l := range m.auditLogs { if l == entry { return true } } + return false +} + +func (m *mockOrchestrateLC) auditEntries() []string { + m.mu.Lock(); defer m.mu.Unlock() + return append([]string{}, m.auditLogs...) +} + +// ── mockProvision ───────────────────────────────────────────────────────────── + +type mockProvision struct { + mu sync.Mutex + providers []orchestrate.ProviderInfo + createCalls []string // nodeIDs + destroyCalls []string + seq int + idem map[string]string +} + +func newMockProvision(providers ...orchestrate.ProviderInfo) *mockProvision { + return &mockProvision{providers: providers, idem: map[string]string{}} +} + +func (m *mockProvision) CreateNode(_ context.Context, _ orchestrate.NodeSpec, idemKey string) (string, error) { + m.mu.Lock(); defer m.mu.Unlock() + if existing, ok := m.idem[idemKey]; ok { return existing, nil } + m.seq++ + id := fmt.Sprintf("new-node-%d", m.seq) + m.idem[idemKey] = id + m.createCalls = append(m.createCalls, id) + return id, nil +} + +func (m *mockProvision) DestroyNode(_ context.Context, nodeID string) error { + m.mu.Lock(); defer m.mu.Unlock() + m.destroyCalls = append(m.destroyCalls, nodeID) + return nil +} + +func (m *mockProvision) RotateIP(_ context.Context, _ string) (string, error) { return "", nil } + +func (m *mockProvision) ListProviders(_ context.Context, _, _ string) ([]orchestrate.ProviderInfo, error) { + m.mu.Lock(); defer m.mu.Unlock() + return append([]orchestrate.ProviderInfo{}, m.providers...), nil +} + +func (m *mockProvision) createCount() int { + m.mu.Lock(); defer m.mu.Unlock(); return len(m.createCalls) +} + +func (m *mockProvision) destroyCount() int { + m.mu.Lock(); defer m.mu.Unlock(); return len(m.destroyCalls) +} + +func (m *mockProvision) lastCreatedID() string { + m.mu.Lock(); defer m.mu.Unlock() + if len(m.createCalls) == 0 { return "" } + return m.createCalls[len(m.createCalls)-1] +} + +// ───────────────────────────────────────────────────────────────────────────── +// Probe snapshot builder helpers (mirrors detect/engine_test.go) +// ───────────────────────────────────────────────────────────────────────────── + +func snap(country, isp string, l1OK bool, l3OK *bool) probe.ProbeSnapshot { + r := probe.NodeReport{L1: probe.L1Result{OK: l1OK}} + if l3OK != nil { r.L3 = &probe.L3Result{OK: *l3OK} } + return probe.ProbeSnapshot{ + Vantage: probe.VantagePoint{Country: country, ISP: isp}, + Report: r, + } +} + +func boolPtr(b bool) *bool { return &b } + +func cnFail(isp string) probe.ProbeSnapshot { return snap("CN", isp, true, boolPtr(false)) } +func cnOK(isp string) probe.ProbeSnapshot { return snap("CN", isp, true, boolPtr(true)) } +func overseasOK() probe.ProbeSnapshot { return snap("SG", "AWS", true, nil) } + +func snapsForNode(snaps ...probe.ProbeSnapshot) map[string]probe.ProbeSnapshot { + m := make(map[string]probe.ProbeSnapshot, len(snaps)) + for _, s := range snaps { + key := s.Vantage.Country + ":" + s.Vantage.ISP + m[key] = s + } + return m +} diff --git a/server/internal/scheduler/wiring.go b/server/internal/scheduler/wiring.go new file mode 100644 index 0000000..cdf5444 --- /dev/null +++ b/server/internal/scheduler/wiring.go @@ -0,0 +1,113 @@ +package scheduler + +// wiring.go — construction helpers for starting the scheduler in the server +// monolith before tasks #5 (LifecycleService) and #14 (ProvisionService) are +// fully implemented. +// +// BuildStubConfig returns a ready-to-use Config whose lifecycle and provision +// dependencies are replaced with no-op stubs. The scheduler's three loops +// still run and hold leader leases, but detect/orchestrate ticks are no-ops. +// Replace the stubs with real implementations once #5/#14 are ready. + +import ( + "context" + + "github.com/redis/go-redis/v9" + + "github.com/wangjia/pangolin/server/internal/scheduler/detect" + "github.com/wangjia/pangolin/server/internal/scheduler/orchestrate" + "github.com/wangjia/pangolin/server/internal/scheduler/probe" +) + +// BuildStubConfig constructs a scheduler Config wired entirely with in-process +// stubs. Useful for: +// - Starting the scheduler before tasks #5 / #14 are delivered (no-op ticks). +// - Integration tests that need a scheduler goroutine to be running. +// +// To swap in real implementations once they exist: +// +// cfg := scheduler.BuildStubConfig(rdb, probeStore) +// cfg.Engine = detect.NewEngine(realLC, ...) +// cfg.Replacer = orchestrate.NewReplacer(orchestrate.Config{LC: realLC, Prov: realProv, ...}) +// cfg.Grayscale = orchestrate.NewGrayscale(rdb, realLC, nil) +// sched := scheduler.New(cfg) +func BuildStubConfig(rdb *redis.Client, probeStore *probe.Store) Config { + // Stub lifecycle for 15D — no nodes registered, so Tick is a safe no-op. + stubDetectLC := detect.NewMockLifecycle(nil) + + // Stub lifecycle and provision for 15E. + stubOrchLC := &stubOrchLC{} + stubProv := &stubProvision{} + + streaks := detect.NewStreakStore(rdb) + engine := detect.NewEngine( + probeStore, // 15A probe snapshot reader + stubDetectLC, // 15D lifecycle (stub until #5) + streaks, + rdb, + nil, // notifier — LogNotifier used by default + nil, // config — DefaultConfig used + ) + + replacer := orchestrate.NewReplacer(orchestrate.Config{ + RDB: rdb, + Prov: stubProv, // provision stub until #14 + LC: stubOrchLC, // lifecycle stub until #5 + Snaps: probeStore, + Breaker: nil, // StubBreaker used by default + Notifier: nil, // LogNotifier used by default + Clock: nil, // RealClock used by default + }) + + grayscale := orchestrate.NewGrayscale(rdb, stubOrchLC, nil) + + return Config{ + RDB: rdb, + Engine: engine, + Replacer: replacer, + Grayscale: grayscale, + // Capacity, Notifier, ProbeStore, Prober, Targets: nil → stubs/disabled + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Stub implementations for orchestrate.LifecycleService and ProvisionService +// +// These stubs satisfy the interface contracts while doing nothing harmful. +// Replace with the real services (#5 / #14) when those tasks are complete. +// ───────────────────────────────────────────────────────────────────────────── + +// stubOrchLC implements orchestrate.LifecycleService as a no-op. +// GetNode always returns nil (node not found); all mutating calls succeed +// silently. This prevents any orchestration work from proceeding until a real +// lifecycle service is wired in. +type stubOrchLC struct{} + +func (stubOrchLC) GetNode(_ context.Context, _ string) (*orchestrate.NodeInfo, error) { + return nil, nil // "node not found" → orchestrate skips this replacement +} + +func (stubOrchLC) TransitionStatus(_ context.Context, _, _, _ string, _ map[string]any) (int, error) { + return 1, nil +} + +func (stubOrchLC) SetWeight(_ context.Context, _ string, _ int) error { return nil } +func (stubOrchLC) BumpVersion(_ context.Context) error { return nil } +func (stubOrchLC) WriteAuditLog(_ context.Context, _, _, _, _ string) error { return nil } + +// stubProvision implements orchestrate.ProvisionService as a no-op. +// CreateNode always returns an error so pending replacements stay pending +// rather than silently creating phantom resources. +type stubProvision struct{} + +func (stubProvision) CreateNode(_ context.Context, _ orchestrate.NodeSpec, _ string) (string, error) { + return "", context.DeadlineExceeded // signal "not ready" without alarming +} + +func (stubProvision) DestroyNode(_ context.Context, _ string) error { return nil } + +func (stubProvision) RotateIP(_ context.Context, _ string) (string, error) { return "", nil } + +func (stubProvision) ListProviders(_ context.Context, _, _ string) ([]orchestrate.ProviderInfo, error) { + return nil, nil +}