Files
pangolin/server/internal/scheduler/orchestrate/replacer_test.go
T
wangjia 5d4b484646 feat(alert): 统一告警出口 TG bot + runbook [tsk_9YMHMTfWJyNB]
新增 server/internal/alert 包(15G):
- 定义 Notifier 接口及 7 种 EventType(判封确认/补新失败/水位低/熔断/
  探针失联/心跳缺失/故障态)
- TGNotifier:Bot API 发送,Critical 事件不去重,Warning/Info 事件
  10min SETNX 去重窗口,失败重试 ≤2 次后降级至 LogNotifier
- LogNotifier:slog 结构化降级实现
- 单测:7 种事件模板 + runbook 锚点正确性;去重窗口内第二条被抑制;
  TG 5xx 重试后 fallback 且 Notify() 返回 nil;
  runbook 文件锚点与枚举一致性

接入 scheduler(替换旧的 NotifyFault 桩):
- detect/engine.go:故障态(Rule 5)→ EventTypeFault;
  判封确认(Rule 3)→ EventTypeBlockConfirmed
- orchestrate/deps.go:Notifier 类型别名指向 alert.Notifier
- orchestrate/replacer.go:补新失败 → EventTypeReplenishFailed;
  熔断触发 → EventTypeBreakerTripped
- probe/prober_agent.go:failCount ≥3 → EventTypeProbeAgentLost
- probe/store.go:新增 CheckHeartbeats() 供 15H 检测心跳缺失>90s

新增 docs/runbook-scheduler.md:7 节各含含义/先查什么/处置/升级条件,
锚点与代码枚举对应。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 00:52:26 +08:00

837 lines
29 KiB
Go

