Initial commit: stats-gateway gRPC service + dashboard + CLI
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
http_latency_ms REAL NOT NULL,
|
||||
tcp_rtt_ms REAL NOT NULL,
|
||||
dns_resolve_ms REAL NOT NULL,
|
||||
tls_handshake_ms REAL NOT NULL,
|
||||
throughput_kbps INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_samples_node_ts ON samples (node, ts DESC);
|
||||
@@ -0,0 +1,114 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/bergabruh/stats-gateway/internal/probes"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
// tsLayout is RFC3339 with fixed 9-digit nanosecond fraction. Lexical
|
||||
// ordering of formatted strings preserves chronological order — unlike
|
||||
// time.RFC3339Nano which strips trailing zeros (".1Z" lex-sorts after
|
||||
// ".100000001Z" because 'Z' > '0'). SQLite TEXT columns are compared
|
||||
// lexically, so this matters for QueryRange boundaries.
|
||||
const tsLayout = "2006-01-02T15:04:05.000000000Z07:00"
|
||||
|
||||
// Store wraps a SQLite database for probe sample persistence.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (or creates) the SQLite database at path and runs migrations.
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
body, err := migrations.ReadFile("migrations/001_init.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(string(body))
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertSample stores a probe result for the given node.
|
||||
func (s *Store) InsertSample(ctx context.Context, node string, r *probes.Result) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO samples (node, ts, http_latency_ms, tcp_rtt_ms, dns_resolve_ms, tls_handshake_ms, throughput_kbps)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, node, r.Timestamp.Format(tsLayout),
|
||||
r.HTTPLatencyMs, r.TCPRttMs, r.DNSResolveMs, r.TLSHandshakeMs, r.ThroughputKbps)
|
||||
return err
|
||||
}
|
||||
|
||||
// QueryRecent returns up to limit samples for node ordered by ts DESC.
|
||||
// If since is zero, no lower bound is applied. If node is empty, all nodes are returned.
|
||||
func (s *Store) QueryRecent(ctx context.Context, node string, limit int, since time.Time) ([]probes.Result, error) {
|
||||
q := `SELECT node, ts, http_latency_ms, tcp_rtt_ms, dns_resolve_ms, tls_handshake_ms, throughput_kbps
|
||||
FROM samples WHERE 1=1`
|
||||
args := []any{}
|
||||
if node != "" {
|
||||
q += ` AND node = ?`
|
||||
args = append(args, node)
|
||||
}
|
||||
if !since.IsZero() {
|
||||
q += ` AND ts >= ?`
|
||||
args = append(args, since.Format(tsLayout))
|
||||
}
|
||||
q += ` ORDER BY ts DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
return s.scanSamples(ctx, q, args...)
|
||||
}
|
||||
|
||||
// QueryRange returns samples for [since, until). Used by Aggregate (Task 5).
|
||||
func (s *Store) QueryRange(ctx context.Context, node string, since, until time.Time) ([]probes.Result, error) {
|
||||
q := `SELECT node, ts, http_latency_ms, tcp_rtt_ms, dns_resolve_ms, tls_handshake_ms, throughput_kbps
|
||||
FROM samples WHERE ts >= ? AND ts < ?`
|
||||
args := []any{since.Format(tsLayout), until.Format(tsLayout)}
|
||||
if node != "" {
|
||||
q += ` AND node = ?`
|
||||
args = append(args, node)
|
||||
}
|
||||
q += ` ORDER BY ts ASC`
|
||||
return s.scanSamples(ctx, q, args...)
|
||||
}
|
||||
|
||||
func (s *Store) scanSamples(ctx context.Context, q string, args ...any) ([]probes.Result, error) {
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []probes.Result
|
||||
for rows.Next() {
|
||||
var ts string
|
||||
var r probes.Result
|
||||
if err := rows.Scan(&r.Node, &ts, &r.HTTPLatencyMs, &r.TCPRttMs, &r.DNSResolveMs, &r.TLSHandshakeMs, &r.ThroughputKbps); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Timestamp, _ = time.Parse(tsLayout, ts)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bergabruh/stats-gateway/internal/probes"
|
||||
)
|
||||
|
||||
func TestInsertAndQuery(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
r := &probes.Result{
|
||||
HTTPLatencyMs: 42.5,
|
||||
TCPRttMs: 12.0,
|
||||
DNSResolveMs: 3.0,
|
||||
TLSHandshakeMs: 25.0,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
if err := st.InsertSample(ctx, "fl", r); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
samples, err := st.QueryRecent(ctx, "fl", 10, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if len(samples) != 1 {
|
||||
t.Fatalf("expected 1 sample, got %d", len(samples))
|
||||
}
|
||||
if samples[0].HTTPLatencyMs != 42.5 {
|
||||
t.Errorf("expected latency 42.5, got %f", samples[0].HTTPLatencyMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
for i, dt := range []time.Duration{-2 * time.Hour, -1 * time.Hour, 0, 1 * time.Hour} {
|
||||
r := &probes.Result{HTTPLatencyMs: float64(10 + i), Timestamp: now.Add(dt)}
|
||||
if err := st.InsertSample(ctx, "fl", r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := st.QueryRange(ctx, "fl", now.Add(-90*time.Minute), now.Add(30*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// expect 2 samples: -1h and 0
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryRange_SubsecondOrdering verifies fixed-width nanosecond format —
|
||||
// regression guard for RFC3339Nano trailing-zero stripping bug.
|
||||
func TestQueryRange_SubsecondOrdering(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
base := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)
|
||||
// Two timestamps where lexical ordering of RFC3339Nano disagrees with chronology:
|
||||
// t1 = base + 100ms → RFC3339Nano formats as "...:00.1Z"
|
||||
// t2 = base + 100ms + 1ns → RFC3339Nano formats as "...:00.100000001Z"
|
||||
// Lexically ".1Z" > ".100000001Z" because 'Z' > '0', but t1 < t2.
|
||||
t1 := base.Add(100 * time.Millisecond)
|
||||
t2 := base.Add(100*time.Millisecond + time.Nanosecond)
|
||||
for _, ts := range []time.Time{t1, t2} {
|
||||
if err := st.InsertSample(ctx, "fl", &probes.Result{HTTPLatencyMs: 1.0, Timestamp: ts}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Range starting just before t1 must include both samples.
|
||||
got, err := st.QueryRange(ctx, "fl", base.Add(50*time.Millisecond), base.Add(200*time.Millisecond))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 samples in [t-50ms, t+200ms), got %d — lexical ordering bug?", len(got))
|
||||
}
|
||||
// Verify chronological ordering preserved on read.
|
||||
if !got[0].Timestamp.Before(got[1].Timestamp) {
|
||||
t.Errorf("expected got[0] < got[1] chronologically; got[0]=%v got[1]=%v", got[0].Timestamp, got[1].Timestamp)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user