merge: 自动更换编排 + 养机灰度 [tsk_mxEFSvKHX64G]
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
// Package orchestrate implements the replacement orchestration state machine
|
||||
// (task 15E). It is driven every 30 s by the OrchestrateLoop (task 15H).
|
||||
//
|
||||
// Key components:
|
||||
// - Replacer.Tick — drains the 15D replace queue and advances in-flight records.
|
||||
// - Grayscale.Advance — gradually ramps new/recovered node weight 10→25→50→75→100.
|
||||
package orchestrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Phase constants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Phase is the orchestration phase of a replacement record.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePending Phase = "pending" // waiting for breaker / quota approval
|
||||
PhaseCreating Phase = "creating" // CreateNode called; waiting for new node
|
||||
PhaseProbing Phase = "probing" // waiting for consecutive probe passes
|
||||
PhaseActivating Phase = "activating" // promoting new node; starting grayscale
|
||||
PhaseDrainingOld Phase = "draining_old" // destroying old (already-down) node
|
||||
PhaseDone Phase = "done" // finished successfully
|
||||
PhaseFailed Phase = "failed" // terminal failure; human review needed
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tunable constants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// MaxAttempts is the maximum number of create+probe tries before marking failed.
|
||||
MaxAttempts = 3
|
||||
|
||||
// ProbeCyclesRequired is the number of consecutive Tick cycles with a passing
|
||||
// probe snapshot required before the new node is promoted to "up".
|
||||
ProbeCyclesRequired = 2
|
||||
|
||||
// ProbeTimeout is the maximum time allowed in the probing phase per attempt.
|
||||
ProbeTimeout = 15 * time.Minute
|
||||
|
||||
// GrayscaleInterval is the time between successive weight ramp steps.
|
||||
GrayscaleInterval = 6 * time.Hour
|
||||
)
|
||||
|
||||
// GrayscaleWeights is the weight ladder for the warmup ramp.
|
||||
// New / recovered nodes start at 10 and advance every GrayscaleInterval.
|
||||
var GrayscaleWeights = []int{10, 25, 50, 75, 100}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// NodeSpec describes the replacement node to be provisioned.
|
||||
type NodeSpec struct {
|
||||
Tier string
|
||||
Region string
|
||||
Role string
|
||||
ProviderID string // preferred provider for this attempt
|
||||
RealitySNI string // SNI rotation
|
||||
RealityPBK string
|
||||
HY2Port int
|
||||
NameZH string
|
||||
NameEn string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// NodeInfo holds the properties of an existing node needed to build a
|
||||
// replacement spec or to look up tier / region for the breaker.
|
||||
type NodeInfo struct {
|
||||
ID string
|
||||
Tier string
|
||||
Region string
|
||||
Role string
|
||||
ProviderID string
|
||||
RealitySNI string
|
||||
RealityPBK string
|
||||
HY2Port int
|
||||
NameZH string
|
||||
NameEn string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// ProviderInfo describes a cloud provider available for provisioning.
|
||||
type ProviderInfo struct {
|
||||
ID string // provider identifier
|
||||
Regions []string // supported regions (empty means all regions)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Service interfaces
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ProvisionService is the #14 provisioning interface.
|
||||
// The real implementation is provided by task #14; a mock is used in tests.
|
||||
type ProvisionService interface {
|
||||
// CreateNode provisions a new node. idempotencyKey makes the call crash-safe:
|
||||
// replaying the same key returns the already-created node without booting again.
|
||||
CreateNode(ctx context.Context, spec NodeSpec, idempotencyKey string) (nodeID string, err error)
|
||||
|
||||
// DestroyNode tears down a node and releases its IP.
|
||||
DestroyNode(ctx context.Context, nodeID string) error
|
||||
|
||||
// RotateIP swaps the elastic IP on an existing node without re-creating it.
|
||||
RotateIP(ctx context.Context, nodeID string) (newNodeID string, err error)
|
||||
|
||||
// ListProviders returns providers available for the given tier and region.
|
||||
// The returned slice is ordered; callers use rotation for provider selection.
|
||||
ListProviders(ctx context.Context, tier, region string) ([]ProviderInfo, error)
|
||||
}
|
||||
|
||||
// LifecycleService provides node lifecycle management (real impl: task #5).
|
||||
type LifecycleService interface {
|
||||
// GetNode returns the current properties of the node, or nil if not found.
|
||||
GetNode(ctx context.Context, nodeID string) (*NodeInfo, error)
|
||||
|
||||
// TransitionStatus performs an optimistic-lock status transition.
|
||||
// Returns (1, nil) on success, (0, nil) on lock conflict.
|
||||
TransitionStatus(ctx context.Context, nodeID string, from, to string, detail map[string]any) (int, error)
|
||||
|
||||
// SetWeight updates the routing weight for the node.
|
||||
SetWeight(ctx context.Context, nodeID string, weight int) error
|
||||
|
||||
// BumpVersion increments the global directory version so clients re-fetch.
|
||||
BumpVersion(ctx context.Context) error
|
||||
|
||||
// WriteAuditLog records an audit trail entry.
|
||||
WriteAuditLog(ctx context.Context, actor, action, target, meta string) error
|
||||
}
|
||||
|
||||
// ProbeSnapshotter reads the most-recent probe snapshots for a node (15A store).
|
||||
type ProbeSnapshotter interface {
|
||||
SnapshotsByNode(ctx context.Context, nodeID string) (map[string]probe.ProbeSnapshot, error)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Breaker (15F stub)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Breaker is the 15F circuit-breaker interface.
|
||||
type Breaker interface {
|
||||
// Allow returns true if a new replacement is permitted to proceed.
|
||||
Allow(tier, region string) bool
|
||||
|
||||
// Record increments the failure counter after a replaced node has been destroyed.
|
||||
Record(tier, region string)
|
||||
}
|
||||
|
||||
// StubBreaker always permits replacements.
|
||||
// TODO(15F): replace with the real Breaker once task 15F is implemented.
|
||||
type StubBreaker struct{}
|
||||
|
||||
// Allow implements Breaker; always returns true.
|
||||
func (StubBreaker) Allow(_, _ string) bool { return true }
|
||||
|
||||
// Record implements Breaker; no-op until 15F is implemented.
|
||||
func (StubBreaker) Record(_, _ string) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Notifier (15G stub)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Notifier is the 15G alerting interface.
|
||||
type Notifier interface {
|
||||
NotifyFault(ctx context.Context, nodeID, reason string) error
|
||||
}
|
||||
|
||||
// LogNotifier logs faults via slog. Used when no real notifier is wired.
|
||||
type LogNotifier struct{}
|
||||
|
||||
// NotifyFault implements Notifier.
|
||||
func (LogNotifier) NotifyFault(_ context.Context, nodeID, reason string) error {
|
||||
slog.Warn("orchestrate: replacement failed — manual review required",
|
||||
"node_id", nodeID,
|
||||
"reason", reason,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Clock (for testability)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Clock abstracts wall-clock time so tests can fast-forward without sleeping.
|
||||
type Clock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// RealClock is the production clock.
|
||||
type RealClock struct{}
|
||||
|
||||
// Now implements Clock.
|
||||
func (RealClock) Now() time.Time { return time.Now().UTC() }
|
||||
@@ -0,0 +1,183 @@
|
||||
package orchestrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Redis key namespace for grayscale records
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// grayKeyPrefix is the namespace for per-node grayscale warm-up records.
|
||||
// Full key: sched:gray:{nodeID}
|
||||
grayKeyPrefix = "sched:gray:"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GrayscaleRecord
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GrayscaleRecord is the Redis-persisted state of a node in weight warm-up.
|
||||
// Key: sched:gray:{nodeID} Value: JSON-encoded GrayscaleRecord.
|
||||
type GrayscaleRecord struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
CurrentWeight int `json:"currentWeight"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
LastAdvancedAt time.Time `json:"lastAdvancedAt"`
|
||||
}
|
||||
|
||||
// startGrayscale creates a new GrayscaleRecord for nodeID at weight 10 (the
|
||||
// initial weight). It is called from stepActivating immediately after the node
|
||||
// is transitioned to "up".
|
||||
func startGrayscale(ctx context.Context, rdb *redis.Client, clock Clock, nodeID string) error {
|
||||
now := clock.Now()
|
||||
rec := GrayscaleRecord{
|
||||
NodeID: nodeID,
|
||||
CurrentWeight: GrayscaleWeights[0], // 10
|
||||
StartedAt: now,
|
||||
LastAdvancedAt: now,
|
||||
}
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rdb.Set(ctx, grayKeyPrefix+nodeID, data, 0).Err()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Grayscale
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Grayscale advances the weight ramp for all nodes currently in warm-up.
|
||||
// It is called by the CapacityLoop (task 15H); the implementation lives here
|
||||
// (task 15E) while wiring is done in 15H.
|
||||
//
|
||||
// Weight ladder: 10 → 25 → 50 → 75 → 100, one step every 6 h.
|
||||
// When weight 100 is reached, the grayscale record is deleted.
|
||||
type Grayscale struct {
|
||||
rdb *redis.Client
|
||||
lc LifecycleService
|
||||
clock Clock
|
||||
}
|
||||
|
||||
// NewGrayscale creates a Grayscale with the given dependencies.
|
||||
// If clock is nil, RealClock is used.
|
||||
func NewGrayscale(rdb *redis.Client, lc LifecycleService, clock Clock) *Grayscale {
|
||||
if clock == nil {
|
||||
clock = RealClock{}
|
||||
}
|
||||
return &Grayscale{rdb: rdb, lc: lc, clock: clock}
|
||||
}
|
||||
|
||||
// Advance scans all sched:gray:* keys and advances any node whose
|
||||
// LastAdvancedAt is at least GrayscaleInterval (6 h) in the past.
|
||||
func (g *Grayscale) Advance(ctx context.Context) error {
|
||||
keys, err := g.scanGrayKeys(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grayscale: scan keys: %w", err)
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if advErr := g.advanceNode(ctx, key); advErr != nil {
|
||||
slog.Error("grayscale: advance node", "key", key, "error", advErr)
|
||||
// continue; one error must not halt other nodes
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// advanceNode loads the record at key, advances the weight if the interval has
|
||||
// elapsed, calls SetWeight, writes an audit log, and deletes the record when
|
||||
// the final weight (100) is reached.
|
||||
func (g *Grayscale) advanceNode(ctx context.Context, key string) error {
|
||||
val, err := g.rdb.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return nil // already deleted
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("grayscale: get %s: %w", key, err)
|
||||
}
|
||||
var rec GrayscaleRecord
|
||||
if err := json.Unmarshal([]byte(val), &rec); err != nil {
|
||||
return fmt.Errorf("grayscale: unmarshal %s: %w", key, err)
|
||||
}
|
||||
|
||||
now := g.clock.Now()
|
||||
if now.Sub(rec.LastAdvancedAt) < GrayscaleInterval {
|
||||
return nil // interval not elapsed yet
|
||||
}
|
||||
|
||||
// Find the next weight in the ladder.
|
||||
nextWeight, ok := nextGrayscaleWeight(rec.CurrentWeight)
|
||||
if !ok {
|
||||
// Already at maximum; remove the record.
|
||||
_ = g.rdb.Del(ctx, key).Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply the new weight.
|
||||
if err := g.lc.SetWeight(ctx, rec.NodeID, nextWeight); err != nil {
|
||||
return fmt.Errorf("grayscale: set weight %d for %s: %w", nextWeight, rec.NodeID, err)
|
||||
}
|
||||
|
||||
// Audit trail.
|
||||
meta := fmt.Sprintf(`{"from":%d,"to":%d,"node":%q}`, rec.CurrentWeight, nextWeight, rec.NodeID)
|
||||
_ = g.lc.WriteAuditLog(ctx, "grayscale", "weight_advanced", "node:"+rec.NodeID, meta)
|
||||
|
||||
slog.Info("grayscale: weight advanced",
|
||||
"node", rec.NodeID,
|
||||
"from", rec.CurrentWeight,
|
||||
"to", nextWeight,
|
||||
)
|
||||
|
||||
if nextWeight >= GrayscaleWeights[len(GrayscaleWeights)-1] {
|
||||
// Final weight reached: remove the grayscale record.
|
||||
_ = g.rdb.Del(ctx, key).Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Persist updated record.
|
||||
rec.CurrentWeight = nextWeight
|
||||
rec.LastAdvancedAt = now
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.rdb.Set(ctx, key, data, 0).Err()
|
||||
}
|
||||
|
||||
// nextGrayscaleWeight returns the next weight after current in GrayscaleWeights.
|
||||
// Returns (0, false) if current is already at or beyond the final weight.
|
||||
func nextGrayscaleWeight(current int) (int, bool) {
|
||||
for i, w := range GrayscaleWeights {
|
||||
if w == current && i+1 < len(GrayscaleWeights) {
|
||||
return GrayscaleWeights[i+1], true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// scanGrayKeys returns all Redis keys matching sched:gray:*.
|
||||
func (g *Grayscale) scanGrayKeys(ctx context.Context) ([]string, error) {
|
||||
var keys []string
|
||||
iter := g.rdb.Scan(ctx, 0, grayKeyPrefix+"*", 0).Iterator()
|
||||
for iter.Next(ctx) {
|
||||
k := iter.Val()
|
||||
// Skip any keys that don't have the expected format (e.g. the index key).
|
||||
if strings.HasPrefix(k, grayKeyPrefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
if err := iter.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
package orchestrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Redis key constants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// replaceQueueKey is the Redis list populated by 15D when a node is confirmed
|
||||
// blocked. We read from the right (RPOP) for FIFO ordering; 15D writes with LPUSH.
|
||||
replaceQueueKey = "detect:replace:queue"
|
||||
|
||||
// replaceKeyPrefix is the namespace for per-replacement orchestration records.
|
||||
// Full key: sched:replace:{uuid}
|
||||
replaceKeyPrefix = "sched:replace:"
|
||||
|
||||
// replaceIndexKey is a Redis set holding all in-flight replacement UUIDs.
|
||||
replaceIndexKey = "sched:replace:index"
|
||||
|
||||
// replaceTTL is how long a terminal (done / failed) record is kept for auditing.
|
||||
replaceTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Persistent record types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ReplaceRecord is the crash-recoverable orchestration state stored in Redis.
|
||||
// Key: sched:replace:{uuid} Value: JSON-encoded ReplaceRecord.
|
||||
//
|
||||
// All phase transitions read the record first and write it last; a crashed
|
||||
// process resumes from the persisted state without repeating already-executed
|
||||
// actions (CreateNode is guarded by an idempotency key; DestroyNode is
|
||||
// idempotent by design).
|
||||
type ReplaceRecord struct {
|
||||
Phase Phase `json:"phase"`
|
||||
OldNode string `json:"oldNode"` // node ID from the 15D replace queue
|
||||
NewNode string `json:"newNode,omitempty"` // new node ID, set after CreateNode
|
||||
CurrentProviderID string `json:"currentProviderId,omitempty"` // provider for the current attempt
|
||||
Attempts int `json:"attempts"` // how many create+probe attempts so far
|
||||
ProviderTried []string `json:"providerTried"` // provider IDs from failed attempts
|
||||
ProbeStreak int `json:"probeStreak"` // consecutive passing probe Ticks
|
||||
PhaseStartedAt time.Time `json:"phaseStartedAt"` // when the current phase began
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// queueEntry is the JSON shape pushed by 15D (detect/engine.go pushReplaceQueue).
|
||||
type queueEntry struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
ReplacementUUID string `json:"replacementUuid"`
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Replacer
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Replacer drives the replacement orchestration state machine.
|
||||
// It is called every 30 s by the OrchestrateLoop (task 15H).
|
||||
type Replacer struct {
|
||||
rdb *redis.Client
|
||||
prov ProvisionService
|
||||
lc LifecycleService
|
||||
snaps ProbeSnapshotter
|
||||
breaker Breaker
|
||||
notifier Notifier
|
||||
clock Clock
|
||||
}
|
||||
|
||||
// Config holds all dependencies for NewReplacer.
|
||||
type Config struct {
|
||||
RDB *redis.Client
|
||||
Prov ProvisionService
|
||||
LC LifecycleService
|
||||
Snaps ProbeSnapshotter
|
||||
Breaker Breaker
|
||||
Notifier Notifier
|
||||
Clock Clock
|
||||
}
|
||||
|
||||
// NewReplacer constructs a Replacer. Nil optional deps are replaced with stubs.
|
||||
func NewReplacer(cfg Config) *Replacer {
|
||||
if cfg.Breaker == nil {
|
||||
cfg.Breaker = StubBreaker{}
|
||||
}
|
||||
if cfg.Notifier == nil {
|
||||
cfg.Notifier = LogNotifier{}
|
||||
}
|
||||
if cfg.Clock == nil {
|
||||
cfg.Clock = RealClock{}
|
||||
}
|
||||
return &Replacer{
|
||||
rdb: cfg.RDB,
|
||||
prov: cfg.Prov,
|
||||
lc: cfg.LC,
|
||||
snaps: cfg.Snaps,
|
||||
breaker: cfg.Breaker,
|
||||
notifier: cfg.Notifier,
|
||||
clock: cfg.Clock,
|
||||
}
|
||||
}
|
||||
|
||||
// Tick drains the 15D replace queue and advances every in-flight record one
|
||||
// step. It is designed to be called every 30 s and to be idempotent across
|
||||
// crashes: all state is persisted in Redis before any external action, so a
|
||||
// restart resumes from the saved phase.
|
||||
func (r *Replacer) Tick(ctx context.Context) error {
|
||||
if err := r.drainQueue(ctx); err != nil {
|
||||
// Non-fatal: log and continue so in-flight records still advance.
|
||||
slog.Error("orchestrate: drain replace queue", "error", err)
|
||||
}
|
||||
return r.advanceAll(ctx)
|
||||
}
|
||||
|
||||
// drainQueue pops entries from detect:replace:queue and creates pending records.
|
||||
func (r *Replacer) drainQueue(ctx context.Context) error {
|
||||
const maxDrain = 100 // guard against burst
|
||||
for i := 0; i < maxDrain; i++ {
|
||||
raw, err := r.rdb.RPop(ctx, replaceQueueKey).Result()
|
||||
if err == redis.Nil {
|
||||
break // queue empty
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("orchestrate: rpop: %w", err)
|
||||
}
|
||||
var entry queueEntry
|
||||
if err := json.Unmarshal([]byte(raw), &entry); err != nil {
|
||||
slog.Error("orchestrate: bad queue entry", "raw", raw, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := r.ensureRecord(ctx, entry.ReplacementUUID, entry.NodeID); err != nil {
|
||||
slog.Error("orchestrate: ensure record",
|
||||
"uuid", entry.ReplacementUUID,
|
||||
"node", entry.NodeID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureRecord creates a pending orchestration record for uuid/oldNode if one
|
||||
// does not already exist, then adds uuid to the in-flight index. It is safe
|
||||
// to call multiple times (SetNX + SAdd are both idempotent).
|
||||
func (r *Replacer) ensureRecord(ctx context.Context, uuid, oldNode string) error {
|
||||
key := replaceKeyPrefix + uuid
|
||||
rec := ReplaceRecord{
|
||||
Phase: PhasePending,
|
||||
OldNode: oldNode,
|
||||
ProviderTried: []string{},
|
||||
PhaseStartedAt: r.clock.Now(),
|
||||
UpdatedAt: r.clock.Now(),
|
||||
}
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// SetNX: only stores if the key does not exist, preserving any in-progress record.
|
||||
r.rdb.SetNX(ctx, key, data, 0) //nolint:errcheck // best-effort; SAdd follows
|
||||
return r.rdb.SAdd(ctx, replaceIndexKey, uuid).Err()
|
||||
}
|
||||
|
||||
// advanceAll loads every UUID from the in-flight index and advances each record.
|
||||
func (r *Replacer) advanceAll(ctx context.Context) error {
|
||||
uuids, err := r.rdb.SMembers(ctx, replaceIndexKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("orchestrate: smembers index: %w", err)
|
||||
}
|
||||
for _, uuid := range uuids {
|
||||
if advErr := r.advanceRecord(ctx, uuid); advErr != nil {
|
||||
slog.Error("orchestrate: advance record", "uuid", uuid, "error", advErr)
|
||||
// continue; one failure must not halt other replacements
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// advanceRecord loads a single record and advances it by one phase step.
|
||||
func (r *Replacer) advanceRecord(ctx context.Context, uuid string) error {
|
||||
rec, err := r.loadRecord(ctx, uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec == nil {
|
||||
// Key expired or was deleted; clean up the index entry.
|
||||
_ = r.rdb.SRem(ctx, replaceIndexKey, uuid).Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
switch rec.Phase {
|
||||
case PhasePending:
|
||||
return r.stepPending(ctx, uuid, rec)
|
||||
case PhaseCreating:
|
||||
return r.stepCreating(ctx, uuid, rec)
|
||||
case PhaseProbing:
|
||||
return r.stepProbing(ctx, uuid, rec)
|
||||
case PhaseActivating:
|
||||
return r.stepActivating(ctx, uuid, rec)
|
||||
case PhaseDrainingOld:
|
||||
return r.stepDrainingOld(ctx, uuid, rec)
|
||||
case PhaseDone, PhaseFailed:
|
||||
// Terminal: the record exists only for audit; remove from active index.
|
||||
_ = r.rdb.SRem(ctx, replaceIndexKey, uuid).Err()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("orchestrate: unknown phase %q for %s", rec.Phase, uuid)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Phase step functions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// stepPending checks the circuit breaker and watermark/quota, then advances
|
||||
// to creating. If the breaker blocks, the record stays pending (retried next Tick).
|
||||
func (r *Replacer) stepPending(ctx context.Context, uuid string, rec *ReplaceRecord) error {
|
||||
nodeInfo, err := r.lc.GetNode(ctx, rec.OldNode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orchestrate: get node %s: %w", rec.OldNode, err)
|
||||
}
|
||||
if nodeInfo == nil {
|
||||
return fmt.Errorf("orchestrate: old node %s not found", rec.OldNode)
|
||||
}
|
||||
|
||||
// Circuit-breaker check (15F).
|
||||
if !r.breaker.Allow(nodeInfo.Tier, nodeInfo.Region) {
|
||||
slog.Info("orchestrate: breaker blocked replacement",
|
||||
"uuid", uuid, "tier", nodeInfo.Tier, "region", nodeInfo.Region)
|
||||
return nil // stay pending; retry next Tick
|
||||
}
|
||||
|
||||
// Watermark / quota check — stub (always passes).
|
||||
// TODO(15F): implement real capacity-quota guard here.
|
||||
|
||||
rec.Phase = PhaseCreating
|
||||
rec.PhaseStartedAt = r.clock.Now()
|
||||
rec.UpdatedAt = r.clock.Now()
|
||||
return r.saveRecord(ctx, uuid, rec)
|
||||
}
|
||||
|
||||
// stepCreating calls CreateNode (idempotent via idempotency key) and advances
|
||||
// to probing. If NewNode is already set (crash recovery after CreateNode
|
||||
// succeeded but before the record was saved with phase=probing), CreateNode is
|
||||
// NOT called again.
|
||||
func (r *Replacer) stepCreating(ctx context.Context, uuid string, rec *ReplaceRecord) error {
|
||||
if rec.NewNode == "" {
|
||||
nodeInfo, err := r.lc.GetNode(ctx, rec.OldNode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orchestrate: get node %s: %w", rec.OldNode, err)
|
||||
}
|
||||
if nodeInfo == nil {
|
||||
return fmt.Errorf("orchestrate: old node %s not found", rec.OldNode)
|
||||
}
|
||||
|
||||
providerID, err := r.pickProvider(ctx, nodeInfo.Tier, nodeInfo.Region, rec.ProviderTried)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orchestrate: pick provider: %w", err)
|
||||
}
|
||||
|
||||
spec := NodeSpec{
|
||||
Tier: nodeInfo.Tier,
|
||||
Region: nodeInfo.Region,
|
||||
Role: nodeInfo.Role,
|
||||
ProviderID: providerID,
|
||||
RealitySNI: nodeInfo.RealitySNI,
|
||||
RealityPBK: nodeInfo.RealityPBK,
|
||||
HY2Port: nodeInfo.HY2Port,
|
||||
NameZH: nodeInfo.NameZH,
|
||||
NameEn: nodeInfo.NameEn,
|
||||
Tags: nodeInfo.Tags,
|
||||
}
|
||||
idemKey := idempotencyKey(uuid, rec.Attempts)
|
||||
|
||||
newNodeID, err := r.prov.CreateNode(ctx, spec, idemKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orchestrate: create node (attempt %d): %w", rec.Attempts, err)
|
||||
}
|
||||
rec.NewNode = newNodeID
|
||||
rec.CurrentProviderID = providerID
|
||||
}
|
||||
|
||||
// Advance to probing; reset streak and set phase start time.
|
||||
rec.Phase = PhaseProbing
|
||||
rec.ProbeStreak = 0
|
||||
rec.PhaseStartedAt = r.clock.Now()
|
||||
rec.UpdatedAt = r.clock.Now()
|
||||
return r.saveRecord(ctx, uuid, rec)
|
||||
}
|
||||
|
||||
// stepProbing checks probe snapshots for the new node.
|
||||
// On 2 consecutive passing Ticks it advances to activating.
|
||||
// On timeout (15 min) or repeated failure it retries with a different provider,
|
||||
// or marks the record failed after MaxAttempts.
|
||||
func (r *Replacer) stepProbing(ctx context.Context, uuid string, rec *ReplaceRecord) error {
|
||||
now := r.clock.Now()
|
||||
|
||||
// Timeout guard.
|
||||
if now.Sub(rec.PhaseStartedAt) > ProbeTimeout {
|
||||
return r.failProbeAttempt(ctx, uuid, rec, "probe timeout (15 min)")
|
||||
}
|
||||
|
||||
// Read probe snapshots for the new node (from the 15A probe store).
|
||||
snapshots, err := r.snaps.SnapshotsByNode(ctx, rec.NewNode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orchestrate: snapshots for %s: %w", rec.NewNode, err)
|
||||
}
|
||||
|
||||
if probePass(snapshots) {
|
||||
rec.ProbeStreak++
|
||||
} else {
|
||||
rec.ProbeStreak = 0
|
||||
}
|
||||
|
||||
if rec.ProbeStreak >= ProbeCyclesRequired {
|
||||
// Probe window passed: advance to activating.
|
||||
rec.Phase = PhaseActivating
|
||||
rec.PhaseStartedAt = now
|
||||
rec.UpdatedAt = now
|
||||
return r.saveRecord(ctx, uuid, rec)
|
||||
}
|
||||
|
||||
// Not yet passed: update streak and wait for next Tick.
|
||||
rec.UpdatedAt = now
|
||||
return r.saveRecord(ctx, uuid, rec)
|
||||
}
|
||||
|
||||
// failProbeAttempt destroys the failed new node and either schedules a retry
|
||||
// (with a different provider) or marks the replacement as permanently failed.
|
||||
func (r *Replacer) failProbeAttempt(ctx context.Context, uuid string, rec *ReplaceRecord, reason string) error {
|
||||
slog.Warn("orchestrate: probing failed",
|
||||
"uuid", uuid,
|
||||
"new_node", rec.NewNode,
|
||||
"attempt", rec.Attempts,
|
||||
"reason", reason,
|
||||
)
|
||||
|
||||
// Destroy the bad new node (bad IP must not enter the pool).
|
||||
if rec.NewNode != "" {
|
||||
if err := r.prov.DestroyNode(ctx, rec.NewNode); err != nil {
|
||||
slog.Error("orchestrate: destroy failed new node",
|
||||
"uuid", uuid, "node", rec.NewNode, "error", err)
|
||||
// Continue: still mark the attempt as failed.
|
||||
}
|
||||
// Track which provider was tried so the next attempt avoids it.
|
||||
if rec.CurrentProviderID != "" {
|
||||
rec.ProviderTried = appendUnique(rec.ProviderTried, rec.CurrentProviderID)
|
||||
}
|
||||
rec.NewNode = ""
|
||||
rec.CurrentProviderID = ""
|
||||
}
|
||||
rec.Attempts++
|
||||
|
||||
if rec.Attempts >= MaxAttempts {
|
||||
// All attempts exhausted: mark failed and alert.
|
||||
rec.Phase = PhaseFailed
|
||||
rec.UpdatedAt = r.clock.Now()
|
||||
if err := r.saveRecord(ctx, uuid, rec); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = r.rdb.SRem(ctx, replaceIndexKey, uuid).Err()
|
||||
_ = r.rdb.Expire(ctx, replaceKeyPrefix+uuid, replaceTTL).Err()
|
||||
|
||||
alertReason := fmt.Sprintf("probing failed after %d attempts: %s", rec.Attempts, reason)
|
||||
if notifyErr := r.notifier.NotifyFault(ctx, rec.OldNode, alertReason); notifyErr != nil {
|
||||
slog.Error("orchestrate: notify fault", "uuid", uuid, "error", notifyErr)
|
||||
}
|
||||
slog.Error("orchestrate: replacement permanently failed — manual review required",
|
||||
"uuid", uuid, "old_node", rec.OldNode, "attempts", rec.Attempts)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Schedule retry: go back to creating (different provider picked next call).
|
||||
rec.Phase = PhaseCreating
|
||||
rec.ProbeStreak = 0
|
||||
rec.PhaseStartedAt = r.clock.Now()
|
||||
rec.UpdatedAt = r.clock.Now()
|
||||
return r.saveRecord(ctx, uuid, rec)
|
||||
}
|
||||
|
||||
// stepActivating sets initial weight, promotes the new node from probing→up,
|
||||
// bumps the directory version, and registers grayscale warm-up.
|
||||
func (r *Replacer) stepActivating(ctx context.Context, uuid string, rec *ReplaceRecord) error {
|
||||
// Set initial weight for grayscale ramp.
|
||||
if err := r.lc.SetWeight(ctx, rec.NewNode, GrayscaleWeights[0]); err != nil {
|
||||
return fmt.Errorf("orchestrate: set initial weight: %w", err)
|
||||
}
|
||||
|
||||
// Optimistic-lock transition: probing → up.
|
||||
detail := map[string]any{
|
||||
"from": "probing",
|
||||
"to": "up",
|
||||
"replacement_uuid": uuid,
|
||||
"old_node": rec.OldNode,
|
||||
}
|
||||
affected, err := r.lc.TransitionStatus(ctx, rec.NewNode, "probing", "up", detail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orchestrate: transition probing→up: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
// Lock conflict: another writer changed the state. Retry next Tick.
|
||||
slog.Info("orchestrate: probing→up conflict, will retry",
|
||||
"uuid", uuid, "new_node", rec.NewNode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bump directory version so clients re-fetch the updated node list.
|
||||
if err := r.lc.BumpVersion(ctx); err != nil {
|
||||
slog.Error("orchestrate: bump version", "uuid", uuid, "error", err)
|
||||
}
|
||||
|
||||
// Register the new node for grayscale warm-up.
|
||||
if err := startGrayscale(ctx, r.rdb, r.clock, rec.NewNode); err != nil {
|
||||
slog.Error("orchestrate: start grayscale", "uuid", uuid, "node", rec.NewNode, "error", err)
|
||||
}
|
||||
|
||||
rec.Phase = PhaseDrainingOld
|
||||
rec.PhaseStartedAt = r.clock.Now()
|
||||
rec.UpdatedAt = r.clock.Now()
|
||||
return r.saveRecord(ctx, uuid, rec)
|
||||
}
|
||||
|
||||
// stepDrainingOld destroys the old node, records the event in the breaker, and
|
||||
// finalises the replacement.
|
||||
func (r *Replacer) stepDrainingOld(ctx context.Context, uuid string, rec *ReplaceRecord) error {
|
||||
// Look up old node for breaker recording (best-effort; node may be gone).
|
||||
nodeInfo, _ := r.lc.GetNode(ctx, rec.OldNode)
|
||||
|
||||
// Destroy the old (already-down) node and release its IP.
|
||||
if err := r.prov.DestroyNode(ctx, rec.OldNode); err != nil {
|
||||
return fmt.Errorf("orchestrate: destroy old node %s: %w", rec.OldNode, err)
|
||||
}
|
||||
|
||||
// Inform the 15F circuit breaker of this successful replacement.
|
||||
if nodeInfo != nil {
|
||||
r.breaker.Record(nodeInfo.Tier, nodeInfo.Region)
|
||||
}
|
||||
|
||||
rec.Phase = PhaseDone
|
||||
rec.UpdatedAt = r.clock.Now()
|
||||
if err := r.saveRecord(ctx, uuid, rec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info("orchestrate: replacement complete",
|
||||
"uuid", uuid, "old_node", rec.OldNode, "new_node", rec.NewNode)
|
||||
|
||||
// Write audit log.
|
||||
_ = r.lc.WriteAuditLog(ctx, "orchestrate", "replacement_done",
|
||||
"node:"+rec.OldNode,
|
||||
fmt.Sprintf(`{"uuid":%q,"new_node":%q}`, uuid, rec.NewNode),
|
||||
)
|
||||
|
||||
// Remove from active index; set TTL for 7-day audit retention.
|
||||
_ = r.rdb.SRem(ctx, replaceIndexKey, uuid).Err()
|
||||
_ = r.rdb.Expire(ctx, replaceKeyPrefix+uuid, replaceTTL).Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Probe-pass check
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// probePass returns true when the snapshot set indicates the new node is healthy:
|
||||
// - Domestic (CN): ≥ 2/3 ISPs are passing.
|
||||
// - Overseas: at least one vantage is present and all overseas vantages pass L1.
|
||||
func probePass(snapshots map[string]probe.ProbeSnapshot) bool {
|
||||
if len(snapshots) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
domesticISPs := make(map[string]bool) // ISP name → failed?
|
||||
overseasTotal := 0
|
||||
overseasOK := 0
|
||||
|
||||
for _, snap := range snapshots {
|
||||
v := snap.Vantage
|
||||
rpt := snap.Report
|
||||
switch {
|
||||
case v.Country == "CN":
|
||||
// Normalise "3rd-" prefix (third-party vantages share ISP grouping).
|
||||
isp := strings.TrimPrefix(v.ISP, "3rd-")
|
||||
failed := isSnapshotFailed(rpt)
|
||||
if prev, seen := domesticISPs[isp]; seen {
|
||||
domesticISPs[isp] = prev || failed
|
||||
} else {
|
||||
domesticISPs[isp] = failed
|
||||
}
|
||||
case v.Country != "":
|
||||
overseasTotal++
|
||||
if rpt.L1.OK {
|
||||
overseasOK++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(domesticISPs) == 0 || overseasTotal == 0 {
|
||||
return false // insufficient probe data
|
||||
}
|
||||
|
||||
// Domestic: ≥ 2/3 ISPs must be passing (not failed).
|
||||
passISPs := 0
|
||||
for _, failed := range domesticISPs {
|
||||
if !failed {
|
||||
passISPs++
|
||||
}
|
||||
}
|
||||
total := len(domesticISPs)
|
||||
domesticOK := passISPs*3 >= total*2
|
||||
|
||||
// Overseas: every vantage must pass L1.
|
||||
overseasPass := overseasOK == overseasTotal
|
||||
|
||||
return domesticOK && overseasPass
|
||||
}
|
||||
|
||||
// isSnapshotFailed mirrors detect/signals.go isReportFailed: check the highest
|
||||
// available layer (L3 > L2 > L1).
|
||||
func isSnapshotFailed(rpt probe.NodeReport) bool {
|
||||
if rpt.L3 != nil {
|
||||
return !rpt.L3.OK
|
||||
}
|
||||
if !rpt.L1.OK {
|
||||
return true
|
||||
}
|
||||
if rpt.L2 != nil && !rpt.L2.OK {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Provider selection
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// pickProvider selects a provider for a new attempt. It prefers providers NOT
|
||||
// in previouslyTried so each retry rotates to a different vendor.
|
||||
func (r *Replacer) pickProvider(ctx context.Context, tier, region string, previouslyTried []string) (string, error) {
|
||||
providers, err := r.prov.ListProviders(ctx, tier, region)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("orchestrate: list providers: %w", err)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return "", fmt.Errorf("orchestrate: no providers for tier=%s region=%s", tier, region)
|
||||
}
|
||||
|
||||
tried := make(map[string]bool, len(previouslyTried))
|
||||
for _, id := range previouslyTried {
|
||||
tried[id] = true
|
||||
}
|
||||
|
||||
for _, p := range providers {
|
||||
if !tried[p.ID] {
|
||||
return p.ID, nil
|
||||
}
|
||||
}
|
||||
// All providers have been tried; fall back to the first one.
|
||||
return providers[0].ID, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Redis helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// loadRecord reads and unmarshals a ReplaceRecord. Returns (nil, nil) if absent.
|
||||
func (r *Replacer) loadRecord(ctx context.Context, uuid string) (*ReplaceRecord, error) {
|
||||
val, err := r.rdb.Get(ctx, replaceKeyPrefix+uuid).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("orchestrate: load %s: %w", uuid, err)
|
||||
}
|
||||
var rec ReplaceRecord
|
||||
if err := json.Unmarshal([]byte(val), &rec); err != nil {
|
||||
return nil, fmt.Errorf("orchestrate: unmarshal %s: %w", uuid, err)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// saveRecord marshals and persists rec under sched:replace:{uuid}.
|
||||
func (r *Replacer) saveRecord(ctx context.Context, uuid string, rec *ReplaceRecord) error {
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orchestrate: marshal %s: %w", uuid, err)
|
||||
}
|
||||
if err := r.rdb.Set(ctx, replaceKeyPrefix+uuid, data, 0).Err(); err != nil {
|
||||
return fmt.Errorf("orchestrate: save %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Misc helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// idempotencyKey derives the CreateNode idempotency key for the given attempt.
|
||||
// Attempt 0 uses the replacement UUID directly; retries append a suffix to
|
||||
// ensure each attempt is independently idempotent.
|
||||
func idempotencyKey(replacementUUID string, attempt int) string {
|
||||
if attempt == 0 {
|
||||
return replacementUUID
|
||||
}
|
||||
return fmt.Sprintf("%s:retry:%d", replacementUUID, attempt)
|
||||
}
|
||||
|
||||
// appendUnique appends s to slice only if s is not already present.
|
||||
func appendUnique(slice []string, s string) []string {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
return append(slice, s)
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
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/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
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (n *mockNotifier) NotifyFault(_ context.Context, nodeID, _ string) error {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.calls = append(n.calls, nodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *mockNotifier) count() int {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return len(n.calls)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyFault must be called exactly once.
|
||||
if n := h.notifier.count(); n != 1 {
|
||||
t.Errorf("NotifyFault 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user