mirror of
https://github.com/vee1e/krply.git
synced 2026-09-01 09:49:51 +00:00
Storage: fixed-width millisecond timestamp format with a one-time migration so lexicographic comparisons stay chronological; snapshot auto-IDs get nanosecond precision; Close is synchronized; commit failures roll back; DSN paths are URI-escaped; the dedup index is no longer rebuilt every startup; the in-memory store now dedups by event_id and honors Limit/Offset/SinceSeq. Event/discovery: stream IDs URL-escape the selector so slash-containing label selectors round-trip and cannot collide; alias resolution fills empty fields only and no longer clobbers explicit group/version/kind; rc maps to replicationcontrollers and rs to replicasets. Watch: the label selector is now actually applied to List and Watch; the 410 relist path is backoff-throttled, backoff resets after a healthy watch, an empty list resourceVersion backs off instead of looping forever, non-410 watch errors reconnect without writing a spurious permanent gap, an idle watch timer forces reconnects, and Run cancels sibling streams and recovers from panics. Synthetic relist events carry a distinct event_id so unchanged objects re-listed after a gap survive dedup. Materialize: a baseline now resets object state (objects deleted during a gap disappear) and heals open gaps; diff ignores server metadata by path, not by key name, so user fields named status/uid are kept; nested add/remove carry Added/Removed flags; dotted paths are escaped; Diff validates the window and treats a zero before as empty; Snapshot records per-stream watermarks and writes a TypeSnapshot journal record. Replay: apply requires a successful dry run; gvrFor covers every policy kind so Include* toggles work; unsupported kinds are reported as skipped instead of silently dropped; NodePort services lose clusterIP and nodePort while headless keeps clusterIP: None; targetNS is always honored; namespace mapping collisions are detected; plan IDs and field managers are collision-safe and plans are mutex-guarded against concurrent dry-runs/applies. API: cursor pagination pushes ingest_seq into the SQL filter (export --server no longer truncates); gap records expose their payload; /v1/diff requires a cluster_id; dry-run/apply no longer accept a client-supplied kubeconfig; plans carry target_context; HTTP server gets timeouts. Metrics: the watch collector and replay planner now bump the registered counters, and the server refreshes store-derived gauges on a ticker. CLI: replay apply works locally and refuses to apply after a failed dry run; coverage --server follows cursors and shows gap details; timeline auto-detects namespaces and resolves cluster-scoped objects; diff validates the window; export writes 0600 files; PVCs are no longer treated as cluster-scoped; krply-server reports the build version.
719 lines
20 KiB
Go
719 lines
20 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"github.com/krply/krply/internal/event"
|
|
)
|
|
|
|
// sqliteStore is the durable, persistent Store backed by SQLite.
|
|
//
|
|
// Writes are serialized through a single mutex so that append and its
|
|
// checkpoint (stream meta) advance atomically and never interleave. Reads
|
|
// run concurrently and do not need the lock.
|
|
type sqliteStore struct {
|
|
db *sql.DB
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewSQLiteStore opens (or creates) a persistent SQLite store at path and
|
|
// returns it as a Store. The special path ":memory:" yields an in-memory
|
|
// database. WAL mode is enabled via the DSN pragmas.
|
|
func NewSQLiteStore(path string) (Store, error) {
|
|
dsn := ":memory:"
|
|
if path != ":memory:" {
|
|
dsn = "file:" + (&url.URL{Path: path}).EscapedPath() + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)"
|
|
}
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite store %q: %w", path, err)
|
|
}
|
|
if path == ":memory:" {
|
|
// Each pooled connection to ":memory:" is its own database, so pin the
|
|
// pool to a single connection.
|
|
db.SetMaxOpenConns(1)
|
|
}
|
|
s := &sqliteStore{db: db}
|
|
if err := s.init(context.Background()); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("init sqlite store: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
const (
|
|
recordsDDL = `CREATE TABLE IF NOT EXISTS records (
|
|
ingest_seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
cluster_id TEXT,
|
|
stream_id TEXT,
|
|
record_type TEXT,
|
|
event_id TEXT,
|
|
observed_at TEXT,
|
|
watch_type TEXT,
|
|
synthetic INTEGER,
|
|
grp TEXT,
|
|
version TEXT,
|
|
kind TEXT,
|
|
namespace TEXT,
|
|
name TEXT,
|
|
uid TEXT,
|
|
resource_version TEXT,
|
|
object_hash TEXT,
|
|
object BLOB,
|
|
provenance TEXT,
|
|
gap TEXT,
|
|
coverage TEXT,
|
|
checkpoint TEXT,
|
|
snapshot TEXT
|
|
)`
|
|
|
|
dedupIndexDDL = `CREATE UNIQUE INDEX IF NOT EXISTS idx_records_dedup
|
|
ON records (cluster_id, stream_id, event_id) WHERE event_id <> ''`
|
|
|
|
lookupIndexDDL = `CREATE INDEX IF NOT EXISTS idx_records_lookup
|
|
ON records (cluster_id, stream_id, observed_at, namespace, name)`
|
|
|
|
streamsDDL = `CREATE TABLE IF NOT EXISTS streams (
|
|
stream_id TEXT PRIMARY KEY,
|
|
cluster_id TEXT,
|
|
grp TEXT,
|
|
version TEXT,
|
|
resource TEXT,
|
|
kind TEXT,
|
|
namespace TEXT,
|
|
selector TEXT,
|
|
available INTEGER,
|
|
first_observed_at TEXT,
|
|
last_observed_at TEXT,
|
|
last_resource_version TEXT,
|
|
gap_count INTEGER,
|
|
degraded INTEGER
|
|
)`
|
|
|
|
snapshotsDDL = `CREATE TABLE IF NOT EXISTS snapshots (
|
|
id TEXT PRIMARY KEY,
|
|
cluster_id TEXT,
|
|
name TEXT,
|
|
at TEXT
|
|
)`
|
|
)
|
|
|
|
func (s *sqliteStore) init(ctx context.Context) error {
|
|
for _, ddl := range []string{recordsDDL, dedupIndexDDL, lookupIndexDDL, streamsDDL, snapshotsDDL} {
|
|
if _, err := s.db.ExecContext(ctx, ddl); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// migrateTimeColumns normalizes timestamps written before the fixed-width
|
|
// format to the same shape so lexicographic comparisons stay chronological.
|
|
var userVersion int
|
|
if err := s.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&userVersion); err != nil {
|
|
return err
|
|
}
|
|
if userVersion < 1 {
|
|
for _, q := range []string{
|
|
`UPDATE records SET observed_at = strftime('%Y-%m-%dT%H:%M:%fZ', observed_at) WHERE observed_at <> ''`,
|
|
`UPDATE streams SET first_observed_at = strftime('%Y-%m-%dT%H:%M:%fZ', first_observed_at) WHERE first_observed_at <> ''`,
|
|
`UPDATE streams SET last_observed_at = strftime('%Y-%m-%dT%H:%M:%fZ', last_observed_at) WHERE last_observed_at <> ''`,
|
|
`UPDATE snapshots SET at = strftime('%Y-%m-%dT%H:%M:%fZ', at) WHERE at <> ''`,
|
|
} {
|
|
if _, err := s.db.ExecContext(ctx, q); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := s.db.ExecContext(ctx, `PRAGMA user_version = 1`); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// recordCols is the SELECT column list for the records table in schema order.
|
|
const recordCols = `ingest_seq, cluster_id, stream_id, record_type, event_id, observed_at,
|
|
watch_type, synthetic, grp, version, kind, namespace, name, uid, resource_version,
|
|
object_hash, object, provenance, gap, coverage, checkpoint, snapshot`
|
|
|
|
// streamCols is the SELECT column list for the streams meta table.
|
|
const streamCols = `stream_id, cluster_id, grp, version, resource, kind, namespace, selector,
|
|
available, first_observed_at, last_observed_at, last_resource_version, gap_count, degraded`
|
|
|
|
func (s *sqliteStore) Append(ctx context.Context, rec *event.Record) (int64, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
seq, err := s.appendRecord(ctx, tx, rec)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
return seq, nil
|
|
}
|
|
|
|
func (s *sqliteStore) Appends(ctx context.Context, recs []*event.Record) ([]int64, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seqs := make([]int64, 0, len(recs))
|
|
for _, r := range recs {
|
|
seq, err := s.appendRecord(ctx, tx, r)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
seqs = append(seqs, seq)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
return seqs, nil
|
|
}
|
|
|
|
func (s *sqliteStore) appendRecord(ctx context.Context, tx *sql.Tx, rec *event.Record) (int64, error) {
|
|
if rec.ObservedAt.IsZero() {
|
|
rec.ObservedAt = time.Now().UTC()
|
|
}
|
|
observedAt := formatTime(rec.ObservedAt)
|
|
synthetic := 0
|
|
if rec.Synthetic {
|
|
synthetic = 1
|
|
}
|
|
var object []byte
|
|
if len(rec.Object) > 0 {
|
|
object = rec.Object
|
|
}
|
|
provenance, err := marshalJSON(rec.Provenance)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
gap, err := marshalJSON(rec.Gap)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
coverage, err := marshalJSON(rec.Coverage)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
checkpoint, err := marshalJSON(rec.Checkpoint)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
snapshot, err := marshalJSON(rec.Snapshot)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
res, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO records (
|
|
cluster_id, stream_id, record_type, event_id, observed_at, watch_type, synthetic,
|
|
grp, version, kind, namespace, name, uid, resource_version, object_hash, object,
|
|
provenance, gap, coverage, checkpoint, snapshot
|
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
rec.ClusterID, rec.StreamID, string(rec.Type), rec.EventID, observedAt,
|
|
string(rec.WatchType), synthetic,
|
|
rec.Resource.Group, rec.Resource.Version, rec.Resource.Kind, rec.Resource.Namespace,
|
|
rec.Resource.Name, rec.Resource.UID, rec.Resource.ResourceVersion, rec.ObjectHash, object,
|
|
provenance, gap, coverage, checkpoint, snapshot,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
inserted, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
var seq int64
|
|
if inserted > 0 {
|
|
if err := tx.QueryRowContext(ctx, `SELECT last_insert_rowid()`).Scan(&seq); err != nil {
|
|
return 0, err
|
|
}
|
|
if rec.StreamID != "" {
|
|
if err := s.upsertMeta(ctx, tx, rec, observedAt); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
} else {
|
|
// Duplicate event_id delivery: re-return the existing ingest sequence.
|
|
if err := tx.QueryRowContext(ctx, `SELECT ingest_seq FROM records
|
|
WHERE cluster_id=? AND stream_id=? AND event_id=?`,
|
|
rec.ClusterID, rec.StreamID, rec.EventID).Scan(&seq); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
rec.IngestSeq = seq
|
|
return seq, nil
|
|
}
|
|
|
|
// upsertMeta mirrors the checkpoint-advance logic of the in-memory store's
|
|
// updateMeta, but persisted. It runs inside the same transaction as the
|
|
// record insert so the two are atomic.
|
|
func (s *sqliteStore) upsertMeta(ctx context.Context, tx *sql.Tx, rec *event.Record, observedAt string) error {
|
|
var (
|
|
available, degraded int
|
|
gapCount int64
|
|
first, last, lastRV string
|
|
)
|
|
err := tx.QueryRowContext(ctx, `SELECT available, degraded, gap_count, first_observed_at,
|
|
last_observed_at, last_resource_version FROM streams WHERE stream_id=?`, rec.StreamID).
|
|
Scan(&available, °raded, &gapCount, &first, &last, &lastRV)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
|
|
if first == "" {
|
|
first = observedAt
|
|
}
|
|
if last == "" || observedAt > last {
|
|
last = observedAt
|
|
}
|
|
|
|
switch rec.Type {
|
|
case event.TypeGap:
|
|
gapCount++
|
|
degraded = 1
|
|
case event.TypeBaseline:
|
|
available = 1
|
|
degraded = 0
|
|
if rec.Resource.ResourceVersion != "" {
|
|
lastRV = rec.Resource.ResourceVersion
|
|
}
|
|
case event.TypeCoverageChange:
|
|
if rec.Coverage != nil {
|
|
if rec.Coverage.Current.Available {
|
|
available = 1
|
|
degraded = 0
|
|
} else {
|
|
available = 0
|
|
degraded = 1
|
|
}
|
|
}
|
|
case event.TypeCheckpoint:
|
|
if rec.Checkpoint != nil {
|
|
lastRV = rec.Checkpoint.ResourceVersion
|
|
}
|
|
case event.TypeEvent:
|
|
if rec.Resource.ResourceVersion != "" {
|
|
lastRV = rec.Resource.ResourceVersion
|
|
}
|
|
}
|
|
|
|
grp, version, resource, kind, namespace, selector := metaParts(rec.StreamID, rec)
|
|
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO streams (
|
|
stream_id, cluster_id, grp, version, resource, kind, namespace, selector,
|
|
available, first_observed_at, last_observed_at, last_resource_version, gap_count, degraded
|
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(stream_id) DO UPDATE SET
|
|
cluster_id=excluded.cluster_id,
|
|
grp=excluded.grp,
|
|
version=excluded.version,
|
|
resource=excluded.resource,
|
|
kind=excluded.kind,
|
|
namespace=excluded.namespace,
|
|
selector=excluded.selector,
|
|
available=excluded.available,
|
|
first_observed_at=excluded.first_observed_at,
|
|
last_observed_at=excluded.last_observed_at,
|
|
last_resource_version=excluded.last_resource_version,
|
|
gap_count=excluded.gap_count,
|
|
degraded=excluded.degraded`,
|
|
rec.StreamID, rec.ClusterID, grp, version, resource, kind, namespace, selector,
|
|
available, first, last, lastRV, gapCount, degraded,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func metaParts(streamID string, rec *event.Record) (grp, version, resource, kind, namespace, selector string) {
|
|
if st, err := event.StreamID(streamID); err == nil {
|
|
return st.Group, st.Version, st.Resource, rec.Resource.Kind, st.Namespace, st.Selector
|
|
}
|
|
return rec.Resource.Group, rec.Resource.Version, "", rec.Resource.Kind, rec.Resource.Namespace, ""
|
|
}
|
|
|
|
func (s *sqliteStore) ListClusters(ctx context.Context) ([]string, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT cluster_id FROM streams
|
|
WHERE cluster_id<>'' ORDER BY cluster_id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var c string
|
|
if err := rows.Scan(&c); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *sqliteStore) Streams(ctx context.Context) ([]StreamMeta, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT `+streamCols+` FROM streams ORDER BY stream_id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []StreamMeta
|
|
for rows.Next() {
|
|
m, err := scanStreamMeta(rows.Scan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *sqliteStore) StreamMeta(ctx context.Context, streamID string) (StreamMeta, error) {
|
|
row := s.db.QueryRowContext(ctx, `SELECT `+streamCols+` FROM streams WHERE stream_id=?`, streamID)
|
|
m, err := scanStreamMeta(row.Scan)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return StreamMeta{}, ErrStreamNotFound
|
|
}
|
|
if err != nil {
|
|
return StreamMeta{}, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (s *sqliteStore) Events(ctx context.Context, f EventFilter) ([]event.Record, error) {
|
|
q := `SELECT ` + recordCols + ` FROM records WHERE 1=1`
|
|
var args []any
|
|
if f.ClusterID != "" {
|
|
q += " AND cluster_id=?"
|
|
args = append(args, f.ClusterID)
|
|
}
|
|
if f.StreamID != "" {
|
|
q += " AND stream_id=?"
|
|
args = append(args, f.StreamID)
|
|
}
|
|
if f.RecordType != "" {
|
|
q += " AND record_type=?"
|
|
args = append(args, string(f.RecordType))
|
|
}
|
|
if f.Namespace != "" {
|
|
q += " AND namespace=?"
|
|
args = append(args, f.Namespace)
|
|
}
|
|
if f.Name != "" {
|
|
q += " AND name=?"
|
|
args = append(args, f.Name)
|
|
}
|
|
if f.Kind != "" {
|
|
q += " AND kind=?"
|
|
args = append(args, f.Kind)
|
|
}
|
|
if !f.Since.IsZero() {
|
|
q += " AND observed_at>=?"
|
|
args = append(args, formatTime(f.Since))
|
|
}
|
|
if !f.Until.IsZero() {
|
|
q += " AND observed_at<=?"
|
|
args = append(args, formatTime(f.Until))
|
|
}
|
|
if f.SinceSeq > 0 {
|
|
q += " AND ingest_seq>?"
|
|
args = append(args, f.SinceSeq)
|
|
}
|
|
q += " ORDER BY ingest_seq ASC"
|
|
if f.Limit > 0 {
|
|
q += " LIMIT ?"
|
|
args = append(args, f.Limit)
|
|
if f.Offset > 0 {
|
|
q += " OFFSET ?"
|
|
args = append(args, f.Offset)
|
|
}
|
|
} else if f.Offset > 0 {
|
|
q += " LIMIT -1 OFFSET ?"
|
|
args = append(args, f.Offset)
|
|
}
|
|
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []event.Record
|
|
for rows.Next() {
|
|
r, err := scanRecord(rows.Scan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *sqliteStore) ObjectHistory(ctx context.Context, ref ObjectRef) ([]event.Record, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT `+recordCols+` FROM records
|
|
WHERE record_type=? AND cluster_id=? AND stream_id=? AND namespace=? AND name=?
|
|
ORDER BY ingest_seq ASC`,
|
|
string(event.TypeEvent), ref.ClusterID, ref.StreamID, ref.Namespace, ref.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []event.Record
|
|
for rows.Next() {
|
|
r, err := scanRecord(rows.Scan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *sqliteStore) ObjectAt(ctx context.Context, ref ObjectRef, ts time.Time) (*event.Record, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT `+recordCols+` FROM records
|
|
WHERE record_type=? AND cluster_id=? AND stream_id=? AND namespace=? AND name=? AND observed_at<=?
|
|
ORDER BY ingest_seq ASC`,
|
|
string(event.TypeEvent), ref.ClusterID, ref.StreamID, ref.Namespace, ref.Name, formatTime(ts))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var last *event.Record
|
|
for rows.Next() {
|
|
r, err := scanRecord(rows.Scan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.WatchType == event.WatchDeleted {
|
|
last = nil
|
|
continue
|
|
}
|
|
cp := r
|
|
last = &cp
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if last == nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
return last, nil
|
|
}
|
|
|
|
func (s *sqliteStore) StreamEvents(ctx context.Context, streamID string, until time.Time) ([]event.Record, error) {
|
|
q := `SELECT ` + recordCols + ` FROM records WHERE stream_id=?`
|
|
args := []any{streamID}
|
|
if !until.IsZero() {
|
|
q += " AND observed_at<=?"
|
|
args = append(args, formatTime(until))
|
|
}
|
|
q += " ORDER BY ingest_seq ASC"
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []event.Record
|
|
for rows.Next() {
|
|
r, err := scanRecord(rows.Scan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *sqliteStore) Baselines(ctx context.Context, streamID string) ([]event.Record, error) {
|
|
return s.recordsByType(ctx, streamID, event.TypeBaseline)
|
|
}
|
|
|
|
func (s *sqliteStore) Gaps(ctx context.Context, streamID string) ([]event.Record, error) {
|
|
return s.recordsByType(ctx, streamID, event.TypeGap)
|
|
}
|
|
|
|
func (s *sqliteStore) recordsByType(ctx context.Context, streamID string, typ event.RecordType) ([]event.Record, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT `+recordCols+` FROM records
|
|
WHERE stream_id=? AND record_type=? ORDER BY ingest_seq ASC`, streamID, string(typ))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []event.Record
|
|
for rows.Next() {
|
|
r, err := scanRecord(rows.Scan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *sqliteStore) SaveSnapshot(ctx context.Context, snap *SnapshotRef) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cp := *snap
|
|
if cp.ID == "" {
|
|
cp.ID = "snap-" + time.Now().UTC().Format("20060102T150405.000000000")
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO snapshots (id, cluster_id, name, at)
|
|
VALUES (?,?,?,?)
|
|
ON CONFLICT(id) DO UPDATE SET cluster_id=excluded.cluster_id, name=excluded.name, at=excluded.at`,
|
|
cp.ID, cp.ClusterID, cp.Name, formatTime(cp.At))
|
|
return err
|
|
}
|
|
|
|
func (s *sqliteStore) Snapshots(ctx context.Context) ([]SnapshotRef, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, cluster_id, name, at FROM snapshots ORDER BY at`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []SnapshotRef
|
|
for rows.Next() {
|
|
var snap SnapshotRef
|
|
var at string
|
|
if err := rows.Scan(&snap.ID, &snap.ClusterID, &snap.Name, &at); err != nil {
|
|
return nil, err
|
|
}
|
|
t, err := parseTime(at)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
snap.At = t
|
|
out = append(out, snap)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *sqliteStore) Close() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.db.Close()
|
|
}
|
|
|
|
// scanFunc matches both *sql.Rows.Scan and *sql.Row.Scan.
|
|
type scanFunc func(dest ...any) error
|
|
|
|
func scanRecord(scan scanFunc) (event.Record, error) {
|
|
var r event.Record
|
|
var (
|
|
observedAt, watchType string
|
|
synthetic int
|
|
grp, version, kind, namespace, name, uid, resourceVersion, objectHash string
|
|
object []byte
|
|
provenance, gap, coverage, checkpoint, snapshot string
|
|
)
|
|
if err := scan(&r.IngestSeq, &r.ClusterID, &r.StreamID, &r.Type, &r.EventID, &observedAt,
|
|
&watchType, &synthetic, &grp, &version, &kind, &namespace, &name, &uid, &resourceVersion,
|
|
&objectHash, &object, &provenance, &gap, &coverage, &checkpoint, &snapshot); err != nil {
|
|
return event.Record{}, err
|
|
}
|
|
t, err := parseTime(observedAt)
|
|
if err != nil {
|
|
return event.Record{}, err
|
|
}
|
|
r.ObservedAt = t
|
|
r.WatchType = event.WatchType(watchType)
|
|
r.Synthetic = synthetic != 0
|
|
r.Resource = event.ResourceRef{
|
|
Group: grp, Version: version, Kind: kind, Namespace: namespace, Name: name,
|
|
UID: uid, ResourceVersion: resourceVersion,
|
|
}
|
|
r.ObjectHash = objectHash
|
|
r.Object = json.RawMessage(object)
|
|
if err := unmarshalJSON(provenance, &r.Provenance); err != nil {
|
|
return event.Record{}, err
|
|
}
|
|
if err := unmarshalJSON(gap, &r.Gap); err != nil {
|
|
return event.Record{}, err
|
|
}
|
|
if err := unmarshalJSON(coverage, &r.Coverage); err != nil {
|
|
return event.Record{}, err
|
|
}
|
|
if err := unmarshalJSON(checkpoint, &r.Checkpoint); err != nil {
|
|
return event.Record{}, err
|
|
}
|
|
if err := unmarshalJSON(snapshot, &r.Snapshot); err != nil {
|
|
return event.Record{}, err
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func scanStreamMeta(scan scanFunc) (StreamMeta, error) {
|
|
var m StreamMeta
|
|
var available, degraded int
|
|
var first, last string
|
|
if err := scan(&m.StreamID, &m.ClusterID, &m.Group, &m.Version, &m.Resource, &m.Kind,
|
|
&m.Namespace, &m.Selector, &available, &first, &last, &m.LastResourceVersion,
|
|
&m.GapCount, °raded); err != nil {
|
|
return StreamMeta{}, err
|
|
}
|
|
m.Available = available != 0
|
|
m.Degraded = degraded != 0
|
|
m.HasGaps = m.GapCount > 0
|
|
t, err := parseTime(first)
|
|
if err != nil {
|
|
return StreamMeta{}, err
|
|
}
|
|
m.FirstObservedAt = t
|
|
t, err = parseTime(last)
|
|
if err != nil {
|
|
return StreamMeta{}, err
|
|
}
|
|
m.LastObservedAt = t
|
|
return m, nil
|
|
}
|
|
|
|
// formatTime renders t as a fixed-width UTC timestamp. Variable-width RFC3339
|
|
// output breaks lexicographic string comparisons at sub-second boundaries, so
|
|
// the fraction is always padded to milliseconds and the zone is always "Z".
|
|
// All comparisons against observed_at use this exact shape.
|
|
func formatTime(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.UTC().Format("2006-01-02T15:04:05.000") + "Z"
|
|
}
|
|
|
|
func parseTime(s string) (time.Time, error) {
|
|
if s == "" {
|
|
return time.Time{}, nil
|
|
}
|
|
return time.Parse(time.RFC3339Nano, s)
|
|
}
|
|
|
|
func marshalJSON(v any) (string, error) {
|
|
if v == nil {
|
|
return "", nil
|
|
}
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func unmarshalJSON(s string, v any) error {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return json.Unmarshal([]byte(s), v)
|
|
}
|