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>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user