mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 12:28:00 +00:00
SaarMesh-Bot flagged on PR #1867 that the server's one-time detectSchema() PRAGMA snapshot racing the ingestor's ALTER TABLE migrations isn't specific to configured_scope -- it's a general property of every optional column, and offered to send a fix. Built it ourselves instead. hasResolvedPath/hasObsRawHex/hasScopeName/hasDefaultScope/ hasConfiguredScope/hasMultibyteSupCols/hasLastSeen are now schemaFlag (atomic.Bool) methods instead of plain bools, self-healed by a background ticker (healSchemaFlags) started once in OpenDB and stopped in Close(). Unlike an eager reprobe-on-read, the healer never nests inside another caller's already-open *sql.Rows cursor, so it can't self-deadlock a single-connection pool the way a naive "reprobe inside get()" version of this fix did (caught immediately by the existing test suite hanging). detectSchemaWithRetry's fixed 150ms budget is gone -- replaced by an unbounded self-heal that catches the migration whenever it actually lands. Every read site across db.go/store.go/chunked_load.go/main.go is now a method call; test fixtures that previously force-set the old bool fields now call .forceTrue() on the underlying schemaFlag.
181 lines
7.4 KiB
Go
181 lines
7.4 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// createTestDBPathJSONNoResolvedPath builds a fixture that mirrors the LIVE
|
|
// deployment state after #1287: observations carry a path_json hop list but
|
|
// observations.resolved_path is NULL (the ingestor no longer writes it; relay
|
|
// data is persisted as aggregate neighbor_edges instead). A single repeater
|
|
// node whose public_key starts with hopPrefix lets the in-memory prefix map
|
|
// resolve that hop unambiguously to relayPubkey.
|
|
//
|
|
// The transmission's decoded_json is empty ({}), so relayPubkey is NOT an
|
|
// endpoint (pubKey/destPubKey/srcPubKey). The ONLY way it can enter
|
|
// s.byNode is via path_json → resolvePathForObs relay-hop resolution.
|
|
func createTestDBPathJSONNoResolvedPath(t *testing.T, relayPubkey, hopPrefix, firstSeen string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "test.db")
|
|
|
|
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
exec := func(s string, args ...interface{}) {
|
|
if _, err := conn.Exec(s, args...); err != nil {
|
|
t.Fatalf("setup exec failed: %v\nSQL: %s", err, s)
|
|
}
|
|
}
|
|
|
|
// PREFLIGHT: async=true reason="test fixture: in-memory t.TempDir SQLite, never touches a real DB. Tables are CREATE-from-empty in a one-shot OpenDB call, not a schema migration over existing data."
|
|
exec(`CREATE TABLE transmissions (
|
|
id INTEGER PRIMARY KEY,
|
|
raw_hex TEXT, hash TEXT, first_seen TEXT,
|
|
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
|
|
decoded_json TEXT
|
|
)`)
|
|
// resolved_path column present (matches live schema) but left NULL.
|
|
// PREFLIGHT: async=true reason="test fixture, in-memory tmpdir DB"
|
|
exec(`CREATE TABLE observations (
|
|
id INTEGER PRIMARY KEY,
|
|
transmission_id INTEGER,
|
|
observer_id TEXT, observer_name TEXT,
|
|
direction TEXT, snr REAL, rssi REAL, score INTEGER,
|
|
path_json TEXT, timestamp TEXT,
|
|
raw_hex TEXT,
|
|
resolved_path TEXT
|
|
)`)
|
|
// PREFLIGHT: async=true reason="test fixture, in-memory tmpdir DB"
|
|
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
|
// Production nodes schema uses public_key (not pubkey) — getAllNodes /
|
|
// buildPrefixMap reads public_key, role, advert_count, first_seen.
|
|
// PREFLIGHT: async=true reason="test fixture, in-memory tmpdir DB"
|
|
exec(`CREATE TABLE nodes (
|
|
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
|
|
last_seen TEXT, first_seen TEXT, advert_count INTEGER DEFAULT 0
|
|
)`)
|
|
// PREFLIGHT: async=true reason="test fixture, in-memory tmpdir DB"
|
|
exec(`CREATE TABLE schema_version (version INTEGER)`)
|
|
exec(`INSERT INTO schema_version (version) VALUES (1)`)
|
|
// PREFLIGHT: async=true reason="test fixture, in-memory tmpdir DB"
|
|
exec(`CREATE INDEX idx_tx_first_seen ON transmissions(first_seen)`)
|
|
|
|
// Repeater node so canAppearInPath() admits it to the prefix map.
|
|
exec(`INSERT INTO nodes (public_key, name, role, advert_count) VALUES (?,?,?,?)`,
|
|
relayPubkey, "Relay One", "repeater", 10)
|
|
|
|
exec("INSERT INTO transmissions VALUES (?,?,?,?,0,4,1,?)",
|
|
1, "aa", "hashpjf_1", firstSeen, `{}`)
|
|
// resolved_path explicitly NULL; path_json carries the relay hop prefix.
|
|
exec("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp, raw_hex, resolved_path) VALUES (?,?,?,?,?,?,?,?,?,?,?,NULL)",
|
|
1, 1, "obs1", "Obs1", "RX", -10.0, -80.0, 5, fmt.Sprintf(`[%q]`, hopPrefix), firstSeen, "")
|
|
|
|
return dbPath
|
|
}
|
|
|
|
// TestLoadChunked_ResolvesRelayHopsFromPathJSON_WhenResolvedPathEmpty pins the
|
|
// fix for the "relay-node analytics empty after every restart" bug.
|
|
//
|
|
// On live, observations.resolved_path is 100% NULL (since #1287 the ingestor
|
|
// persists relay data as neighbor_edges, not per-observation resolved_path).
|
|
// The cold-load paths (Load / scanAndMergeChunk) indexed relay hops ONLY from
|
|
// resolved_path, so a relay node's path-hop attribution was never rebuilt on
|
|
// startup — it only re-accumulated from live traffic, collapsing the activity
|
|
// timeline to "just the hour the server restarted".
|
|
//
|
|
// The fix: when resolved_path is empty, fall back to resolving the hops from
|
|
// the persisted path_json using the in-memory prefix map + neighbor graph
|
|
// (exactly what the live ingest path already does), then index the relay hops.
|
|
func TestLoadChunked_ResolvesRelayHopsFromPathJSON_WhenResolvedPathEmpty(t *testing.T) {
|
|
relayPK := "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
|
|
hop := "aa" // 2-hex-char path hop; unique 2-char prefix of relayPK
|
|
|
|
ts := time.Now().UTC().Format(time.RFC3339)
|
|
dbPath := createTestDBPathJSONNoResolvedPath(t, relayPK, hop, ts)
|
|
|
|
db, err := OpenDB(dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.conn.Close()
|
|
|
|
if !db.hasResolvedPath() {
|
|
t.Fatalf("setup: fixture should expose resolved_path column; hasResolvedPath=false")
|
|
}
|
|
|
|
store := NewPacketStore(db, &PacketStoreConfig{RetentionHours: 72})
|
|
// Empty graph is sufficient: a single prefix candidate resolves without
|
|
// neighbor-affinity disambiguation. Mirrors a freshly restarted server
|
|
// that has loaded its neighbor_edges snapshot before the packet load.
|
|
store.graph.Store(NewNeighborGraph())
|
|
|
|
if err := store.LoadChunked(0); err != nil {
|
|
t.Fatalf("LoadChunked: %v", err)
|
|
}
|
|
|
|
// The relay pubkey only reachable through path_json resolution must be
|
|
// indexed in byNode for the transmission.
|
|
if got := len(store.byNode[relayPK]); got != 1 {
|
|
t.Errorf("byNode[%s]: got %d transmissions, want 1 — cold load did not "+
|
|
"resolve relay hops from path_json when resolved_path was NULL "+
|
|
"(relay history lost on restart)", relayPK, got)
|
|
}
|
|
}
|
|
|
|
// TestLoadChunk_ResolvesRelayHopsFromPathJSON_WhenResolvedPathEmpty covers the
|
|
// background-window loader (loadBackgroundChunks → loadChunk), which on live
|
|
// loads everything older than hotStartupHours (24h) up to retentionHours
|
|
// (168h). Without the path_json fallback here, a relay node's analytics for
|
|
// the older 6 days would still vanish on every restart even with the hot
|
|
// window fixed.
|
|
func TestLoadChunk_ResolvesRelayHopsFromPathJSON_WhenResolvedPathEmpty(t *testing.T) {
|
|
relayPK := "ccddeeff00112233445566778899aabbccddeeff00112233445566778899aabb"
|
|
hop := "cc"
|
|
|
|
// Aged 48h so it falls in the background window, not the hot window.
|
|
aged := time.Now().UTC().Add(-48 * time.Hour).Format(time.RFC3339)
|
|
dbPath := createTestDBPathJSONNoResolvedPath(t, relayPK, hop, aged)
|
|
|
|
db, err := OpenDB(dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.conn.Close()
|
|
|
|
store := NewPacketStore(db, &PacketStoreConfig{
|
|
RetentionHours: 72,
|
|
HotStartupHours: 1, // hot load must NOT pick up the 48h-old row
|
|
})
|
|
store.graph.Store(NewNeighborGraph())
|
|
|
|
if err := store.LoadChunked(0); err != nil {
|
|
t.Fatalf("LoadChunked: %v", err)
|
|
}
|
|
if got := len(store.byNode[relayPK]); got != 0 {
|
|
t.Fatalf("setup: hot load unexpectedly picked up 48h-old row; "+
|
|
"byNode[relayPK]=%d (want 0) — test would not exercise loadChunk", got)
|
|
}
|
|
|
|
chunkStart := time.Now().UTC().Add(-72 * time.Hour)
|
|
chunkEnd := time.Now().UTC().Add(-1 * time.Hour)
|
|
if err := store.loadChunk(chunkStart, chunkEnd); err != nil {
|
|
t.Fatalf("loadChunk: %v", err)
|
|
}
|
|
|
|
if got := len(store.byNode[relayPK]); got != 1 {
|
|
t.Errorf("byNode[%s]: got %d transmissions, want 1 — background loadChunk "+
|
|
"did not resolve relay hops from path_json when resolved_path was NULL "+
|
|
"(relay history lost on restart for the older retention window)", relayPK, got)
|
|
}
|
|
}
|