Files
pangolin/server/internal/nodes/lifecycle.go
T
wangjia 86b67c557b feat(server/db): 多库(3/4)— Dialect 抽象(upsert/锁)+ 合并 directory_version
- db.Dialect:LockForUpdate(mysql "FOR UPDATE" / sqlite "")、Upsert(中性
  EXCLUDED.col → mysql VALUES()/ sqlite excluded.);DialectForDB 从连接驱动推导
- 9 处 ON DUPLICATE KEY、11 处 FOR UPDATE 全走 dialect;sqlite 靠 _txlock=
  immediate 取得 BEGIN IMMEDIATE 悲观锁等价语义
- directory_version 三处重复合并为 store.BumpDirectoryVersion(dialect 感知)
- 这些文件同时含(2/4)的 UTC→Go 改动(与 upsert/锁同语句交错,无法拆分)
- 顺带:usage 的 FIELD()、codes 的 DATE_ADD/GREATEST 续期、nodes 的
  UNIX_TIMESTAMP、NULLIF 等 MySQL 专属构造一并退回 Go/可移植写法

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:01:26 +08:00

281 lines
10 KiB
Go

package nodes
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
dbx "github.com/wangjia/pangolin/server/internal/db"
"github.com/wangjia/pangolin/server/internal/mtls"
"github.com/wangjia/pangolin/server/internal/store"
)
// ErrInvalidTransition is returned when the requested status transition is not
// permitted by the lifecycle state machine, or when the node's current state
// does not match any valid "from" state (optimistic-lock conflict or illegal move).
var ErrInvalidTransition = errors.New("nodes: invalid lifecycle transition")
// nodeEvent mirrors the nine ENUM values in the node_events.event column (migration 6).
type nodeEvent string
const (
eventProvisioned nodeEvent = "provisioned"
eventMarkedUp nodeEvent = "marked_up"
eventDraining nodeEvent = "draining"
eventProbeFail nodeEvent = "probe_fail"
eventBlockedSuspect nodeEvent = "blocked_suspect"
eventBlockedConfirmed nodeEvent = "blocked_confirmed"
eventDestroyed nodeEvent = "destroyed"
)
// transitionSpec describes one allowed status move for a Lifecycle method.
// When a method accepts multiple "from" states, specs are tried in order.
type transitionSpec struct {
from string
to string
event nodeEvent
}
// Lifecycle drives the node lifecycle state machine.
//
// State machine (doc/04 §3):
//
// provisioning → probing → up → draining → down → destroyed
// ↘ destroyed (bad IP)
// ↘ down (skip draining, confirmed block)
//
// Each method executes a single MySQL transaction that:
// 1. UPDATE nodes SET status=<to> WHERE uuid=? AND status=<from> (optimistic lock)
// 2. INSERT node_events(node_id, event, detail JSON, at)
// 3. BumpVersion(ctx, tx) — increments the directory_version singleton
//
// MarkDestroyed additionally (after commit) calls mtls.CRL.Revoke and deletes
// the Redis keys node:load:{uuid} and node:cmdq:{uuid}.
//
// Called by #15 (block-detection / drain scheduler) and #14 (provisioning).
type Lifecycle struct {
db *sql.DB
dialect dbx.Dialect
crl *mtls.CRL // for MarkDestroyed post-commit hook
rdb redis.Cmdable // for MarkDestroyed post-commit cleanup
}
// NewLifecycle constructs a Lifecycle.
// - db: MySQL connection pool (from store.Open)
// - crl: mTLS revocation manager (task 5b, mtls.NewCRL)
// - rdb: Redis client or Cmdable (for post-destroy key cleanup)
func NewLifecycle(db *sql.DB, crl *mtls.CRL, rdb redis.Cmdable) *Lifecycle {
return &Lifecycle{db: db, dialect: dbx.DialectForDB(db), crl: crl, rdb: rdb}
}
// BumpVersion atomically increments the directory_version singleton within tx.
// Delegates to the canonical store.BumpDirectoryVersion (dialect-aware).
func BumpVersion(ctx context.Context, tx *sql.Tx, d dbx.Dialect) error {
return store.BumpDirectoryVersion(ctx, tx, d)
}
// MarkProbing transitions a node from provisioning → probing.
// Writes a "provisioned" event.
// Called by #14 after the VM passes initial reachability checks.
func (l *Lifecycle) MarkProbing(ctx context.Context, nodeUUID string, detail map[string]any) error {
return l.transact(ctx, nodeUUID, []transitionSpec{
{from: "provisioning", to: "probing", event: eventProvisioned},
}, detail)
}
// MarkUp transitions a node from probing → up, admitting it to the directory.
// Writes a "marked_up" event.
// Called by #15 after initial probes confirm the node is reachable.
func (l *Lifecycle) MarkUp(ctx context.Context, nodeUUID string, detail map[string]any) error {
return l.transact(ctx, nodeUUID, []transitionSpec{
{from: "probing", to: "up", event: eventMarkedUp},
}, detail)
}
// MarkDraining transitions a node from up → draining, removing it from the
// live directory while letting existing connections finish.
// Writes a "draining" event.
// The subsequent draining→down transition is driven by the #15 scheduler timer.
// Called by #15 on sustained suspect signals.
func (l *Lifecycle) MarkDraining(ctx context.Context, nodeUUID string, detail map[string]any) error {
return l.transact(ctx, nodeUUID, []transitionSpec{
{from: "up", to: "draining", event: eventDraining},
}, detail)
}
// MarkDown transitions a node from draining → down or (fast-path) up → down.
// Both paths write a "blocked_confirmed" event.
// The up → down fast-path skips draining when block is immediately confirmed (#15).
// The draining → down path is driven by the drain timer in #15 scheduler.
func (l *Lifecycle) MarkDown(ctx context.Context, nodeUUID string, detail map[string]any) error {
return l.transact(ctx, nodeUUID, []transitionSpec{
{from: "draining", to: "down", event: eventBlockedConfirmed},
{from: "up", to: "down", event: eventBlockedConfirmed},
}, detail)
}
// MarkDestroyed permanently retires a node.
// Valid from-states:
// - probing → destroyed ("probe_fail" event): bad IP, never entered the pool.
// - down → destroyed ("destroyed" event): normal retirement after draining.
//
// After the DB transaction commits, two post-commit hooks run:
// 1. mtls.CRL.Revoke(nodeUUID) — marks the node's cert as revoked in Redis.
// 2. Redis DEL node:load:{uuid} and node:cmdq:{uuid} — evicts runtime state.
//
// Called by #14 (failed initial probe) and #15 (retirement scheduler).
func (l *Lifecycle) MarkDestroyed(ctx context.Context, nodeUUID string, detail map[string]any) error {
if err := l.transact(ctx, nodeUUID, []transitionSpec{
{from: "probing", to: "destroyed", event: eventProbeFail},
{from: "down", to: "destroyed", event: eventDestroyed},
}, detail); err != nil {
return err
}
// Post-commit: revoke mTLS certificate.
if l.crl != nil {
if err := l.crl.Revoke(ctx, nodeUUID); err != nil {
return fmt.Errorf("lifecycle.MarkDestroyed: revoke cert: %w", err)
}
}
// Post-commit: delete runtime Redis keys.
if l.rdb != nil {
if err := l.rdb.Del(ctx,
nodeLoadKeyPrefix+nodeUUID,
cmdQueuePrefix+nodeUUID,
).Err(); err != nil {
return fmt.Errorf("lifecycle.MarkDestroyed: redis del: %w", err)
}
}
return nil
}
// MarkBlockedSuspect does NOT change the node's status.
// It lowers the routing weight to 10, writes a "blocked_suspect" event, and
// bumps the directory version so clients receive a re-weighted catalogue.
//
// Called by #15 on the first domestic-probe failure streak ("疑似" judgement).
func (l *Lifecycle) MarkBlockedSuspect(ctx context.Context, nodeUUID string, detail map[string]any) error {
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("lifecycle.MarkBlockedSuspect: begin: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op after Commit
// Resolve the node's internal ID — fail fast if UUID is unknown.
var nodeID int64
if err := tx.QueryRowContext(ctx,
`SELECT id FROM nodes WHERE uuid = ?`, nodeUUID,
).Scan(&nodeID); err == sql.ErrNoRows {
return ErrInvalidTransition
} else if err != nil {
return fmt.Errorf("lifecycle.MarkBlockedSuspect: resolve id: %w", err)
}
// Lower routing weight to 10 (idempotent if already ≤ 10).
if _, err := tx.ExecContext(ctx,
`UPDATE nodes SET weight = 10 WHERE uuid = ?`, nodeUUID,
); err != nil {
return fmt.Errorf("lifecycle.MarkBlockedSuspect: update weight: %w", err)
}
// Write the blocked_suspect event.
detailJSON, err := json.Marshal(detail)
if err != nil {
return fmt.Errorf("lifecycle.MarkBlockedSuspect: marshal detail: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO node_events (node_id, event, detail, at) VALUES (?, ?, ?, ?)`,
nodeID, string(eventBlockedSuspect), detailJSON, time.Now().UTC(),
); err != nil {
return fmt.Errorf("lifecycle.MarkBlockedSuspect: insert event: %w", err)
}
// Bump directory version.
if err := BumpVersion(ctx, tx, l.dialect); err != nil {
return err
}
return tx.Commit()
}
// transact attempts each spec in order, all within a single transaction.
//
// The first spec whose UPDATE affects ≥ 1 row "wins": the corresponding event
// is inserted, the directory version is bumped, and the transaction is committed.
//
// If no spec matches (all return 0 rows affected), the transaction is rolled back
// and ErrInvalidTransition is returned. A 0-row result means either:
// - The node is in a state that is not a valid "from" for this method (illegal move).
// - A concurrent writer already advanced the node past the expected "from" state
// (optimistic-lock conflict: the first writer won; the second sees ErrInvalidTransition).
func (l *Lifecycle) transact(
ctx context.Context,
nodeUUID string,
specs []transitionSpec,
detail map[string]any,
) error {
detailJSON, err := json.Marshal(detail)
if err != nil {
return fmt.Errorf("lifecycle.transact: marshal detail: %w", err)
}
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("lifecycle.transact: begin: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op after Commit
for _, spec := range specs {
res, err := tx.ExecContext(ctx,
`UPDATE nodes SET status = ? WHERE uuid = ? AND status = ?`,
spec.to, nodeUUID, spec.from,
)
if err != nil {
return fmt.Errorf("lifecycle.transact [%s→%s]: %w", spec.from, spec.to, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("lifecycle.transact RowsAffected: %w", err)
}
if n == 0 {
continue // from-state mismatch; try next spec
}
// Transition succeeded — resolve the internal node_id for the event row.
var nodeID int64
if err := tx.QueryRowContext(ctx,
`SELECT id FROM nodes WHERE uuid = ?`, nodeUUID,
).Scan(&nodeID); err != nil {
return fmt.Errorf("lifecycle.transact: resolve node_id after [%s→%s]: %w",
spec.from, spec.to, err)
}
// Insert the event record.
if _, err := tx.ExecContext(ctx,
`INSERT INTO node_events (node_id, event, detail, at) VALUES (?, ?, ?, ?)`,
nodeID, string(spec.event), detailJSON, time.Now().UTC(),
); err != nil {
return fmt.Errorf("lifecycle.transact: insert event [%s→%s]: %w",
spec.from, spec.to, err)
}
// Bump the global directory version.
if err := BumpVersion(ctx, tx, l.dialect); err != nil {
return err
}
return tx.Commit()
}
// No spec matched → illegal transition from the node's current state.
return ErrInvalidTransition
}