Files
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

247 lines
9.4 KiB
Go

package agentstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// MaxAgentIDBytes bounds the reporting agent identifier. It matches the column check in
// migration 0016 so a rejection surfaces as a terse store error rather than a constraint
// violation from PostgreSQL.
const MaxAgentIDBytes = 128
// MaxClockSkew is the only tolerance granted to an agent clock that runs ahead of the
// database. The agent and the database share one host in the supported topology, so real
// skew is sub-millisecond; one second absorbs scheduling jitter while staying far below
// the tightest freshness window, so a skewed clock cannot mask meaningful staleness.
const MaxClockSkew = time.Second
// ErrUnavailable is returned when the store has no database pool. Callers translate it
// into an Unknown status exactly as they do ErrNoSnapshot.
var ErrUnavailable = errors.New("agent snapshot store is unavailable")
// PostgresStore is the only implementation of Store. It owns the agent_snapshots table
// created by migration 0016 and keeps exactly one row per (agent, capability).
//
// Transaction boundary: Put commits the latest snapshot and any derived capacity samples
// atomically. Latest remains a single read statement.
type PostgresStore struct {
// Pool is the shared pgx pool. A nil pool makes every call return ErrUnavailable.
Pool *pgxpool.Pool
// Clock supplies ReceivedAt and the future-observation check. It exists for tests;
// production leaves it nil and the store uses the wall clock in UTC.
Clock func() time.Time
}
var _ Store = PostgresStore{}
func (s PostgresStore) now() time.Time {
if s.Clock == nil {
return time.Now().UTC()
}
return s.Clock().UTC()
}
// Put records the newest snapshot for one capability, replacing any previous one for the
// same agent. It rejects an unknown capability, a missing or oversized payload, a payload
// that is not a JSON object, and a zero or future ObservedAt.
//
// ReceivedAt is always taken from the store clock: the caller's value is ignored so a
// skewed or hostile agent clock cannot make stale data look freshly received.
//
// A snapshot that is older than the row already stored is accepted but does not overwrite
// it, so a delayed retry cannot resurrect superseded telemetry.
func (s PostgresStore) Put(ctx context.Context, snapshot Snapshot) error {
row, err := prepare(snapshot, s.now())
if err != nil {
return err
}
if s.Pool == nil {
return ErrUnavailable
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin agent snapshot write: %w", err)
}
defer func() {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = tx.Rollback(rollbackCtx)
}()
if _, err := tx.Exec(ctx, `INSERT INTO agent_snapshots (agent_id, capability, observed_at, received_at, payload)
VALUES ($1, $2, $3, $4, $5::jsonb)
ON CONFLICT (agent_id, capability) DO UPDATE
SET observed_at = EXCLUDED.observed_at, received_at = EXCLUDED.received_at, payload = EXCLUDED.payload
WHERE agent_snapshots.observed_at <= EXCLUDED.observed_at`,
row.AgentID, string(row.Capability), row.ObservedAt, row.ReceivedAt, []byte(row.Payload)); err != nil {
return fmt.Errorf("write agent snapshot: %w", err)
}
if err := persistCapacitySamples(ctx, tx, row); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit agent snapshot write: %w", err)
}
return nil
}
type capacitySample struct {
Kind string `json:"kind"`
ID string `json:"id"`
Name string `json:"name"`
ObservedAt time.Time `json:"observed_at"`
UsedBytes uint64 `json:"used_bytes"`
CapacityBytes uint64 `json:"capacity_bytes"`
}
func persistCapacitySamples(ctx context.Context, tx pgx.Tx, snapshot Snapshot) error {
samples, err := capacitySamples(snapshot)
if err != nil {
return err
}
if len(samples) == 0 {
return nil
}
payload, err := json.Marshal(samples)
if err != nil {
return fmt.Errorf("encode capacity samples: %w", err)
}
_, err = tx.Exec(ctx, `INSERT INTO capacity_samples (entity_kind,entity_id,entity_name,source_id,sampled_at,observed_at,used_bytes,capacity_bytes)
SELECT sample.kind,sample.id,sample.name,$1,
date_trunc('day',sample.observed_at) + floor(extract(hour FROM sample.observed_at)/6)*interval '6 hours',
sample.observed_at,sample.used_bytes,sample.capacity_bytes
FROM jsonb_to_recordset($2::jsonb) AS sample(kind text,id text,name text,observed_at timestamptz,used_bytes bigint,capacity_bytes bigint)
ON CONFLICT (entity_kind,entity_id,source_id,sampled_at) DO UPDATE
SET entity_name=EXCLUDED.entity_name,observed_at=EXCLUDED.observed_at,used_bytes=EXCLUDED.used_bytes,capacity_bytes=EXCLUDED.capacity_bytes
WHERE capacity_samples.observed_at <= EXCLUDED.observed_at`, snapshot.AgentID, payload)
if err != nil {
return fmt.Errorf("persist capacity samples: %w", err)
}
return nil
}
func capacitySamples(snapshot Snapshot) ([]capacitySample, error) {
kind := ""
collection := ""
capacityField := ""
switch snapshot.Capability {
case CapabilityShares:
kind, collection = "share", "shares"
case CapabilityPools:
kind, collection, capacityField = "pool", "pools", "usableBytes"
case CapabilityDisks:
kind, collection, capacityField = "disk", "disks", "sizeBytes"
default:
return nil, nil
}
var document map[string]json.RawMessage
if err := json.Unmarshal(snapshot.Payload, &document); err != nil {
return nil, errors.New("decode capacity snapshot payload")
}
var items []map[string]json.RawMessage
if err := json.Unmarshal(document[collection], &items); err != nil {
return nil, fmt.Errorf("decode %s capacity collection", kind)
}
samples := make([]capacitySample, 0, len(items))
for _, item := range items {
var id, name string
var used, capacity uint64
if json.Unmarshal(item["id"], &id) != nil || json.Unmarshal(item["name"], &name) != nil || json.Unmarshal(item["usedBytes"], &used) != nil {
continue
}
if capacityField != "" {
if json.Unmarshal(item[capacityField], &capacity) != nil {
continue
}
}
observed := snapshot.ObservedAt
if kind == "share" {
var sizeObserved time.Time
if json.Unmarshal(item["sizeObservedAt"], &sizeObserved) == nil && !sizeObserved.IsZero() {
observed = sizeObserved.UTC()
}
}
id, name = strings.TrimSpace(id), strings.TrimSpace(name)
if id == "" || name == "" || len(id) > 128 || len(name) > 255 || used > math.MaxInt64 || capacity > math.MaxInt64 || observed.IsZero() || observed.After(snapshot.ReceivedAt.Add(MaxClockSkew)) {
continue
}
samples = append(samples, capacitySample{Kind: kind, ID: id, Name: name, ObservedAt: observed, UsedBytes: used, CapacityBytes: capacity})
}
return samples, nil
}
// Latest returns the most recent snapshot for the capability across every reporting
// agent, or ErrNoSnapshot when none has been recorded.
func (s PostgresStore) Latest(ctx context.Context, capability Capability) (Snapshot, error) {
if !capability.Valid() {
return Snapshot{}, fmt.Errorf("unknown agent capability %q", capability)
}
if s.Pool == nil {
return Snapshot{}, ErrUnavailable
}
return scanSnapshot(s.Pool.QueryRow(ctx, `SELECT agent_id, capability, observed_at, received_at, payload
FROM agent_snapshots WHERE capability = $1 ORDER BY observed_at DESC, received_at DESC LIMIT 1`,
string(capability)))
}
// scanSnapshot reads one row and maps the absence of a row onto ErrNoSnapshot, which is an
// expected condition on a fresh install rather than a failure.
func scanSnapshot(row pgx.Row) (Snapshot, error) {
var (
snapshot Snapshot
name string
payload []byte
)
err := row.Scan(&snapshot.AgentID, &name, &snapshot.ObservedAt, &snapshot.ReceivedAt, &payload)
if errors.Is(err, pgx.ErrNoRows) {
return Snapshot{}, ErrNoSnapshot
}
if err != nil {
return Snapshot{}, fmt.Errorf("read agent snapshot: %w", err)
}
snapshot.Capability = Capability(name)
snapshot.ObservedAt = snapshot.ObservedAt.UTC()
snapshot.ReceivedAt = snapshot.ReceivedAt.UTC()
snapshot.Payload = json.RawMessage(payload)
return snapshot, nil
}
// prepare enforces every bound the Writer contract documents and returns the row the
// store persists. It is the single place where an inbound snapshot is trusted, and it
// overwrites ReceivedAt with the store clock so the caller cannot influence freshness.
func prepare(snapshot Snapshot, now time.Time) (Snapshot, error) {
if snapshot.AgentID == "" || len(snapshot.AgentID) > MaxAgentIDBytes {
return Snapshot{}, errors.New("agent id is required and bounded")
}
if !snapshot.Capability.Valid() {
return Snapshot{}, fmt.Errorf("unknown agent capability %q", snapshot.Capability)
}
if len(snapshot.Payload) == 0 {
return Snapshot{}, errors.New("agent snapshot payload is required")
}
if len(snapshot.Payload) > MaxPayloadBytes {
return Snapshot{}, fmt.Errorf("agent snapshot payload exceeds %d bytes", MaxPayloadBytes)
}
if snapshot.ObservedAt.IsZero() {
return Snapshot{}, errors.New("agent snapshot observed time is required")
}
if snapshot.ObservedAt.After(now.Add(MaxClockSkew)) {
return Snapshot{}, errors.New("agent snapshot observed time is in the future")
}
var object map[string]json.RawMessage
if err := json.Unmarshal(snapshot.Payload, &object); err != nil || object == nil {
return Snapshot{}, errors.New("agent snapshot payload must be a JSON object")
}
snapshot.ObservedAt = snapshot.ObservedAt.UTC()
snapshot.ReceivedAt = now
return snapshot, nil
}