Files
pangolin/server/internal/scheduler/probe/store.go
T
wangjia f76e1797c2 merge: maestro/tsk_rBPr0Xuy10bz [tsk_rBPr0Xuy10bz] 探针汇聚入口 + Redis 探针存储 (tsk_eu2qVvni1HJR)
Manually apply changes from maestro/tsk_rBPr0Xuy10bz that could not be
auto-merged due to uncommitted changes on main at merge time.

Added:
- server/internal/scheduler/probe/types.go  – frozen schema types (ReportRequest,
  VantagePoint, NodeReport, L1/L2/L3Result, ProbeSnapshot)
- server/internal/scheduler/probe/store.go  – Redis Store: SaveReports, CheckAndMarkSeen,
  SnapshotsByNode, AliveProbes with TTL constants and key-schema docs
- server/internal/scheduler/probe/ingest.go – IngestHandler (POST /probe/report),
  HMAC-SHA256 auth + timestamp window + replay prevention via SetNX
- server/internal/scheduler/probe/ingest_test.go – 17 tests (auth failures, integration,
  Store unit tests); all pass with miniredis

Updated:
- server/cmd/server/main.go – register /probe/report route when PROBE_SECRETS env is set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:35:24 +08:00

213 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package probe
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// Redis TTL constants for the probe subsystem.
const (
// snapshotTTL is how long a per-(node,vantage) probe snapshot is retained.
// After this window the key expires and 15D must treat it as "no data".
snapshotTTL = 30 * time.Minute
// heartbeatTTL is how long a probe heartbeat key lives without renewal.
// After this window the key expires — meaning "no recent data", NOT "failure".
heartbeatTTL = 15 * time.Minute
// seenTTL is the lifetime of a replay-prevention key.
// Must exceed 2 × timeWindow (2 × 300 s = 600 s); 700 s adds a 100 s buffer.
seenTTL = 700 * time.Second
)
// Store handles Redis reads and writes for the probe subsystem.
//
// # Key schema
//
// probe:{nodeID}:{vantageKey} → ProbeSnapshot JSON, TTL snapshotTTL (30 min)
// probe:hb:{probeID} → Unix timestamp string, TTL heartbeatTTL (15 min)
// probe:seen:{probeID}:{ts} → "1", TTL seenTTL (700 s), replay prevention
//
// # Missing-key semantics (IMPORTANT must not be violated by callers)
//
// A missing probe:hb:{probeID} key means "no recent heartbeat data".
// It must NEVER be interpreted as "probe is down" or folded into a failure
// signal. Determination logic (15D) must treat an absent key as unknown,
// not as a negative result.
//
// # Node-ID constraint
//
// NodeIDs must not be the literal string "hb" or "seen", as those are used as
// key-namespace prefixes. In practice node IDs are UUIDs so this is safe.
type Store struct {
rdb *redis.Client
}
// NewStore creates a Store backed by the given Redis client.
func NewStore(rdb *redis.Client) *Store {
return &Store{rdb: rdb}
}
// --------------------------------------------------------------------------
// Key helpers
// --------------------------------------------------------------------------
// snapshotKey returns the Redis key for the latest probe snapshot
// for the given (nodeID, vantage) pair.
//
// Format: probe:{nodeID}:{country}:{region}:{isp}
func snapshotKey(nodeID string, v VantagePoint) string {
return "probe:" + nodeID + ":" + vantageKey(v)
}
// vantageKey returns a canonical, colon-safe string for a VantagePoint.
// It is used as the trailing segment of a snapshot Redis key.
func vantageKey(v VantagePoint) string {
return sanitizeKeySegment(v.Country) + ":" +
sanitizeKeySegment(v.Region) + ":" +
sanitizeKeySegment(v.ISP)
}
// sanitizeKeySegment replaces characters that would interfere with Redis key
// parsing (space, colon) with underscores so they are safe in compound keys.
func sanitizeKeySegment(s string) string {
s = strings.ReplaceAll(s, " ", "_")
s = strings.ReplaceAll(s, ":", "_")
return s
}
// heartbeatKey returns the Redis key for a probe agent heartbeat.
func heartbeatKey(probeID string) string {
return "probe:hb:" + probeID
}
// seenKey returns the Redis key used for replay-prevention on a
// (probeID, ts) tuple.
func seenKey(probeID, ts string) string {
return "probe:seen:" + probeID + ":" + ts
}
// --------------------------------------------------------------------------
// Write path
// --------------------------------------------------------------------------
// SaveReports persists one ProbeSnapshot per (nodeID, vantage) pair from the
// given report batch and updates the probe heartbeat.
// All writes are issued in a single pipeline for efficiency.
func (s *Store) SaveReports(ctx context.Context, probeID string, vantage VantagePoint, reports []NodeReport) error {
now := time.Now().Unix()
pipe := s.rdb.Pipeline()
for _, rep := range reports {
snap := ProbeSnapshot{
ProbeID: probeID,
Vantage: vantage,
Report: rep,
ReceivedAt: now,
}
data, err := json.Marshal(snap)
if err != nil {
return fmt.Errorf("probe: marshal snapshot for node %s: %w", rep.NodeID, err)
}
pipe.Set(ctx, snapshotKey(rep.NodeID, vantage), data, snapshotTTL)
}
// Update heartbeat: value is the ingest timestamp so readers can compute
// staleness without needing a separate TTL query.
pipe.Set(ctx, heartbeatKey(probeID), strconv.FormatInt(now, 10), heartbeatTTL)
if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("probe: redis pipeline exec: %w", err)
}
return nil
}
// CheckAndMarkSeen atomically checks whether the (probeID, ts) pair has been
// seen before, and marks it as seen if not.
//
// Returns (true, nil) this is a replay; the caller should handle idempotently.
// Returns (false, nil) first time seen; the caller should process normally.
func (s *Store) CheckAndMarkSeen(ctx context.Context, probeID, ts string) (bool, error) {
key := seenKey(probeID, ts)
// SET … NX EX: atomically set iff the key does not exist.
set, err := s.rdb.SetNX(ctx, key, "1", seenTTL).Result()
if err != nil {
return false, fmt.Errorf("probe: seen check: %w", err)
}
// SetNX returns true if the key was newly created (not a replay).
return !set, nil
}
// --------------------------------------------------------------------------
// Read interfaces consumed by 15D (determination logic)
// --------------------------------------------------------------------------
// SnapshotsByNode returns all cached probe snapshots for the given nodeID,
// indexed by vantage key string (Country:Region:ISP).
//
// A missing key — and therefore an empty map — means "no recent probe data
// from any vantage point". Callers (15D) must treat this as "unknown", not
// as a failure signal.
//
// Uses SCAN + GET in two passes. For the expected scale (dozens of vantages
// per node) this is efficient enough; 15D may add caching on top if needed.
func (s *Store) SnapshotsByNode(ctx context.Context, nodeID string) (map[string]ProbeSnapshot, error) {
pattern := "probe:" + nodeID + ":*"
var keys []string
iter := s.rdb.Scan(ctx, 0, pattern, 0).Iterator()
for iter.Next(ctx) {
keys = append(keys, iter.Val())
}
if err := iter.Err(); err != nil {
return nil, fmt.Errorf("probe: scan snapshots for node %s: %w", nodeID, err)
}
result := make(map[string]ProbeSnapshot, len(keys))
prefix := "probe:" + nodeID + ":"
for _, k := range keys {
val, err := s.rdb.Get(ctx, k).Result()
if err == redis.Nil {
// Key expired between SCAN and GET not an error.
continue
}
if err != nil {
return nil, fmt.Errorf("probe: get snapshot %s: %w", k, err)
}
var snap ProbeSnapshot
if err := json.Unmarshal([]byte(val), &snap); err != nil {
// Corrupted data log-worthy but non-fatal; skip this entry.
continue
}
vk := strings.TrimPrefix(k, prefix)
result[vk] = snap
}
return result, nil
}
// AliveProbes returns the IDs of probe agents that have sent a heartbeat
// within the last heartbeatTTL window (15 min).
//
// An empty result means "no probes have reported recently" not that all
// probes are down. Missing heartbeat keys must never be folded into a
// failure signal by callers.
func (s *Store) AliveProbes(ctx context.Context) ([]string, error) {
var ids []string
iter := s.rdb.Scan(ctx, 0, "probe:hb:*", 0).Iterator()
for iter.Next(ctx) {
k := iter.Val()
ids = append(ids, strings.TrimPrefix(k, "probe:hb:"))
}
if err := iter.Err(); err != nil {
return nil, fmt.Errorf("probe: scan heartbeats: %w", err)
}
return ids, nil
}