86b67c557b
- 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>
281 lines
10 KiB
Go
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
|
|
}
|