package orchestrate_test
import (
"context"
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/alert"
"github.com/wangjia/pangolin/server/internal/scheduler/orchestrate"
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
)
// ─────────────────────────────────────────────────────────────────────────────
// In-process Redis
// ─────────────────────────────────────────────────────────────────────────────
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
}
// ─────────────────────────────────────────────────────────────────────────────
// Mock ProvisionService
// ─────────────────────────────────────────────────────────────────────────────
type createCall struct {
Spec orchestrate.NodeSpec
IdempotencyKey string
ReturnedID string
}
type mockProvision struct {
mu sync.Mutex
seq int
providers []orchestrate.ProviderInfo
createCalls []createCall
destroyCalls []string
createErr error
// idem maps idempotency key → nodeID (simulates provider idempotency).
idem map[string]string
}
func newMockProvision(providers ...orchestrate.ProviderInfo) *mockProvision {
return &mockProvision{
providers: providers,
idem: map[string]string{},
}
}
func (m *mockProvision) CreateNode(_ context.Context, spec orchestrate.NodeSpec, idemKey string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.createErr != nil {
return "", m.createErr
}
// Idempotency replay.
if existing, ok := m.idem[idemKey]; ok {
m.createCalls = append(m.createCalls, createCall{spec, idemKey, existing})
return existing, nil
}
m.seq++
nodeID := fmt.Sprintf("new-node-%d", m.seq)
m.idem[idemKey] = nodeID
m.createCalls = append(m.createCalls, createCall{spec, idemKey, nodeID})
return nodeID, 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)
}
// ─────────────────────────────────────────────────────────────────────────────
// Mock LifecycleService
// ─────────────────────────────────────────────────────────────────────────────
type transitionEvent struct {
NodeID, From, To string
}
type mockLC struct {
mu sync.Mutex
nodes map[string]*orchestrate.NodeInfo
weights map[string]int
version int64
auditLogs []string
transitions []transitionEvent
}
func newMockLC(nodes ...*orchestrate.NodeInfo) *mockLC {
m := &mockLC{
nodes: make(map[string]*orchestrate.NodeInfo),
weights: make(map[string]int),
}
for _, n := range nodes {
cp := *n
m.nodes[n.ID] = &cp
}
return m
}
func (m *mockLC) addNode(n *orchestrate.NodeInfo) {
m.mu.Lock()
defer m.mu.Unlock()
cp := *n
m.nodes[n.ID] = &cp
}
func (m *mockLC) 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 *mockLC) TransitionStatus(_ context.Context, nodeID, from, to string, _ map[string]any) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.transitions = append(m.transitions, transitionEvent{nodeID, from, to})
return 1, nil // always succeed in tests
}
func (m *mockLC) SetWeight(_ context.Context, nodeID string, weight int) error {
m.mu.Lock()
defer m.mu.Unlock()
m.weights[nodeID] = weight
return nil
}
func (m *mockLC) BumpVersion(_ context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
m.version++
return nil
}
func (m *mockLC) 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 *mockLC) weightOf(nodeID string) int {
m.mu.Lock()
defer m.mu.Unlock()
return m.weights[nodeID]
}
func (m *mockLC) versionOf() int64 {
m.mu.Lock()
defer m.mu.Unlock()
return m.version
}
func (m *mockLC) hasAudit(entry string) bool {
m.mu.Lock()
defer m.mu.Unlock()
for _, l := range m.auditLogs {
if l == entry {
return true
}
}
return false
}
// ─────────────────────────────────────────────────────────────────────────────
// Mock ProbeSnapshotter
// ─────────────────────────────────────────────────────────────────────────────
type mockSnaps struct {
mu sync.Mutex
data map[string]map[string]probe.ProbeSnapshot
}
func (s *mockSnaps) SnapshotsByNode(_ context.Context, nodeID string) (map[string]probe.ProbeSnapshot, error) {
s.mu.Lock()
defer s.mu.Unlock()
d, ok := s.data[nodeID]
if !ok {
return nil, nil
}
out := make(map[string]probe.ProbeSnapshot, len(d))
for k, v := range d {
out[k] = v
}
return out, nil
}
func (s *mockSnaps) setPass(nodeID string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.data == nil {
s.data = make(map[string]map[string]probe.ProbeSnapshot)
}
s.data[nodeID] = passingSnapshots()
}
// passingSnapshots returns a snapshot set where 2/3 domestic ISPs pass + overseas OK.
func passingSnapshots() map[string]probe.ProbeSnapshot {
return map[string]probe.ProbeSnapshot{
"CN:Telecom": {
Vantage: probe.VantagePoint{Country: "CN", ISP: "ChinaTelecom"},
Report: probe.NodeReport{
L1: probe.L1Result{OK: true},
L3: &probe.L3Result{OK: true},
},
},
"CN:Unicom": {
Vantage: probe.VantagePoint{Country: "CN", ISP: "ChinaUnicom"},
Report: probe.NodeReport{
L1: probe.L1Result{OK: true},
L3: &probe.L3Result{OK: true},
},
},
"CN:Mobile": {
Vantage: probe.VantagePoint{Country: "CN", ISP: "ChinaMobile"},
Report: probe.NodeReport{
L1: probe.L1Result{OK: false},
L3: &probe.L3Result{OK: false},
},
},
"SG:AWS": {
Vantage: probe.VantagePoint{Country: "SG", ISP: "AWS"},
Report: probe.NodeReport{L1: probe.L1Result{OK: true}},
},
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Mock Notifier
// ─────────────────────────────────────────────────────────────────────────────
type mockNotifier struct {
mu sync.Mutex
events []alert.Event
}
func (n *mockNotifier) Notify(_ context.Context, e alert.Event) error {
n.mu.Lock()
defer n.mu.Unlock()
n.events = append(n.events, e)
return nil
}
// count returns the number of Notify calls received.
func (n *mockNotifier) count() int {
n.mu.Lock()
defer n.mu.Unlock()
return len(n.events)
}
// countByType returns the number of Notify calls with the given EventType.
func (n *mockNotifier) countByType(t alert.EventType) int {
n.mu.Lock()
defer n.mu.Unlock()
c := 0
for _, e := range n.events {
if e.Type == t {
c++
}
}
return c
}
// ─────────────────────────────────────────────────────────────────────────────
// Mock Clock
// ─────────────────────────────────────────────────────────────────────────────
type mockClock struct {
mu sync.Mutex
now time.Time
}
func newMockClock(t time.Time) *mockClock { return &mockClock{now: t} }
func (c *mockClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *mockClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
// ─────────────────────────────────────────────────────────────────────────────
// Test harness
// ─────────────────────────────────────────────────────────────────────────────
type harness struct {
rdb *redis.Client
prov *mockProvision
lc *mockLC
snaps *mockSnaps
notifier *mockNotifier
clock *mockClock
replacer *orchestrate.Replacer
}
func newHarness(t *testing.T, nodes ...*orchestrate.NodeInfo) *harness {
t.Helper()
rdb, _ := newTestRedis(t)
prov := newMockProvision(
orchestrate.ProviderInfo{ID: "provider-A"},
orchestrate.ProviderInfo{ID: "provider-B"},
orchestrate.ProviderInfo{ID: "provider-C"},
)
lc := newMockLC(nodes...)
snaps := &mockSnaps{}
notifier := &mockNotifier{}
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
r := orchestrate.NewReplacer(orchestrate.Config{
RDB: rdb,
Prov: prov,
LC: lc,
Snaps: snaps,
Notifier: notifier,
Clock: clock,
})
return &harness{rdb: rdb, prov: prov, lc: lc, snaps: snaps, notifier: notifier, clock: clock, replacer: r}
}
func (h *harness) pushQueue(t *testing.T, oldNodeID, replacementUUID string) {
t.Helper()
data, _ := json.Marshal(map[string]string{
"nodeId": oldNodeID,
"replacementUuid": replacementUUID,
})
if err := h.rdb.LPush(context.Background(), "detect:replace:queue", data).Err(); err != nil {
t.Fatalf("pushQueue: %v", err)
}
}
func (h *harness) tick(t *testing.T) {
t.Helper()
if err := h.replacer.Tick(context.Background()); err != nil {
t.Fatalf("Tick() error: %v", err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// State-machine phase explanation (for reference):
//
// Tick 1: drainQueue creates pending record → advanceAll: stepPending → creating
// Tick 2: stepCreating → CreateNode called → probing
// Tick 3: stepProbing (probeStreak=1, need 2)
// Tick 4: stepProbing (probeStreak=2) → activating
// Tick 5: stepActivating → SetWeight(10)+TransitionStatus+BumpVersion+grayscale → draining_old
// Tick 6: stepDrainingOld → DestroyNode(oldNode) → done
// ─────────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// Test: happy-path replacement (6 ticks)
// ─────────────────────────────────────────────────────────────────────────────
func TestHappyPathReplacement(t *testing.T) {
const (
oldNode = "old-node-001"
replacementUUID = "uuid-happy"
)
h := newHarness(t,
&orchestrate.NodeInfo{ID: oldNode, Tier: "free", Region: "hkg", Role: "entry"},
)
h.pushQueue(t, oldNode, replacementUUID)
// Tick 1: pending → creating (CreateNode not yet called).
h.tick(t)
if h.prov.createCount() != 0 {
t.Errorf("tick 1: CreateNode should not be called yet; got %d calls", h.prov.createCount())
}
// Tick 2: creating → CreateNode → probing.
h.tick(t)
if h.prov.createCount() != 1 {
t.Fatalf("tick 2: CreateNode calls = %d; want 1", h.prov.createCount())
}
call0 := h.prov.createCalls[0]
// The idempotency key for attempt 0 must equal the replacement UUID.
if call0.IdempotencyKey != replacementUUID {
t.Errorf("CreateNode idempotency key = %q; want %q", call0.IdempotencyKey, replacementUUID)
}
newNodeID := call0.ReturnedID
// Simulate agent self-registration: add the new node to the lifecycle mock.
h.lc.addNode(&orchestrate.NodeInfo{ID: newNodeID, Tier: "free", Region: "hkg"})
// Set probe snapshots to PASS for the new node.
h.snaps.setPass(newNodeID)
h.tick(t) // Tick 3: probing streak=1 (< 2 required).
h.tick(t) // Tick 4: probing streak=2 → phase saved as activating.
h.tick(t) // Tick 5: activating → SetWeight(10) + TransitionStatus(probing→up) + BumpVersion + grayscale → draining_old.
// After stepActivating:
if w := h.lc.weightOf(newNodeID); w != 10 {
t.Errorf("new node weight after activating = %d; want 10", w)
}
if h.lc.versionOf() == 0 {
t.Error("BumpVersion was not called after activating")
}
h.tick(t) // Tick 6: draining_old → DestroyNode(oldNode) → done.
// Old node must be destroyed.
oldDestroyed := false
for _, id := range h.prov.destroyCalls {
if id == oldNode {
oldDestroyed = true
break
}
}
if !oldDestroyed {
t.Errorf("old node %s not destroyed; destroyCalls = %v", oldNode, h.prov.destroyCalls)
}
// Grayscale record must exist for the new node.
if _, err := h.rdb.Get(context.Background(), "sched:gray:"+newNodeID).Result(); err == redis.Nil {
t.Errorf("grayscale record for %s not found", newNodeID)
}
// Audit log must contain replacement_done.
if !h.lc.hasAudit("orchestrate|replacement_done|node:" + oldNode) {
t.Errorf("replacement_done audit log not found; got: %v", h.lc.auditLogs)
}
// New node must NOT have been destroyed.
for _, id := range h.prov.destroyCalls {
if id == newNodeID {
t.Errorf("new node %s was incorrectly destroyed", newNodeID)
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Test: probe failure exhausts MaxAttempts → failed + alert (7 ticks + 3 clock advances)
// ─────────────────────────────────────────────────────────────────────────────
func TestProbeFailMaxAttempts(t *testing.T) {
const (
oldNode = "old-node-fail"
replacementUUID = "uuid-fail"
)
h := newHarness(t,
&orchestrate.NodeInfo{ID: oldNode, Tier: "free", Region: "hkg", Role: "entry"},
)
h.pushQueue(t, oldNode, replacementUUID)
ctx := context.Background()
// ── Attempt 0 ────────────────────────────────────────────────────────────
h.tick(t) // Tick 1: pending → creating.
h.tick(t) // Tick 2: creating → CreateNode(idem=uuid) → probing.
if h.prov.createCount() != 1 {
t.Fatalf("attempt 0: CreateNode calls = %d; want 1", h.prov.createCount())
}
if h.prov.createCalls[0].IdempotencyKey != replacementUUID {
t.Errorf("attempt 0 idem key = %q; want %q",
h.prov.createCalls[0].IdempotencyKey, replacementUUID)
}
newNode0 := h.prov.createCalls[0].ReturnedID
h.snaps.setPass(newNode0) // even if snapshots pass, timeout overrides
h.clock.advance(orchestrate.ProbeTimeout + time.Second) // cause timeout
h.tick(t) // Tick 3: probing → timeout → DestroyNode(newNode0) → creating (attempts=1).
if h.prov.destroyCount() != 1 {
t.Fatalf("after attempt 0 timeout: DestroyNode calls = %d; want 1", h.prov.destroyCount())
}
if h.prov.destroyCalls[0] != newNode0 {
t.Errorf("attempt 0: expected %s destroyed; got %s", newNode0, h.prov.destroyCalls[0])
}
// Old node must NOT have been destroyed yet.
for _, id := range h.prov.destroyCalls {
if id == oldNode {
t.Errorf("old node destroyed during attempt 0 (want: only after success)")
}
}
// ── Attempt 1 ────────────────────────────────────────────────────────────
h.tick(t) // Tick 4: creating → CreateNode(idem=uuid:retry:1) → probing.
if h.prov.createCount() != 2 {
t.Fatalf("attempt 1: CreateNode calls = %d; want 2", h.prov.createCount())
}
call1 := h.prov.createCalls[1]
if call1.IdempotencyKey != replacementUUID+":retry:1" {
t.Errorf("attempt 1 idem key = %q; want %q:retry:1",
call1.IdempotencyKey, replacementUUID)
}
newNode1 := call1.ReturnedID
h.snaps.setPass(newNode1)
h.clock.advance(orchestrate.ProbeTimeout + time.Second)
h.tick(t) // Tick 5: probing → timeout → DestroyNode(newNode1) → creating (attempts=2).
if h.prov.destroyCount() != 2 {
t.Fatalf("after attempt 1 timeout: DestroyNode calls = %d; want 2", h.prov.destroyCount())
}
// ── Attempt 2 (final) ────────────────────────────────────────────────────
h.tick(t) // Tick 6: creating → CreateNode(idem=uuid:retry:2) → probing.
if h.prov.createCount() != 3 {
t.Fatalf("attempt 2: CreateNode calls = %d; want 3", h.prov.createCount())
}
call2 := h.prov.createCalls[2]
if call2.IdempotencyKey != replacementUUID+":retry:2" {
t.Errorf("attempt 2 idem key = %q; want %q:retry:2",
call2.IdempotencyKey, replacementUUID)
}
newNode2 := call2.ReturnedID
h.snaps.setPass(newNode2)
h.clock.advance(orchestrate.ProbeTimeout + time.Second)
h.tick(t) // Tick 7: probing → timeout → DestroyNode(newNode2) → FAILED.
if h.prov.destroyCount() != 3 {
t.Fatalf("after attempt 2 timeout: DestroyNode calls = %d; want 3", h.prov.destroyCount())
}
// Old node must never have been destroyed.
for _, id := range h.prov.destroyCalls {
if id == oldNode {
t.Errorf("old node %s was incorrectly destroyed during failed replacement", oldNode)
}
}
// Notify(EventTypeReplenishFailed) must be called exactly once.
if n := h.notifier.countByType(alert.EventTypeReplenishFailed); n != 1 {
t.Errorf("Notify(ReplenishFailed) calls = %d; want 1", n)
}
// Record must be in failed phase.
raw, err := h.rdb.Get(ctx, "sched:replace:"+replacementUUID).Result()
if err != nil {
t.Fatalf("read final record: %v", err)
}
var p struct {
Phase orchestrate.Phase `json:"phase"`
}
if err := json.Unmarshal([]byte(raw), &p); err != nil {
t.Fatalf("unmarshal phase: %v", err)
}
if p.Phase != orchestrate.PhaseFailed {
t.Errorf("final phase = %q; want %q", p.Phase, orchestrate.PhaseFailed)
}
// Each attempt must have used a different provider (rotation check).
p0 := h.prov.createCalls[0].Spec.ProviderID
p1 := h.prov.createCalls[1].Spec.ProviderID
p2 := h.prov.createCalls[2].Spec.ProviderID
if p0 == p1 || p1 == p2 || p0 == p2 {
t.Errorf("providers should differ across retries; got [%s, %s, %s]", p0, p1, p2)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Test: crash recovery — CreateNode NOT called again when creating is complete
//
// Simulates a crash where CreateNode succeeded and newNode was set in memory
// but the record was NOT yet saved with phase=probing. On restart the record
// shows phase=creating with newNode set; we expect the Replacer to resume
// from probing without calling CreateNode again.
// ─────────────────────────────────────────────────────────────────────────────
func TestCrashRecovery(t *testing.T) {
const (
oldNode = "old-node-crash"
replacementUUID = "uuid-crash"
simulatedNewNode = "new-node-crash"
)
h := newHarness(t,
&orchestrate.NodeInfo{ID: oldNode, Tier: "free", Region: "hkg", Role: "entry"},
)
ctx := context.Background()
// Inject a pre-crash record: phase=creating, newNode already set.
// This models a crash AFTER CreateNode returned but BEFORE the record was
// saved with phase=probing.
type crashRecord struct {
Phase string `json:"phase"`
OldNode string `json:"oldNode"`
NewNode string `json:"newNode"`
CurrentProviderID string `json:"currentProviderId"`
Attempts int `json:"attempts"`
ProviderTried []string `json:"providerTried"`
ProbeStreak int `json:"probeStreak"`
PhaseStartedAt time.Time `json:"phaseStartedAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
recJSON, _ := json.Marshal(crashRecord{
Phase: "creating",
OldNode: oldNode,
NewNode: simulatedNewNode,
ProviderTried: []string{},
PhaseStartedAt: h.clock.Now(),
UpdatedAt: h.clock.Now(),
})
if err := h.rdb.Set(ctx, "sched:replace:"+replacementUUID, recJSON, 0).Err(); err != nil {
t.Fatalf("inject crash record: %v", err)
}
if err := h.rdb.SAdd(ctx, "sched:replace:index", replacementUUID).Err(); err != nil {
t.Fatalf("inject index: %v", err)
}
// Add the simulated new node to the lifecycle mock.
h.lc.addNode(&orchestrate.NodeInfo{ID: simulatedNewNode, Tier: "free", Region: "hkg"})
h.snaps.setPass(simulatedNewNode)
// Tick 1 ("restart"): phase=creating, newNode already set → skip CreateNode → probing.
h.tick(t)
if h.prov.createCount() != 0 {
t.Fatalf("tick 1 (recovery): CreateNode called %d times; want 0", h.prov.createCount())
}
h.tick(t) // Tick 2: probing streak=1.
h.tick(t) // Tick 3: probing streak=2 → activating.
h.tick(t) // Tick 4: activating → SetWeight(10) + TransitionStatus + BumpVersion + grayscale → draining_old.
h.tick(t) // Tick 5: draining_old → DestroyNode(oldNode) → done.
// CreateNode must never have been called.
if h.prov.createCount() != 0 {
t.Errorf("CreateNode called %d times during crash recovery; want 0", h.prov.createCount())
}
// New node must be at weight 10.
if w := h.lc.weightOf(simulatedNewNode); w != 10 {
t.Errorf("new node weight = %d; want 10", w)
}
// Old node must be destroyed.
oldDestroyed := false
for _, id := range h.prov.destroyCalls {
if id == oldNode {
oldDestroyed = true
break
}
}
if !oldDestroyed {
t.Errorf("old node %s not destroyed in crash-recovery path; destroyCalls = %v",
oldNode, h.prov.destroyCalls)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Test: idempotency — duplicate queue entries produce a single replacement
// ─────────────────────────────────────────────────────────────────────────────
func TestQueueReplayIdempotent(t *testing.T) {
const (
oldNode = "old-node-idem"
replacementUUID = "uuid-idem"
)
h := newHarness(t,
&orchestrate.NodeInfo{ID: oldNode, Tier: "free", Region: "hkg"},
)
// Push the same entry twice (duplicate delivery).
h.pushQueue(t, oldNode, replacementUUID)
h.pushQueue(t, oldNode, replacementUUID)
// Tick 1: both entries drained; SetNX ensures a single record; stepPending → creating.
h.tick(t)
if h.prov.createCount() != 0 {
t.Errorf("tick 1: CreateNode should not be called yet; got %d", h.prov.createCount())
}
// Tick 2: creating → CreateNode called exactly once.
h.tick(t)
if h.prov.createCount() != 1 {
t.Errorf("CreateNode calls = %d after two queue entries; want 1", h.prov.createCount())
}
// Only one UUID must be in the in-flight index.
members, err := h.rdb.SMembers(context.Background(), "sched:replace:index").Result()
if err != nil {
t.Fatalf("smembers: %v", err)
}
if len(members) != 1 {
t.Errorf("index member count = %d; want 1", len(members))
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Test: grayscale weight ramp 10 → 25 → 50 → 75 → 100 (each step = 6 h)
// ─────────────────────────────────────────────────────────────────────────────
func TestGrayscaleAdvance(t *testing.T) {
const nodeID = "gray-node-001"
rdb, _ := newTestRedis(t)
lc := newMockLC(&orchestrate.NodeInfo{ID: nodeID})
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
// Seed a grayscale record at weight 10.
grayKey := "sched:gray:" + nodeID
type grayRec struct {
NodeID string `json:"nodeId"`
CurrentWeight int `json:"currentWeight"`
StartedAt time.Time `json:"startedAt"`
LastAdvancedAt time.Time `json:"lastAdvancedAt"`
}
data, _ := json.Marshal(grayRec{
NodeID: nodeID,
CurrentWeight: 10,
StartedAt: clock.Now(),
LastAdvancedAt: clock.Now(),
})
if err := rdb.Set(context.Background(), grayKey, data, 0).Err(); err != nil {
t.Fatalf("seed gray record: %v", err)
}
g := orchestrate.NewGrayscale(rdb, lc, clock)
ctx := context.Background()
// Advance the clock by GrayscaleInterval (6 h) and call Advance() four times.
// Expected progression: 10 → 25 → 50 → 75 → 100 (record deleted).
wantWeights := []int{25, 50, 75, 100}
for step, want := range wantWeights {
clock.advance(orchestrate.GrayscaleInterval)
if err := g.Advance(ctx); err != nil {
t.Fatalf("step %d Advance(): %v", step, err)
}
if got := lc.weightOf(nodeID); got != want {
t.Errorf("step %d: weight = %d; want %d", step, got, want)
}
// Audit log must record the weight advance.
auditKey := "grayscale|weight_advanced|node:" + nodeID
if !lc.hasAudit(auditKey) {
t.Errorf("step %d: audit log %q not found; logs = %v", step, auditKey, lc.auditLogs)
}
}
// After weight 100, the grayscale record must be deleted.
if _, err := rdb.Get(ctx, grayKey).Result(); err != redis.Nil {
t.Error("grayscale record should be deleted after weight 100")
}
// Final weight must be 100.
if lc.weightOf(nodeID) != 100 {
t.Errorf("final weight = %d; want 100", lc.weightOf(nodeID))
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Test: grayscale does not advance before the interval elapses
// ─────────────────────────────────────────────────────────────────────────────
func TestGrayscaleNoAdvanceBeforeInterval(t *testing.T) {
const nodeID = "gray-node-wait"
rdb, _ := newTestRedis(t)
lc := newMockLC(&orchestrate.NodeInfo{ID: nodeID})
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
grayKey := "sched:gray:" + nodeID
type grayRec struct {
NodeID string `json:"nodeId"`
CurrentWeight int `json:"currentWeight"`
StartedAt time.Time `json:"startedAt"`
LastAdvancedAt time.Time `json:"lastAdvancedAt"`
}
data, _ := json.Marshal(grayRec{
NodeID: nodeID,
CurrentWeight: 10,
StartedAt: clock.Now(),
LastAdvancedAt: clock.Now(),
})
rdb.Set(context.Background(), grayKey, data, 0)
g := orchestrate.NewGrayscale(rdb, lc, clock)
// Advance only 5 h (< 6 h interval).
clock.advance(5 * time.Hour)
if err := g.Advance(context.Background()); err != nil {
t.Fatalf("Advance(): %v", err)
}
// Weight must NOT have been updated.
if w := lc.weightOf(nodeID); w != 0 {
t.Errorf("weight was updated too early: got %d; want 0", w)
}
}