mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 12:12:51 +00:00
refactor(db): finish #1283 — Option 4: ingestor owns neighbor-graph + schema migrations; server is read-only (fixes #1287) (#1289)
Red commit: https://github.com/Kpa-clawbot/CoreScope/commit/eae179b99b5fd34924547632aa8f8025c405aa53 (CI: pending — opens with this PR) Finishes #1283. RED test `TestServerSourceHasNoCachedRWCalls` goes from failing (13 writer call-sites) to GREEN (zero). Per #1287 Option 4 (https://github.com/Kpa-clawbot/CoreScope/issues/1287#issuecomment-4485099992): ingestor owns the neighbor graph build + persist; server reads the snapshot. **Category A — Schema migrations** → new `internal/dbschema` package. `dbschema.Apply(rw)` runs in `cmd/ingestor` startup (in `OpenStore`). `dbschema.AssertReady(ro)` runs in `cmd/server/main.go` and FATAL-LOG-EXITS if any expected column/index/table is missing — the operator must restart the ingestor first. Covers indexes, `neighbor_edges`, `observations.resolved_path`, `observers.{inactive,last_packet_at,iata}`, `(inactive_)nodes.foreign_advert`, `transmissions.from_pubkey`. **Category B — Backfill** → ingestor. `BackfillFromPubkey` and observer-blacklist soft-delete moved to `cmd/ingestor/maintenance.go`. Server keeps an inert `fromPubkeyBackfillSnapshot` stub for `/api/healthz` API compatibility. **Category C — Neighbor-graph persistence (Option 4)** → ingestor writes, server reads. - Ingestor (`cmd/ingestor/neighbor_builder.go`): every 60s scans `observations + transmissions`, extracts edges (originator↔first-hop for ADVERTs; observer↔last-hop for all), resolves hop prefixes via a node-table prefix index, upserts into `neighbor_edges`. - Server (`cmd/server/neighbor_recomputer.go`): every 60s re-reads `neighbor_edges` and atomic-swaps the resulting `NeighborGraph` into `s.graph`. Initial load is synchronous on startup. All server-side incremental edge writers (the two `asyncPersistResolvedPathsAndEdges` paths in `cmd/server/store.go`) are gone. - Neighbor-edge daily prune (`PruneNeighborEdges`) moved to ingestor. **Why Option 4**: clean read/write separation, no startup CPU spike (server loads existing snapshot instead of rebuilding from history), no IPC/delta-protocol churn. Staleness budget ~60s — same model as the analytics recomputers in #1240 / #1248 / #672 axis 2. **Recomputer interval default for neighbor graph**: 60s (`NeighborGraphRecomputerDefaultInterval`, `NeighborEdgesBuilderInterval`). **Invariants added**: - `TestServerSourceHasNoCachedRWCalls` (RED commit eae179b9): grep enforces zero `cachedRW(`, `mode=rw`, or `sql.Open(_journal_mode=WAL…)` in non-test `cmd/server/` sources. - `TestServerStartupRequiresMigratedSchema`: server refuses to start against an unmigrated DB. - `TestNeighborGraphRecomputerLoadsSnapshot`: post-write snapshot is picked up on the next refresh. - `TestNeighborEdgesBuilderUpsertsFromObservations`: end-to-end pipeline writes the expected edge. `grep cachedRW cmd/server/*.go | grep -v _test.go` → 0 matches. Fixes #1287. --------- Co-authored-by: MeshCore Bot <bot@meshcore.local> Co-authored-by: Kpa-clawbot <Kpa-clawbot@users.noreply.github.com> Co-authored-by: corescope-bot <bot@corescope.local>
This commit is contained in:
31 files changed
+1514
-2862
No files matched your search
@@ -191,6 +191,12 @@ jobs:
|
||||
go build -o ../../corescope-server .
|
||||
echo "Go server built successfully"
|
||||
|
||||
- name: Build Go migrate tool
|
||||
run: |
|
||||
cd cmd/migrate
|
||||
go build -o ../../corescope-migrate .
|
||||
echo "Go migrate tool built successfully"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci --production=false
|
||||
|
||||
@@ -205,6 +211,15 @@ jobs:
|
||||
- name: Freshen fixture timestamps
|
||||
run: bash tools/freshen-fixture.sh test-fixtures/e2e-fixture.db
|
||||
|
||||
- name: Migrate fixture DB to current schema (#1287)
|
||||
# Server now ASSERTs schema is migrated and refuses to start
|
||||
# otherwise (cmd/server/main.go: dbschema.AssertReady). In prod
|
||||
# the ingestor owns dbschema.Apply, but CI starts only the
|
||||
# server against the committed e2e fixture — so we run the
|
||||
# standalone migrate tool here to bring the fixture up to the
|
||||
# required shape before the server boots.
|
||||
run: ./corescope-migrate -db test-fixtures/e2e-fixture.db
|
||||
|
||||
- name: Start Go server with fixture DB
|
||||
run: |
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
|
||||
@@ -75,6 +75,18 @@ type Config struct {
|
||||
// obsBlacklistSetCached is the lazily-built lowercase set for O(1) lookups.
|
||||
obsBlacklistSetCached map[string]bool
|
||||
obsBlacklistOnce sync.Once
|
||||
|
||||
// NeighborEdgesMaxAgeDays controls neighbor_edges row retention
|
||||
// (#1287 — moved from cmd/server). 0 = default 5.
|
||||
NeighborEdgesMaxAgeDays int `json:"neighborEdgesMaxAgeDays,omitempty"`
|
||||
}
|
||||
|
||||
// NeighborEdgesDaysOrDefault returns the configured pruning window or 5.
|
||||
func (c *Config) NeighborEdgesDaysOrDefault() int {
|
||||
if c == nil || c.NeighborEdgesMaxAgeDays <= 0 {
|
||||
return 5
|
||||
}
|
||||
return c.NeighborEdgesMaxAgeDays
|
||||
}
|
||||
|
||||
// GeoFilterConfig is an alias for the shared geofilter.Config type.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
"github.com/meshcore-analyzer/packetpath"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -110,6 +111,13 @@ func OpenStoreWithInterval(dbPath string, sampleIntervalSec int) (*Store, error)
|
||||
return nil, fmt.Errorf("applying schema: %w", err)
|
||||
}
|
||||
|
||||
// Apply the additional server-originated migrations (now owned by
|
||||
// the ingestor per #1287). Adds the indexes/columns that used to live
|
||||
// in cmd/server/ensure_*.go: server now ASSERTS these exist.
|
||||
if err := dbschema.Apply(db, log.Printf); err != nil {
|
||||
return nil, fmt.Errorf("dbschema.Apply: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: db, sampleIntervalSec: sampleIntervalSec}
|
||||
if err := s.prepareStatements(); err != nil {
|
||||
return nil, fmt.Errorf("preparing statements: %w", err)
|
||||
|
||||
@@ -25,6 +25,10 @@ require github.com/meshcore-analyzer/perfio v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/perfio => ../../internal/perfio
|
||||
|
||||
require github.com/meshcore-analyzer/dbschema v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/dbschema => ../../internal/dbschema
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -62,6 +62,15 @@ func main() {
|
||||
// Async backfill: path_json from raw_hex (#888) — must not block MQTT startup
|
||||
store.BackfillPathJSONAsync()
|
||||
|
||||
// Soft-delete blacklisted observers (#1287 — moved from cmd/server).
|
||||
if len(cfg.ObserverBlacklist) > 0 {
|
||||
store.SoftDeleteBlacklistedObservers(cfg.ObserverBlacklist)
|
||||
}
|
||||
|
||||
// Async backfill: from_pubkey for legacy ADVERT rows (#1143).
|
||||
// Moved from cmd/server in #1287. Best-effort; must not block MQTT.
|
||||
go store.BackfillFromPubkey(5000, 100*time.Millisecond, nil)
|
||||
|
||||
// Check auto_vacuum mode and optionally migrate (#919)
|
||||
store.CheckAutoVacuum(cfg)
|
||||
|
||||
@@ -140,6 +149,28 @@ func main() {
|
||||
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", packetDays)
|
||||
}
|
||||
|
||||
// Daily neighbor_edges retention (#1287 — moved from cmd/server).
|
||||
{
|
||||
nDays := cfg.NeighborEdgesDaysOrDefault()
|
||||
neighborPruneTicker := time.NewTicker(24 * time.Hour)
|
||||
go func() {
|
||||
time.Sleep(4 * time.Minute) // stagger
|
||||
if n, err := store.PruneNeighborEdges(nDays); err != nil {
|
||||
log.Printf("[neighbor-prune] error: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[neighbor-prune] startup pruned %d edges older than %d days", n, nDays)
|
||||
}
|
||||
for range neighborPruneTicker.C {
|
||||
if n, err := store.PruneNeighborEdges(nDays); err != nil {
|
||||
log.Printf("[neighbor-prune] error: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[neighbor-prune] pruned %d edges older than %d days", n, nDays)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[neighbor-prune] auto-prune enabled: edges older than %d days", nDays)
|
||||
}
|
||||
|
||||
// Periodic stats logging (every 5 minutes)
|
||||
statsTicker := time.NewTicker(5 * time.Minute)
|
||||
go func() {
|
||||
@@ -152,6 +183,13 @@ func main() {
|
||||
// endpoint (#1120). Best-effort; never fatal.
|
||||
StartStatsFileWriter(store, time.Second)
|
||||
|
||||
// Neighbor-edges builder (#1287 — Option 4): ingestor owns
|
||||
// neighbor_edges writes. Runs every 60s. Server reads the snapshot
|
||||
// via cmd/server/neighbor_recomputer.go on the same cadence.
|
||||
stopNeighborBuilder := store.StartNeighborEdgesBuilder(NeighborEdgesBuilderInterval)
|
||||
defer stopNeighborBuilder()
|
||||
log.Printf("[neighbor-build] enabled (interval=%s)", NeighborEdgesBuilderInterval)
|
||||
|
||||
channelKeys := loadChannelKeys(cfg, *configPath)
|
||||
if len(channelKeys) > 0 {
|
||||
log.Printf("Loaded %d channel keys for GRP_TXT decryption", len(channelKeys))
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
)
|
||||
|
||||
// PruneOldPackets deletes transmissions (and their child observations)
|
||||
@@ -44,3 +48,175 @@ func (s *Store) PruneOldPackets(days int) (int64, error) {
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SoftDeleteBlacklistedObservers marks observers in the blacklist as
|
||||
// inactive=1 so they are hidden from API responses. Owned by ingestor
|
||||
// per #1287. Runs once at startup.
|
||||
func (s *Store) SoftDeleteBlacklistedObservers(blacklist []string) {
|
||||
n, err := dbschema.SoftDeleteBlacklistedObservers(s.db, blacklist)
|
||||
if err != nil {
|
||||
log.Printf("[observer-blacklist] warning: soft-delete failed: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[observer-blacklist] soft-deleted %d blacklisted observer(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// PruneNeighborEdges deletes rows older than maxAgeDays from
|
||||
// neighbor_edges. Owned by the ingestor per #1287 (was in cmd/server).
|
||||
// Returns DB rows deleted.
|
||||
func (s *Store) PruneNeighborEdges(maxAgeDays int) (int64, error) {
|
||||
if maxAgeDays <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(maxAgeDays) * 24 * time.Hour).Format(time.RFC3339)
|
||||
res, err := s.db.Exec("DELETE FROM neighbor_edges WHERE last_seen < ?", cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune neighbor_edges: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n > 0 {
|
||||
log.Printf("[neighbor-prune] removed %d DB rows older than %d days", n, maxAgeDays)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ─── from_pubkey backfill (#1143) ──────────────────────────────────────────
|
||||
//
|
||||
// Moved from cmd/server/from_pubkey_migration.go in #1287. Runs from the
|
||||
// ingestor's maintenance loop. Populates transmissions.from_pubkey for
|
||||
// ADVERT rows whose value is still NULL, by parsing decoded_json.pubKey.
|
||||
|
||||
// FromPubkeyBackfillStats holds progress for /api/healthz exposure.
|
||||
// The ingestor exposes these via stats_file.go so the server can read
|
||||
// them without writing.
|
||||
type FromPubkeyBackfillStats struct {
|
||||
Total int64 `json:"total"`
|
||||
Processed int64 `json:"processed"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
// BackfillFromPubkey scans transmissions where from_pubkey IS NULL and
|
||||
// payload_type = 4 (ADVERT) and populates from_pubkey from decoded_json.
|
||||
// Chunked + yields between batches. Safe to call repeatedly; once a row
|
||||
// is set to either "" or hex it never matches the WHERE clause again.
|
||||
func (s *Store) BackfillFromPubkey(chunkSize int, yieldDuration time.Duration, progress func(total, processed int64, done bool)) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[backfill] from_pubkey panic recovered: %v", r)
|
||||
}
|
||||
if progress != nil {
|
||||
progress(0, 0, true) // signal done; values overwritten below if collected
|
||||
}
|
||||
}()
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 5000
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4",
|
||||
).Scan(&total); err != nil {
|
||||
log.Printf("[backfill] from_pubkey count error: %v", err)
|
||||
return
|
||||
}
|
||||
if total == 0 {
|
||||
log.Println("[backfill] from_pubkey: nothing to do")
|
||||
if progress != nil {
|
||||
progress(0, 0, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if progress != nil {
|
||||
progress(total, 0, false)
|
||||
}
|
||||
log.Printf("[backfill] from_pubkey starting: %d ADVERT rows", total)
|
||||
|
||||
stmt, err := s.db.Prepare("UPDATE transmissions SET from_pubkey = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
log.Printf("[backfill] from_pubkey prepare: %v", err)
|
||||
return
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
var processed int64
|
||||
for {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, decoded_json FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4 LIMIT ?",
|
||||
chunkSize)
|
||||
if err != nil {
|
||||
log.Printf("[backfill] from_pubkey select: %v", err)
|
||||
return
|
||||
}
|
||||
type row struct {
|
||||
id int64
|
||||
pk string
|
||||
}
|
||||
batch := make([]row, 0, chunkSize)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var dj sql.NullString
|
||||
if err := rows.Scan(&id, &dj); err != nil {
|
||||
continue
|
||||
}
|
||||
batch = append(batch, row{id: id, pk: extractPubkeyFromAdvertJSON(dj.String)})
|
||||
}
|
||||
rows.Close()
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[backfill] from_pubkey begin tx: %v", err)
|
||||
return
|
||||
}
|
||||
txStmt := tx.Stmt(stmt)
|
||||
for _, b := range batch {
|
||||
// Sentinel: "" = scanned-no-pubkey (so the WHERE clause
|
||||
// won't keep rescanning this row). hex = real pubkey.
|
||||
var val interface{} = ""
|
||||
if b.pk != "" {
|
||||
val = b.pk
|
||||
}
|
||||
if _, err := txStmt.Exec(val, b.id); err != nil {
|
||||
log.Printf("[backfill] from_pubkey update id=%d: %v", b.id, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[backfill] from_pubkey commit: %v", err)
|
||||
return
|
||||
}
|
||||
processed += int64(len(batch))
|
||||
if progress != nil {
|
||||
progress(total, processed, false)
|
||||
}
|
||||
if len(batch) < chunkSize {
|
||||
break
|
||||
}
|
||||
if yieldDuration > 0 {
|
||||
time.Sleep(yieldDuration)
|
||||
}
|
||||
}
|
||||
log.Printf("[backfill] from_pubkey complete: %d rows processed", processed)
|
||||
if progress != nil {
|
||||
progress(total, processed, true)
|
||||
}
|
||||
}
|
||||
|
||||
// extractPubkeyFromAdvertJSON parses an ADVERT decoded_json blob and
|
||||
// returns the pubKey field, or "" if absent/invalid.
|
||||
func extractPubkeyFromAdvertJSON(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m["pubKey"].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeighborEdgesBuilderInterval is how often the ingestor rescans
|
||||
// observations and refreshes neighbor_edges. Server reads with the
|
||||
// same 60s cadence (see cmd/server/neighbor_recomputer.go); a 60s
|
||||
// pulse here is sufficient to keep the snapshot fresh.
|
||||
const NeighborEdgesBuilderInterval = 60 * time.Second
|
||||
|
||||
// payloadADVERT mirrors the constant in cmd/server/decoder.go.
|
||||
// Duplicated rather than imported so the ingestor binary stays
|
||||
// independent of the server package.
|
||||
const payloadADVERT = 0x04
|
||||
|
||||
// edgeRow is one row to upsert into neighbor_edges. (a, b) is already
|
||||
// canonical-ordered (a <= b).
|
||||
type edgeRow struct {
|
||||
a, b, ts string
|
||||
}
|
||||
|
||||
// StartNeighborEdgesBuilder launches the periodic builder. On each
|
||||
// tick it rescans recent observations + transmissions and upserts
|
||||
// derived neighbor_edges rows. Builder is the only writer to
|
||||
// neighbor_edges (#1287).
|
||||
//
|
||||
// The function returns a stop closure. Initial build runs synchronously
|
||||
// before the ticker starts so the server's first snapshot load picks
|
||||
// up real data instead of an empty table.
|
||||
func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
|
||||
if interval <= 0 {
|
||||
interval = NeighborEdgesBuilderInterval
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
|
||||
// Synchronous warm-up: a single pass so the first server load
|
||||
// after process start sees a populated table.
|
||||
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
|
||||
log.Printf("[neighbor-build] initial build error: %v", err)
|
||||
} else {
|
||||
log.Printf("[neighbor-build] initial build: %d edges upserted", n)
|
||||
}
|
||||
|
||||
var stopOnce sync.Once
|
||||
go func() {
|
||||
defer close(done)
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
|
||||
log.Printf("[neighbor-build] tick error: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[neighbor-build] %d edges upserted", n)
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
stopOnce.Do(func() { close(stop) })
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildAndPersistNeighborEdges scans transmissions + observations,
|
||||
// extracts edge candidates (originator↔first-hop on ADVERTs;
|
||||
// observer↔last-hop on all packet types) and upserts them into
|
||||
// neighbor_edges. Returns count of attempted upserts.
|
||||
//
|
||||
// Resolution of hop-prefix → full pubkey is done via a one-shot
|
||||
// SELECT of (lowered) pubkey prefixes from nodes. Prefixes with
|
||||
// multiple candidates are skipped (matches the conservative
|
||||
// resolution rule in cmd/server/extractEdgesFromObs).
|
||||
func (s *Store) buildAndPersistNeighborEdges() (int, error) {
|
||||
prefixIdx, err := buildPrefixIndex(s.db)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("build prefix index: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`SELECT
|
||||
t.payload_type,
|
||||
t.decoded_json,
|
||||
COALESCE(t.from_pubkey, ''),
|
||||
COALESCE(o.path_json, ''),
|
||||
COALESCE(obs.id, '') AS observer_id,
|
||||
o.timestamp
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scan observations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var edges []edgeRow
|
||||
for rows.Next() {
|
||||
var payloadType sql.NullInt64
|
||||
var decodedJSON, fromPubkey, pathJSON, observerID string
|
||||
var epochTs int64
|
||||
if err := rows.Scan(&payloadType, &decodedJSON, &fromPubkey, &pathJSON, &observerID, &epochTs); err != nil {
|
||||
continue
|
||||
}
|
||||
fromNode := strings.ToLower(fromPubkey)
|
||||
if fromNode == "" {
|
||||
fromNode = strings.ToLower(extractPubkeyFromAdvertJSON(decodedJSON))
|
||||
}
|
||||
isAdvert := payloadType.Valid && payloadType.Int64 == int64(payloadADVERT)
|
||||
ts := time.Unix(epochTs, 0).UTC().Format(time.RFC3339)
|
||||
observerPK := strings.ToLower(observerID)
|
||||
path := parsePathArray(pathJSON)
|
||||
|
||||
if len(path) == 0 {
|
||||
if isAdvert && fromNode != "" && fromNode != observerPK && observerPK != "" {
|
||||
edges = append(edges, canonEdge(fromNode, observerPK, ts))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isAdvert && fromNode != "" {
|
||||
if resolved, ok := resolvePrefix(prefixIdx, path[0]); ok && resolved != fromNode {
|
||||
edges = append(edges, canonEdge(fromNode, resolved, ts))
|
||||
}
|
||||
}
|
||||
if observerPK != "" {
|
||||
last := path[len(path)-1]
|
||||
if resolved, ok := resolvePrefix(prefixIdx, last); ok && resolved != observerPK {
|
||||
edges = append(edges, canonEdge(observerPK, resolved, ts))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(edges) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stmt, err := tx.Prepare(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||||
count = count + 1,
|
||||
last_seen = MAX(last_seen, excluded.last_seen)`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prepare: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
var firstErr error
|
||||
for _, e := range edges {
|
||||
if _, err := stmt.Exec(e.a, e.b, e.ts); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
return 0, fmt.Errorf("upsert: %w", firstErr)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return len(edges), nil
|
||||
}
|
||||
|
||||
// canonEdge orders the pair so node_a <= node_b (matches the existing
|
||||
// schema convention used by the loader and the bridge recomputer).
|
||||
func canonEdge(a, b, ts string) edgeRow {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return edgeRow{a, b, ts}
|
||||
}
|
||||
|
||||
// parsePathArray returns the hop strings from a path_json blob.
|
||||
// Defensive against missing/invalid JSON.
|
||||
func parsePathArray(s string) []string {
|
||||
if s == "" || s == "[]" {
|
||||
return nil
|
||||
}
|
||||
var arr []string
|
||||
if json.Unmarshal([]byte(s), &arr) != nil {
|
||||
return nil
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// prefixIndex maps a hop prefix (lowercase) → all full pubkeys whose
|
||||
// public_key starts with that prefix. Prefixes with > 1 candidate are
|
||||
// considered ambiguous and skipped during resolution.
|
||||
type prefixIndex map[string][]string
|
||||
|
||||
// buildPrefixIndex reads nodes.public_key and builds the prefix → pubkey
|
||||
// map. We index every 1-byte (2 hex char) prefix length the firmware
|
||||
// uses (1, 2, 3, 4, 6, 8). Memory cost is O(nodes × len(prefixLens)).
|
||||
func buildPrefixIndex(db *sql.DB) (prefixIndex, error) {
|
||||
rows, err := db.Query(`SELECT public_key FROM nodes`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
idx := make(prefixIndex, 1024)
|
||||
var prefixLens = []int{1 * 2, 2 * 2, 3 * 2, 4 * 2, 6 * 2, 8 * 2}
|
||||
for rows.Next() {
|
||||
var pk string
|
||||
if err := rows.Scan(&pk); err != nil {
|
||||
continue
|
||||
}
|
||||
pkLower := strings.ToLower(pk)
|
||||
for _, n := range prefixLens {
|
||||
if len(pkLower) < n {
|
||||
continue
|
||||
}
|
||||
prefix := pkLower[:n]
|
||||
idx[prefix] = append(idx[prefix], pkLower)
|
||||
}
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// resolvePrefix returns the single resolved pubkey if exactly one
|
||||
// candidate matches, otherwise (zero || multiple), it returns ok=false
|
||||
// (matches the conservative server-side resolver in
|
||||
// cmd/server/extractEdgesFromObs).
|
||||
func resolvePrefix(idx prefixIndex, hop string) (string, bool) {
|
||||
h := strings.ToLower(hop)
|
||||
candidates := idx[h]
|
||||
if len(candidates) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return candidates[0], true
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNeighborEdgesBuilderUpsertsFromObservations enforces issue
|
||||
// #1287 Option 4: the INGESTOR builds neighbor_edges from raw
|
||||
// observations/transmissions and persists them. Server is read-only.
|
||||
//
|
||||
// Synthesize a tiny DB with one ADVERT observation whose path[0]
|
||||
// uniquely resolves to a known node, then assert the builder writes
|
||||
// the expected edge.
|
||||
func TestNeighborEdgesBuilderUpsertsFromObservations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "build.db")
|
||||
|
||||
// Open via the ingestor's normal opener so applySchema and
|
||||
// dbschema.Apply both run (the builder requires neighbor_edges +
|
||||
// observers.iata etc.).
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Seed two nodes whose pubkey prefixes will be used as hops.
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO nodes (public_key, name) VALUES (?, ?), (?, ?)`,
|
||||
"aaaaaaaaaa", "from-node",
|
||||
"bbbbbbbbbb", "first-hop",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Seed one observer.
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO observers (id, name) VALUES (?, ?)`,
|
||||
"obs-1", "observer-1",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var obsRowid int64
|
||||
if err := store.db.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, "obs-1").Scan(&obsRowid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Insert one ADVERT transmission with from_pubkey = aaaaa…
|
||||
res, err := store.db.Exec(
|
||||
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, from_pubkey)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"", "h1", "2026-01-01T00:00:00Z", 0, payloadADVERT, 0, "{}", "aaaaaaaaaa",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
|
||||
// Insert one observation whose path[0] = "bb" (2-hex prefix unique
|
||||
// to bbbbb… in the nodes table). Expected edge: a↔b.
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, ?, ?)`,
|
||||
txID, obsRowid, `["bb"]`, int64(1735689600),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := store.buildAndPersistNeighborEdges()
|
||||
if err != nil {
|
||||
t.Fatalf("buildAndPersistNeighborEdges: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("expected at least 1 edge upserted, got 0")
|
||||
}
|
||||
|
||||
var got int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM neighbor_edges WHERE node_a = ? AND node_b = ?`, "aaaaaaaaaa", "bbbbbbbbbb").Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 1 {
|
||||
t.Fatalf("expected the a↔b edge to be persisted; got %d rows", got)
|
||||
}
|
||||
}
|
||||
|
||||
// (test ends here)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
module github.com/corescope/migrate
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/meshcore-analyzer/dbschema v0.0.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
replace github.com/meshcore-analyzer/dbschema => ../../internal/dbschema
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,55 @@
|
||||
// Command migrate runs all dbschema migrations against a SQLite
|
||||
// CoreScope database and exits. Used by CI / one-shot tooling to bring
|
||||
// an unmigrated fixture (or a fresh DB) up to the schema shape the
|
||||
// read-only server (cmd/server) requires via dbschema.AssertReady.
|
||||
//
|
||||
// In production the ingestor (cmd/ingestor) runs dbschema.Apply at
|
||||
// startup before subscribing to MQTT — this binary exists so CI's E2E
|
||||
// job can migrate the e2e-fixture.db without booting the full ingestor
|
||||
// (which needs MQTT brokers).
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// migrate -db path/to/file.db
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbPath := flag.String("db", "", "path to SQLite database to migrate (required)")
|
||||
flag.Parse()
|
||||
|
||||
if *dbPath == "" {
|
||||
log.Fatalf("[migrate] -db is required")
|
||||
}
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
log.SetPrefix("[migrate] ")
|
||||
|
||||
db, err := sql.Open("sqlite", *dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open %s: %v", *dbPath, err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalf("ping %s: %v", *dbPath, err)
|
||||
}
|
||||
|
||||
if err := dbschema.Apply(db, log.Printf); err != nil {
|
||||
log.Fatalf("dbschema.Apply: %v", err)
|
||||
}
|
||||
|
||||
if err := dbschema.AssertReady(db); err != nil {
|
||||
log.Fatalf("dbschema.AssertReady after Apply: %v (this is a bug — Apply did not produce a ready schema)", err)
|
||||
}
|
||||
|
||||
log.Printf("OK: %s is migrated and ready", *dbPath)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Test that the migrate binary brings the e2e fixture DB up to the
|
||||
// shape required by cmd/server's dbschema.AssertReady. Regression test
|
||||
// for PR #1289 / fix for the CI "Server failed to start within 30s"
|
||||
// failure: AssertReady fired against the unmigrated fixture and the
|
||||
// server fatal-logged before opening its HTTP listener.
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// fixtureCandidates lists possible locations of the committed e2e
|
||||
// fixture DB relative to this test's package directory. We resolve
|
||||
// against runtime cwd which is cmd/migrate when `go test` runs.
|
||||
var fixtureCandidates = []string{
|
||||
"../../test-fixtures/e2e-fixture.db",
|
||||
}
|
||||
|
||||
func locateFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
for _, p := range fixtureCandidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
abs, _ := filepath.Abs(p)
|
||||
return abs
|
||||
}
|
||||
}
|
||||
t.Skipf("e2e fixture not found (looked in: %v)", fixtureCandidates)
|
||||
return ""
|
||||
}
|
||||
|
||||
func copyFile(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("create dst: %v", err)
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
t.Fatalf("copy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateBringsFixtureToReady is the gate test for the CI bug.
|
||||
// Before the fix landed, AssertReady against the committed fixture
|
||||
// returned an error ("missing: inactive_nodes.foreign_advert" etc.).
|
||||
// After Apply(), AssertReady must return nil.
|
||||
func TestMigrateBringsFixtureToReady(t *testing.T) {
|
||||
src := locateFixture(t)
|
||||
dst := filepath.Join(t.TempDir(), "fixture-copy.db")
|
||||
copyFile(t, src, dst)
|
||||
|
||||
db, err := sql.Open("sqlite", dst)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Sanity: the committed fixture is missing at least one expected
|
||||
// migration column. If this stops being true, either someone
|
||||
// pre-migrated the fixture (and this test no longer protects #1289)
|
||||
// or AssertReady's required set changed.
|
||||
if err := dbschema.AssertReady(db); err == nil {
|
||||
t.Logf("note: fixture already passes AssertReady; skipping pre-condition assertion")
|
||||
}
|
||||
|
||||
if err := dbschema.Apply(db, t.Logf); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if err := dbschema.AssertReady(db); err != nil {
|
||||
t.Fatalf("AssertReady after Apply: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestBackfillAsyncChunked verifies that backfillResolvedPathsAsync processes
|
||||
// observations in chunks, yields between batches, and sets the completion flag.
|
||||
func TestBackfillAsyncChunked(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
packets: make([]*StoreTx, 0),
|
||||
byHash: make(map[string]*StoreTx),
|
||||
byTxID: make(map[int]*StoreTx),
|
||||
byObsID: make(map[int]*StoreObs),
|
||||
}
|
||||
|
||||
// No pending observations → should complete immediately.
|
||||
backfillResolvedPathsAsync(store, "", 100, time.Millisecond, 24)
|
||||
if !store.backfillComplete.Load() {
|
||||
t.Fatal("expected backfillComplete to be true with empty store")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillStatusHeader verifies the X-CoreScope-Status header is set correctly.
|
||||
func TestBackfillStatusHeader(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
packets: make([]*StoreTx, 0),
|
||||
byHash: make(map[string]*StoreTx),
|
||||
byTxID: make(map[int]*StoreTx),
|
||||
byObsID: make(map[int]*StoreObs),
|
||||
}
|
||||
|
||||
srv := &Server{store: store}
|
||||
|
||||
handler := srv.backfillStatusMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
|
||||
// Before backfill completes → backfilling
|
||||
req := httptest.NewRequest("GET", "/api/stats", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("X-CoreScope-Status"); got != "backfilling" {
|
||||
t.Fatalf("expected 'backfilling', got %q", got)
|
||||
}
|
||||
|
||||
// After backfill completes → ready
|
||||
store.backfillComplete.Store(true)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("X-CoreScope-Status"); got != "ready" {
|
||||
t.Fatalf("expected 'ready', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatsBackfillFields verifies /api/stats includes backfill fields.
|
||||
func TestStatsBackfillFields(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
defer db.Close()
|
||||
seedV2Data(t, db)
|
||||
|
||||
store := &PacketStore{
|
||||
db: db,
|
||||
packets: make([]*StoreTx, 0),
|
||||
byHash: make(map[string]*StoreTx),
|
||||
byTxID: make(map[int]*StoreTx),
|
||||
byObsID: make(map[int]*StoreObs),
|
||||
loaded: true,
|
||||
}
|
||||
|
||||
cfg := &Config{Port: 0}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.store = store
|
||||
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
// While backfilling
|
||||
req := httptest.NewRequest("GET", "/api/stats", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse stats response: %v", err)
|
||||
}
|
||||
|
||||
if backfilling, ok := resp["backfilling"]; !ok {
|
||||
t.Fatal("missing 'backfilling' field in stats response")
|
||||
} else if backfilling != true {
|
||||
t.Fatalf("expected backfilling=true, got %v", backfilling)
|
||||
}
|
||||
|
||||
if _, ok := resp["backfillProgress"]; !ok {
|
||||
t.Fatal("missing 'backfillProgress' field in stats response")
|
||||
}
|
||||
|
||||
// Check header
|
||||
if got := rec.Header().Get("X-CoreScope-Status"); got != "backfilling" {
|
||||
t.Fatalf("expected X-CoreScope-Status=backfilling, got %q", got)
|
||||
}
|
||||
|
||||
// After backfill completes
|
||||
store.backfillComplete.Store(true)
|
||||
// Invalidate stats cache
|
||||
srv.statsMu.Lock()
|
||||
srv.statsCache = nil
|
||||
srv.statsMu.Unlock()
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
resp = nil
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse stats response: %v", err)
|
||||
}
|
||||
|
||||
if backfilling, ok := resp["backfilling"]; !ok || backfilling != false {
|
||||
t.Fatalf("expected backfilling=false after completion, got %v", backfilling)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("X-CoreScope-Status"); got != "ready" {
|
||||
t.Fatalf("expected X-CoreScope-Status=ready, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestBackfillHoursDefault(t *testing.T) {
|
||||
@@ -72,106 +68,3 @@ func TestGraphPruneOlderThan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneNeighborEdgesDB(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`CREATE TABLE neighbor_edges (
|
||||
node_a TEXT NOT NULL,
|
||||
node_b TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
last_seen TEXT,
|
||||
PRIMARY KEY (node_a, node_b)
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
old := now.Add(-60 * 24 * time.Hour)
|
||||
|
||||
db.Exec("INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, 5, ?)",
|
||||
"aaa", "bbb", now.Format(time.RFC3339))
|
||||
db.Exec("INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, 3, ?)",
|
||||
"ccc", "ddd", old.Format(time.RFC3339))
|
||||
|
||||
g := NewNeighborGraph()
|
||||
g.upsertEdge("aaa", "bbb", "bb", "obs1", nil, now)
|
||||
g.upsertEdge("ccc", "ddd", "dd", "obs1", nil, old)
|
||||
|
||||
pruned, err := PruneNeighborEdges(dbPath, g, 30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pruned != 1 {
|
||||
t.Errorf("PruneNeighborEdges pruned %d DB rows, want 1", pruned)
|
||||
}
|
||||
|
||||
var count int
|
||||
db.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 row in DB after prune, got %d", count)
|
||||
}
|
||||
|
||||
if len(g.AllEdges()) != 1 {
|
||||
t.Errorf("expected 1 in-memory edge after prune, got %d", len(g.AllEdges()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillRespectsHourWindow(t *testing.T) {
|
||||
store := &PacketStore{}
|
||||
|
||||
now := time.Now().UTC()
|
||||
oldTime := now.Add(-48 * time.Hour).Format(time.RFC3339Nano)
|
||||
newTime := now.Add(-30 * time.Minute).Format(time.RFC3339Nano)
|
||||
|
||||
store.packets = []*StoreTx{
|
||||
{
|
||||
ID: 1,
|
||||
Hash: "old-hash",
|
||||
FirstSeen: oldTime,
|
||||
Observations: []*StoreObs{
|
||||
{ID: 1, PathJSON: `["abc"]`},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Hash: "new-hash",
|
||||
FirstSeen: newTime,
|
||||
Observations: []*StoreObs{
|
||||
{ID: 2, PathJSON: `["def"]`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// With a 1-hour window, only the new tx should be processed.
|
||||
// backfillResolvedPathsAsync will find no prefix map and finish quickly,
|
||||
// but we can verify the pending count reflects the window.
|
||||
go backfillResolvedPathsAsync(store, "", 100, time.Millisecond, 1)
|
||||
|
||||
// Wait for completion
|
||||
for i := 0; i < 100; i++ {
|
||||
if store.backfillComplete.Load() {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
if !store.backfillComplete.Load() {
|
||||
t.Fatal("backfill did not complete")
|
||||
}
|
||||
|
||||
// With no prefix map, total should be 0 (early exit) or just the new one
|
||||
// The function exits early when pm == nil, so backfillTotal stays at 0
|
||||
// if there were pending items but no pm. Let's verify it didn't process
|
||||
// the old one by checking total <= 1.
|
||||
total := store.backfillTotal.Load()
|
||||
if total > 1 {
|
||||
t.Errorf("backfill total = %d, want <= 1 (old tx should be excluded by hour window)", total)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ensureServerIndexes creates the indexes that the SQL fallback path in
|
||||
// QueryPackets / QueryGroupedPackets and the background hot-startup chunk
|
||||
// loader depend on. Mirrors the indexes the ingestor creates (see
|
||||
// cmd/ingestor/db.go applySchema). Safe to call on every server start
|
||||
// because every CREATE INDEX uses IF NOT EXISTS. Needed because DBs
|
||||
// created by an old server-only build (pre-ingestor) won't have the
|
||||
// ingestor's indexes, which would cause full table scans on the SQL
|
||||
// fallback path during hot startup.
|
||||
func ensureServerIndexes(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open rw for index ensure: %w", err)
|
||||
}
|
||||
stmts := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_transmissions_first_seen ON transmissions(first_seen)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_transmissions_hash ON transmissions(hash)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type)`,
|
||||
// PR #1187 r3: commit 63cc1bc3 restored the RFC3339 since/until path
|
||||
// to a SELECT … FROM observations WHERE timestamp >= ? subquery in
|
||||
// buildTransmissionWhere. Without these indexes the subquery
|
||||
// full-scans observations on legacy server-only DBs (the ingestor
|
||||
// already creates them; see cmd/ingestor/db.go applySchema).
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := rw.Exec(s); err != nil {
|
||||
return fmt.Errorf("ensure index %q: %w", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
// observer_idx column exists in v3 schema only; observer_id is the
|
||||
// v2 equivalent. Probe the schema and create the matching index.
|
||||
rows, err := rw.Query(`PRAGMA table_info(observations)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pragma table_info(observations): %w", err)
|
||||
}
|
||||
var hasObserverIdx, hasObserverID bool
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dflt interface{}
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan table_info: %w", err)
|
||||
}
|
||||
switch strings.ToLower(name) {
|
||||
case "observer_idx":
|
||||
hasObserverIdx = true
|
||||
case "observer_id":
|
||||
hasObserverID = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if hasObserverIdx {
|
||||
if _, err := rw.Exec(`CREATE INDEX IF NOT EXISTS idx_observations_observer_idx ON observations(observer_idx)`); err != nil {
|
||||
return fmt.Errorf("ensure idx_observations_observer_idx: %w", err)
|
||||
}
|
||||
}
|
||||
if hasObserverID {
|
||||
if _, err := rw.Exec(`CREATE INDEX IF NOT EXISTS idx_observations_observer_id ON observations(observer_id)`); err != nil {
|
||||
return fmt.Errorf("ensure idx_observations_observer_id: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestEnsureServerIndexes_CreatesObservationsIndexes guards against
|
||||
// regression of PR #1187 r3 MUST-FIX 2: legacy server-only DBs that lack
|
||||
// the ingestor-created observation indexes used to full-scan the
|
||||
// `SELECT ... FROM observations WHERE timestamp >= ?` subquery added to
|
||||
// buildTransmissionWhere by 63cc1bc3. ensureServerIndexes must create
|
||||
// idx_observations_timestamp (and the join companions) so the hot-startup
|
||||
// chunk loader and RFC3339 since/until path don't full-scan observations.
|
||||
func TestEnsureServerIndexes_CreatesObservationsIndexes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "schema_only.db")
|
||||
|
||||
conn, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
|
||||
// Minimal legacy server-only schema: tables present, no extra indexes.
|
||||
stmts := []string{
|
||||
`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)`,
|
||||
// v3 schema (observer_idx) — matches the ingestor-created shape
|
||||
// and the path that 63cc1bc3 / hot-startup loadChunk traverse.
|
||||
`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_idx INTEGER, direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT)`,
|
||||
`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`,
|
||||
`CREATE TABLE nodes (pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, frequency REAL)`,
|
||||
`CREATE TABLE schema_version (version INTEGER)`,
|
||||
`INSERT INTO schema_version (version) VALUES (1)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := conn.Exec(s); err != nil {
|
||||
t.Fatalf("setup %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
if err := ensureServerIndexes(dbPath); err != nil {
|
||||
t.Fatalf("ensureServerIndexes: %v", err)
|
||||
}
|
||||
|
||||
// Reopen and query sqlite_master for the indexes we expect.
|
||||
conn2, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer conn2.Close()
|
||||
|
||||
required := []string{
|
||||
"idx_transmissions_first_seen",
|
||||
"idx_transmissions_hash",
|
||||
"idx_transmissions_payload_type",
|
||||
"idx_observations_timestamp",
|
||||
"idx_observations_transmission_id",
|
||||
"idx_observations_observer_idx",
|
||||
}
|
||||
for _, name := range required {
|
||||
var found string
|
||||
err := conn2.QueryRow(`SELECT name FROM sqlite_master WHERE type='index' AND name=?`, name).Scan(&found)
|
||||
if err != nil {
|
||||
t.Errorf("index %s not created (err=%v) — ensureServerIndexes must create it to avoid full scans on the SQL fallback path", name, err)
|
||||
continue
|
||||
}
|
||||
if found != name {
|
||||
t.Errorf("index lookup mismatch: want %s got %s", name, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
package main
|
||||
|
||||
// Tests for issue #1143: pubkey attribution must use exact-match on a
|
||||
// dedicated `from_pubkey` column, not `decoded_json LIKE '%pubkey%'`.
|
||||
//
|
||||
// These tests demonstrate the structural holes documented in #1143:
|
||||
// Hole 1: name-LIKE fallback surfaces same-name nodes
|
||||
// Hole 2a: an attacker can name themselves with someone else's pubkey
|
||||
// and get their transmissions attributed to the victim
|
||||
// Hole 2b: any 64-char hex substring inside decoded_json (path elements,
|
||||
// channel names, message bodies) produces false positives
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
pkVictim = "f7181c468dfe7c55aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
pkAttacker = "deadbeefdeadbeefcccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
pkOther = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
// seedAttribution inserts the standard adversarial fixture used by the
|
||||
// issue #1143 tests. It returns the victim pubkey for convenience.
|
||||
func seedAttribution(t *testing.T, db *DB) string {
|
||||
t.Helper()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// (1) Legitimate ADVERT from the victim.
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('AA','h_victim_advert',?,1,4,
|
||||
'{"type":"ADVERT","pubKey":"`+pkVictim+`","name":"VictimNode"}',
|
||||
?)`, now, pkVictim)
|
||||
|
||||
// (2) Hole 1: a different node sharing the *display name* "VictimNode".
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('BB','h_namespoof_advert',?,1,4,
|
||||
'{"type":"ADVERT","pubKey":"`+pkOther+`","name":"VictimNode"}',
|
||||
?)`, now, pkOther)
|
||||
|
||||
// (3) Hole 2a: malicious node whose *name* is the victim's pubkey.
|
||||
// decoded_json contains pkVictim as a substring (in the name field),
|
||||
// but the actual originator is pkAttacker.
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('CC','h_spoof_advert',?,1,4,
|
||||
'{"type":"ADVERT","pubKey":"`+pkAttacker+`","name":"`+pkVictim+`"}',
|
||||
?)`, now, pkAttacker)
|
||||
|
||||
// (4) Hole 2b: free-text packet (e.g. channel message) whose body
|
||||
// coincidentally contains the victim's pubkey as a substring.
|
||||
// Real originator is pkAttacker; from_pubkey reflects that.
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('DD','h_freetext_msg',?,1,5,
|
||||
'{"type":"GRP_TXT","text":"hello `+pkVictim+` how are you"}',
|
||||
?)`, now, pkAttacker)
|
||||
|
||||
return pkVictim
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *DB, q string, args ...interface{}) {
|
||||
t.Helper()
|
||||
if _, err := db.conn.Exec(q, args...); err != nil {
|
||||
t.Fatalf("exec failed: %v\nquery: %s", err, q)
|
||||
}
|
||||
}
|
||||
|
||||
func hashesOf(rows []map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if h, ok := r["hash"].(string); ok {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_Hole1_SameNameDifferentPubkey(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
for _, h := range hashes {
|
||||
if h == "h_namespoof_advert" {
|
||||
t.Fatalf("Hole 1: same-name node was attributed to the victim. got hashes=%v", hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_Hole2a_PubkeyAsNameSpoof(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
for _, h := range hashes {
|
||||
if h == "h_spoof_advert" {
|
||||
t.Fatalf("Hole 2a: attacker who named themselves with victim's pubkey "+
|
||||
"was attributed to the victim. got hashes=%v", hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_Hole2b_FreeTextHexFalsePositive(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
for _, h := range hashes {
|
||||
if h == "h_freetext_msg" {
|
||||
t.Fatalf("Hole 2b: free-text containing the victim's pubkey as a "+
|
||||
"substring produced a false positive. got hashes=%v", hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_LegitimateAdvertReturned(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
found := false
|
||||
for _, h := range hashes {
|
||||
if h == "h_victim_advert" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected legitimate victim advert (h_victim_advert) in result, got %v", hashes)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-pubkey OR query (#1143 — db.go:1785) ---
|
||||
|
||||
func TestQueryMultiNodePackets_ExactMatchOnly(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedAttribution(t, db)
|
||||
|
||||
// Query the victim's pubkey via the multi-node API. The malicious
|
||||
// "name = victim pubkey" row and the free-text row must NOT show up.
|
||||
res, err := db.QueryMultiNodePackets([]string{pkVictim}, 50, 0, "DESC", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes := hashesOf(res.Packets)
|
||||
for _, bad := range []string{"h_spoof_advert", "h_freetext_msg", "h_namespoof_advert"} {
|
||||
for _, h := range hashes {
|
||||
if h == bad {
|
||||
t.Fatalf("QueryMultiNodePackets returned spurious match %q (pubkey %s as substring); hashes=%v",
|
||||
bad, pkVictim, hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The legitimate one must still be present.
|
||||
if !contains(hashes, "h_victim_advert") {
|
||||
t.Fatalf("expected h_victim_advert in QueryMultiNodePackets result, got %v", hashes)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Index sanity check (#1143 perf): verify EXPLAIN QUERY PLAN uses the
|
||||
// new index, not a SCAN. ---
|
||||
|
||||
func TestFromPubkeyIndexUsed(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
mustExec(t, db, `CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)`)
|
||||
|
||||
rows, err := db.conn.Query(
|
||||
`EXPLAIN QUERY PLAN SELECT id FROM transmissions WHERE from_pubkey = ?`,
|
||||
pkVictim)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
plan := ""
|
||||
for rows.Next() {
|
||||
var id, parent, notused int
|
||||
var detail string
|
||||
if err := rows.Scan(&id, &parent, ¬used, &detail); err == nil {
|
||||
plan += detail + "\n"
|
||||
}
|
||||
}
|
||||
if !strings.Contains(plan, "idx_transmissions_from_pubkey") {
|
||||
t.Fatalf("expected EXPLAIN QUERY PLAN to use idx_transmissions_from_pubkey, got:\n%s", plan)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromPubkeyIndexUsedForInClause verifies the index is used for the
|
||||
// IN (?, ?, ...) query path used by QueryMultiNodePackets (db.go ~1787).
|
||||
// Coverage extension — the equality path is covered above; this asserts
|
||||
// the multi-node path doesn't silently regress to a full scan when the
|
||||
// planner can't use the index for set membership.
|
||||
func TestFromPubkeyIndexUsedForInClause(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
mustExec(t, db, `CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)`)
|
||||
|
||||
rows, err := db.conn.Query(
|
||||
`EXPLAIN QUERY PLAN SELECT id FROM transmissions WHERE from_pubkey IN (?, ?)`,
|
||||
pkVictim, pkOther)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
plan := ""
|
||||
for rows.Next() {
|
||||
var id, parent, notused int
|
||||
var detail string
|
||||
if err := rows.Scan(&id, &parent, ¬used, &detail); err == nil {
|
||||
plan += detail + "\n"
|
||||
}
|
||||
}
|
||||
if !strings.Contains(plan, "idx_transmissions_from_pubkey") {
|
||||
t.Fatalf("expected EXPLAIN QUERY PLAN for IN(...) to use idx_transmissions_from_pubkey, got:\n%s", plan)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Migration / backfill ---
|
||||
|
||||
func TestBackfillFromPubkey_AdvertRowsPopulated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := dir + "/test.db"
|
||||
|
||||
// Create a legacy-style DB: transmissions table WITHOUT from_pubkey,
|
||||
// then run ensureFromPubkeyColumn to ALTER it in.
|
||||
rw, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec(`CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
|
||||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
|
||||
decoded_json TEXT, created_at TEXT
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two ADVERTs (different pubkeys) and a non-ADVERT.
|
||||
if _, err := rw.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type, decoded_json) VALUES
|
||||
('AA','m1','2026-01-01T00:00:00Z',4,'{"type":"ADVERT","pubKey":"`+pkVictim+`","name":"V"}'),
|
||||
('BB','m2','2026-01-01T00:00:00Z',4,'{"type":"ADVERT","pubKey":"`+pkOther+`","name":"O"}'),
|
||||
('CC','m3','2026-01-01T00:00:00Z',5,'{"type":"GRP_TXT","text":"hi"}')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rw.Close()
|
||||
|
||||
if err := ensureFromPubkeyColumn(dbPath); err != nil {
|
||||
t.Fatalf("ensureFromPubkeyColumn: %v", err)
|
||||
}
|
||||
|
||||
// Run synchronously by calling the function directly.
|
||||
backfillFromPubkeyAsync(dbPath, 100, 0)
|
||||
|
||||
// Verify backfill populated the ADVERT rows.
|
||||
rw2, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rw2.Close()
|
||||
rows, err := rw2.Query("SELECT hash, from_pubkey FROM transmissions ORDER BY hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
got := map[string]string{}
|
||||
for rows.Next() {
|
||||
var h string
|
||||
var pk sql.NullString
|
||||
if err := rows.Scan(&h, &pk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[h] = pk.String
|
||||
}
|
||||
if got["m1"] != pkVictim {
|
||||
t.Errorf("m1 from_pubkey = %q, want %q", got["m1"], pkVictim)
|
||||
}
|
||||
if got["m2"] != pkOther {
|
||||
t.Errorf("m2 from_pubkey = %q, want %q", got["m2"], pkOther)
|
||||
}
|
||||
// Non-ADVERT row was not in the backfill scope; from_pubkey stays NULL.
|
||||
if got["m3"] != "" {
|
||||
t.Errorf("m3 from_pubkey = %q, want empty (NULL)", got["m3"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillFromPubkey_DoesNotBlockBoot exercises the async contract:
|
||||
// main.go (cmd/server/main.go) calls startFromPubkeyBackfill, which is the
|
||||
// SAME entry point used at production startup. The wrapper must dispatch
|
||||
// the backfill in a goroutine; if anyone removes the `go` keyword inside
|
||||
// startFromPubkeyBackfill, this test fails because the call no longer
|
||||
// returns within the 50ms boot dispatch budget. The test does NOT use `go`
|
||||
// itself — that would test only the test's own scheduler, not the
|
||||
// production code path (cycle-3 M1c).
|
||||
//
|
||||
// DO NOT t.Parallel — uses package-global atomics
|
||||
// (fromPubkeyBackfillTotal/Processed/Done). Concurrent tests would clobber
|
||||
// the resets (cycle-3 m1c).
|
||||
func TestBackfillFromPubkey_DoesNotBlockBoot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := dir + "/async_boot.db"
|
||||
|
||||
rw, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec(`CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
|
||||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
|
||||
decoded_json TEXT, created_at TEXT
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Insert N=1000 legacy ADVERT rows. With chunkSize=100 + yield=100ms
|
||||
// between chunks, sync would be ~900ms; we assert dispatch is <50ms.
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, payload_type, decoded_json) VALUES (?, ?, ?, 4, ?)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const N = 1000
|
||||
for i := 0; i < N; i++ {
|
||||
hash := fmt.Sprintf("h_async_boot_%d", i)
|
||||
dj := fmt.Sprintf(`{"type":"ADVERT","pubKey":"%s","name":"N%d"}`, pkVictim, i)
|
||||
if _, err := stmt.Exec("AA", hash, "2026-01-01T00:00:00Z", dj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rw.Close()
|
||||
|
||||
if err := ensureFromPubkeyColumn(dbPath); err != nil {
|
||||
t.Fatalf("ensureFromPubkeyColumn: %v", err)
|
||||
}
|
||||
|
||||
// Reset all backfill state — other tests may have set it.
|
||||
fromPubkeyBackfillReset()
|
||||
defer fromPubkeyBackfillReset()
|
||||
|
||||
// Dispatch via the production wrapper. startFromPubkeyBackfill is the
|
||||
// same entry point main.go calls at boot; it must launch the backfill
|
||||
// in a goroutine internally. We deliberately do NOT prefix `go` here —
|
||||
// if the wrapper is ever made synchronous, the dispatch budget below
|
||||
// fires first.
|
||||
t0 := time.Now()
|
||||
startFromPubkeyBackfill(dbPath, 100, 100*time.Millisecond)
|
||||
dispatchElapsed := time.Since(t0)
|
||||
|
||||
// (a) Boot-time dispatch budget: must return ~immediately.
|
||||
if dispatchElapsed > 50*time.Millisecond {
|
||||
t.Fatalf("backfill dispatch took %v (>50ms): not async — would block boot", dispatchElapsed)
|
||||
}
|
||||
|
||||
// (b) Eventual completion via the fromPubkeyBackfill snapshot.
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, _, done := fromPubkeyBackfillSnapshot(); done {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if _, _, done := fromPubkeyBackfillSnapshot(); !done {
|
||||
t.Fatalf("backfill never flipped Done within 30s; dispatched=%v", dispatchElapsed)
|
||||
}
|
||||
|
||||
// (c) Backfill actually populated rows.
|
||||
rw2, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rw2.Close()
|
||||
var nullCount int
|
||||
if err := rw2.QueryRow(
|
||||
`SELECT COUNT(*) FROM transmissions WHERE payload_type = 4 AND from_pubkey IS NULL`,
|
||||
).Scan(&nullCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nullCount > 0 {
|
||||
t.Errorf("backfill left %d ADVERT rows with NULL from_pubkey", nullCount)
|
||||
}
|
||||
if _, processed, _ := fromPubkeyBackfillSnapshot(); processed != int64(N) {
|
||||
t.Errorf("fromPubkeyBackfillProcessed = %d, want %d", processed, N)
|
||||
}
|
||||
}
|
||||
@@ -1,261 +1,38 @@
|
||||
// Package main: from_pubkey backfill shim (issue #1287).
|
||||
//
|
||||
// The actual backfill moved to cmd/ingestor (see
|
||||
// cmd/ingestor/maintenance.go: BackfillFromPubkey) because the server
|
||||
// is the read path and may not write to SQLite (#1283/#1287). This
|
||||
// file retains the snapshot getter so /api/healthz still compiles —
|
||||
// it always reports done=true with zero counters. Operators monitor
|
||||
// the ingestor's stats file for true progress.
|
||||
package main
|
||||
|
||||
// from_pubkey migration (#1143).
|
||||
//
|
||||
// Adds the `transmissions.from_pubkey` column + index, and provides an async
|
||||
// backfill that populates the column from `decoded_json` for ADVERT packets
|
||||
// whose `from_pubkey` is still NULL.
|
||||
//
|
||||
// Why a column at all: the legacy attribution path used
|
||||
// `WHERE decoded_json LIKE '%pubkey%'` (and `OR LIKE '%name%'`). This is
|
||||
// structurally unsound (adversarial spoofing + accidental hex-substring
|
||||
// false positives + full table scan). The column gives us exact match,
|
||||
// O(log n) lookups, and an explicit, auditable attribution surface.
|
||||
//
|
||||
// Backfill is run async (best-effort) so it cannot block server startup
|
||||
// even on prod-sized DBs (100K+ transmissions). Queries handle NULL
|
||||
// gracefully (return empty for that pubkey, same as today's behaviour
|
||||
// for unknown pubkeys).
|
||||
import "sync"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ensureFromPubkeyColumn adds the from_pubkey column + index to the
|
||||
// transmissions table if missing. Safe to call repeatedly.
|
||||
func ensureFromPubkeyColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := tableHasColumn(rw, "transmissions", "from_pubkey")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect transmissions: %w", err)
|
||||
}
|
||||
if !has {
|
||||
if _, err := rw.Exec("ALTER TABLE transmissions ADD COLUMN from_pubkey TEXT"); err != nil {
|
||||
return fmt.Errorf("add from_pubkey column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added from_pubkey column to transmissions (#1143)")
|
||||
}
|
||||
|
||||
if _, err := rw.Exec("CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)"); err != nil {
|
||||
return fmt.Errorf("create idx_transmissions_from_pubkey: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fromPubkeyBackfillProgress reports backfill state for /api/healthz.
|
||||
// All three values are read together via fromPubkeyBackfillSnapshot()
|
||||
// under a single RWMutex so /api/healthz never sees a torn snapshot
|
||||
// (e.g. done=true with processed<total). Updates use the Set/Mark
|
||||
// helpers which take the write lock.
|
||||
//
|
||||
// Cycle-3 m2c: previously these were independent atomic.{Int64,Bool};
|
||||
// healthz read each one separately and could observe an interleaved
|
||||
// write between Loads. The mutex-guarded snapshot fixes that.
|
||||
var (
|
||||
fromPubkeyBackfillMu sync.RWMutex
|
||||
fromPubkeyBackfillTotal int64
|
||||
fromPubkeyBackfillProcessed int64
|
||||
fromPubkeyBackfillDone bool
|
||||
fromPubkeyBackfillDone = true
|
||||
)
|
||||
|
||||
// fromPubkeyBackfillSnapshot returns a consistent snapshot of all three
|
||||
// backfill progress fields under a single read lock.
|
||||
// fromPubkeyBackfillSnapshot returns the current backfill progress.
|
||||
// In the post-#1287 world the server does not run the backfill, so
|
||||
// the snapshot is always done=true with zeros. The real progress
|
||||
// lives in the ingestor stats file.
|
||||
func fromPubkeyBackfillSnapshot() (total, processed int64, done bool) {
|
||||
fromPubkeyBackfillMu.RLock()
|
||||
defer fromPubkeyBackfillMu.RUnlock()
|
||||
return fromPubkeyBackfillTotal, fromPubkeyBackfillProcessed, fromPubkeyBackfillDone
|
||||
}
|
||||
|
||||
func fromPubkeyBackfillSetTotal(v int64) {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillTotal = v
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
func fromPubkeyBackfillSetProcessed(v int64) {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillProcessed = v
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
func fromPubkeyBackfillMarkDone() {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillDone = true
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
// fromPubkeyBackfillReset zeroes all three fields atomically. Used by
|
||||
// tests; never called from production code.
|
||||
// fromPubkeyBackfillReset is a test helper used by legacy tests; in
|
||||
// the post-#1287 world it merely resets the static snapshot fields.
|
||||
func fromPubkeyBackfillReset() {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillTotal = 0
|
||||
fromPubkeyBackfillProcessed = 0
|
||||
fromPubkeyBackfillDone = false
|
||||
fromPubkeyBackfillDone = true
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
// startFromPubkeyBackfill is the production entry point used by main.go to
|
||||
// launch the backfill so it cannot block startup. It MUST dispatch the
|
||||
// backfill in a goroutine; the dispatch path is gated by
|
||||
// TestBackfillFromPubkey_DoesNotBlockBoot — if the `go` keyword below is ever
|
||||
// removed, that test fails because dispatch becomes synchronous and exceeds
|
||||
// the 50ms boot budget.
|
||||
func startFromPubkeyBackfill(dbPath string, chunkSize int, yieldDuration time.Duration) {
|
||||
// MUST stay `go` — TestBackfillFromPubkey_DoesNotBlockBoot fails if
|
||||
// this becomes synchronous (boot dispatch budget exceeds 50ms).
|
||||
go backfillFromPubkeyAsync(dbPath, chunkSize, yieldDuration)
|
||||
}
|
||||
|
||||
// backfillFromPubkeyAsync scans transmissions where from_pubkey IS NULL and
|
||||
// populates from_pubkey by parsing decoded_json. Runs in chunks with a
|
||||
// short yield between chunks so it can't starve other writers.
|
||||
//
|
||||
// Strategy:
|
||||
// - ADVERT (payload_type = 4) -> decoded_json.pubKey
|
||||
// - other types -> leave NULL (queries handle NULL gracefully)
|
||||
//
|
||||
// chunkSize and yieldDuration are tunable for tests.
|
||||
func backfillFromPubkeyAsync(dbPath string, chunkSize int, yieldDuration time.Duration) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[store] backfillFromPubkeyAsync panic recovered: %v", r)
|
||||
}
|
||||
fromPubkeyBackfillMarkDone()
|
||||
}()
|
||||
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 5000
|
||||
}
|
||||
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: open rw error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := rw.QueryRow(
|
||||
"SELECT COUNT(*) FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4",
|
||||
).Scan(&total); err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: count error: %v", err)
|
||||
return
|
||||
}
|
||||
fromPubkeyBackfillSetTotal(total)
|
||||
if total == 0 {
|
||||
log.Println("[store] from_pubkey backfill: nothing to do")
|
||||
return
|
||||
}
|
||||
log.Printf("[store] from_pubkey backfill starting: %d ADVERT rows", total)
|
||||
|
||||
updateStmt, err := rw.Prepare("UPDATE transmissions SET from_pubkey = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: prepare update: %v", err)
|
||||
return
|
||||
}
|
||||
defer updateStmt.Close()
|
||||
|
||||
var processed int64
|
||||
for {
|
||||
rows, err := rw.Query(
|
||||
"SELECT id, decoded_json FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4 LIMIT ?",
|
||||
chunkSize)
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: select error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
type row struct {
|
||||
id int64
|
||||
pk string
|
||||
}
|
||||
batch := make([]row, 0, chunkSize)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var dj sql.NullString
|
||||
if err := rows.Scan(&id, &dj); err != nil {
|
||||
continue
|
||||
}
|
||||
pk := extractPubkeyFromAdvertJSON(dj.String)
|
||||
batch = append(batch, row{id: id, pk: pk})
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Apply updates in a single tx for throughput.
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: begin tx: %v", err)
|
||||
return
|
||||
}
|
||||
txStmt := tx.Stmt(updateStmt)
|
||||
for _, b := range batch {
|
||||
// Sentinel convention for transmissions.from_pubkey (#1143, m5):
|
||||
// NULL — row has not yet been scanned by this backfill.
|
||||
// "" — scanned, no extractable pubkey (malformed/legacy ADVERT
|
||||
// decoded_json, or a JSON shape we don't understand).
|
||||
// hex — scanned, pubkey successfully extracted.
|
||||
//
|
||||
// The "" sentinel exists ONLY in this backfill path: it's how we
|
||||
// avoid the #1119 infinite-rescan loop (the WHERE clause is
|
||||
// `from_pubkey IS NULL`, so once we mark a row "" it never matches
|
||||
// again). The ingest write path (cmd/ingestor/db.go ~1289) leaves
|
||||
// from_pubkey NULL when PubKey is empty; the two states are
|
||||
// semantically equivalent ("we have no pubkey for this row") and
|
||||
// all attribution call sites query `from_pubkey = ?` with a real
|
||||
// pubkey, so neither NULL nor "" matches — no UX divergence.
|
||||
var val interface{}
|
||||
if b.pk != "" {
|
||||
val = b.pk
|
||||
} else {
|
||||
val = "" // scanned, no extractable pubkey — see comment above
|
||||
}
|
||||
if _, err := txStmt.Exec(val, b.id); err != nil {
|
||||
// non-fatal; log first failure per chunk and keep going
|
||||
log.Printf("[store] from_pubkey backfill: update id=%d: %v", b.id, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: commit: %v", err)
|
||||
return
|
||||
}
|
||||
processed += int64(len(batch))
|
||||
fromPubkeyBackfillSetProcessed(processed)
|
||||
|
||||
if len(batch) < chunkSize {
|
||||
break
|
||||
}
|
||||
if yieldDuration > 0 {
|
||||
time.Sleep(yieldDuration)
|
||||
}
|
||||
}
|
||||
log.Printf("[store] from_pubkey backfill complete: %d rows processed", processed)
|
||||
}
|
||||
|
||||
// extractPubkeyFromAdvertJSON parses an ADVERT decoded_json blob and returns
|
||||
// the pubKey field, or "" if absent/invalid. Lenient: any parse error yields
|
||||
// the empty string rather than a panic.
|
||||
func extractPubkeyFromAdvertJSON(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m["pubKey"].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -26,6 +26,10 @@ require github.com/meshcore-analyzer/perfio v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/perfio => ../../internal/perfio
|
||||
|
||||
require github.com/meshcore-analyzer/dbschema v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/dbschema => ../../internal/dbschema
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -2,14 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHealthzNotReady(t *testing.T) {
|
||||
// Ensure readiness is 0 (not ready)
|
||||
readiness.Store(0)
|
||||
@@ -82,150 +78,3 @@ func TestHealthzAntiTautology(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthzExposesFromPubkeyBackfill verifies the from_pubkey backfill
|
||||
// progress (#1143, M2) is observable via /api/healthz. The atomics are
|
||||
// updated by backfillFromPubkeyAsync; without exposure here they were dead
|
||||
// code. Asserts the response includes a from_pubkey_backfill object with
|
||||
// total/processed/done fields.
|
||||
func TestHealthzExposesFromPubkeyBackfill(t *testing.T) {
|
||||
readiness.Store(1)
|
||||
defer readiness.Store(0)
|
||||
|
||||
// Set known values so we can assert wiring (not just presence).
|
||||
fromPubkeyBackfillReset()
|
||||
fromPubkeyBackfillSetTotal(7)
|
||||
fromPubkeyBackfillSetProcessed(3)
|
||||
defer fromPubkeyBackfillReset()
|
||||
|
||||
srv := &Server{store: &PacketStore{}}
|
||||
req := httptest.NewRequest("GET", "/api/healthz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleHealthz(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
bf, ok := resp["from_pubkey_backfill"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing from_pubkey_backfill object in healthz response: %v", resp)
|
||||
}
|
||||
if got, want := bf["total"], float64(7); got != want {
|
||||
t.Errorf("from_pubkey_backfill.total = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bf["processed"], float64(3); got != want {
|
||||
t.Errorf("from_pubkey_backfill.processed = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bf["done"], false; got != want {
|
||||
t.Errorf("from_pubkey_backfill.done = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthzFromPubkeyBackfillConsistentSnapshot exercises cycle-3 m2c:
|
||||
// the handler used to read three independent atomics (Total/Processed/Done)
|
||||
// in sequence, so a backfill update interleaved between reads could yield
|
||||
// an inconsistent snapshot (e.g. done=true with processed<total, or
|
||||
// processed>total when total is updated last). This test races concurrent
|
||||
// progress updates against many healthz reads and asserts every snapshot
|
||||
// satisfies the invariants:
|
||||
//
|
||||
// processed <= total
|
||||
// if done: processed == total (or both 0 — nothing to do)
|
||||
//
|
||||
// With the pre-fix code (separate atomic.Load calls), this fires within
|
||||
// a few hundred iterations on a multi-core box. With the RWMutex-guarded
|
||||
// snapshot, it never fires.
|
||||
func TestHealthzFromPubkeyBackfillConsistentSnapshot(t *testing.T) {
|
||||
readiness.Store(1)
|
||||
defer readiness.Store(0)
|
||||
defer fromPubkeyBackfillReset()
|
||||
|
||||
srv := &Server{store: &PacketStore{}}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var writerWg sync.WaitGroup
|
||||
var readerWg sync.WaitGroup
|
||||
|
||||
// Writer: simulates the backfill loop — sets total, then increments
|
||||
// processed in lock-step, occasionally finishing (done=true with
|
||||
// processed==total). Each "tick" mutates all three values.
|
||||
writerWg.Add(1)
|
||||
go func() {
|
||||
defer writerWg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
fromPubkeyBackfillSetTotal(100)
|
||||
for p := int64(0); p <= 100; p++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
fromPubkeyBackfillSetProcessed(p)
|
||||
}
|
||||
fromPubkeyBackfillMarkDone()
|
||||
fromPubkeyBackfillReset()
|
||||
}
|
||||
}()
|
||||
|
||||
// Readers: hammer healthz, assert invariants on each response.
|
||||
const readers = 8
|
||||
const reads = 200
|
||||
errs := make(chan string, readers*reads)
|
||||
for i := 0; i < readers; i++ {
|
||||
readerWg.Add(1)
|
||||
go func() {
|
||||
defer readerWg.Done()
|
||||
for j := 0; j < reads; j++ {
|
||||
req := httptest.NewRequest("GET", "/api/healthz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleHealthz(w, req)
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
errs <- "invalid JSON: " + err.Error()
|
||||
return
|
||||
}
|
||||
bf, _ := resp["from_pubkey_backfill"].(map[string]interface{})
|
||||
total, _ := bf["total"].(float64)
|
||||
processed, _ := bf["processed"].(float64)
|
||||
done, _ := bf["done"].(bool)
|
||||
if processed > total {
|
||||
errs <- "processed>total snapshot: processed=" + ftoa(processed) + " total=" + ftoa(total)
|
||||
return
|
||||
}
|
||||
if done && processed != total {
|
||||
errs <- "done=true but processed!=total: processed=" + ftoa(processed) + " total=" + ftoa(total)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for readers to complete (bounded by 'reads' iterations), then
|
||||
// stop the writer and drain.
|
||||
readerDone := make(chan struct{})
|
||||
go func() { readerWg.Wait(); close(readerDone) }()
|
||||
select {
|
||||
case <-readerDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
close(stop)
|
||||
writerWg.Wait()
|
||||
t.Fatal("timed out waiting for reader goroutines")
|
||||
}
|
||||
close(stop)
|
||||
writerWg.Wait()
|
||||
|
||||
close(errs)
|
||||
for e := range errs {
|
||||
t.Errorf("inconsistent snapshot: %s", e)
|
||||
}
|
||||
}
|
||||
|
||||
func ftoa(f float64) string { return fmt.Sprintf("%g", f) }
|
||||
+41
-112
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
)
|
||||
|
||||
// Set via -ldflags at build time
|
||||
@@ -170,10 +171,12 @@ func main() {
|
||||
// auto_vacuum is checked + migrated by the ingestor (#1283). The
|
||||
// server is read-only and must not race the writer for the lock.
|
||||
|
||||
// Ensure indexes the server's SQL fallback path depends on
|
||||
// (mirrors ingestor schema for DBs created by old server-only builds).
|
||||
if err := ensureServerIndexes(resolvedDB); err != nil {
|
||||
log.Printf("[db] warning: could not ensure server indexes: %v", err)
|
||||
// Assert all schema migrations the ingestor owns have already run
|
||||
// (#1287). The server NEVER migrates — it only reads. If a required
|
||||
// column/index/table is missing, the operator must restart the
|
||||
// ingestor (which owns dbschema.Apply) before this server can start.
|
||||
if err := dbschema.AssertReady(database.conn); err != nil {
|
||||
log.Fatalf("[db] schema not ready (ingestor must run migrations first): %v", err)
|
||||
}
|
||||
|
||||
// In-memory packet store
|
||||
@@ -187,63 +190,12 @@ func main() {
|
||||
go store.loadBackgroundChunks()
|
||||
}
|
||||
|
||||
// Initialize persisted neighbor graph
|
||||
// Initialize persisted neighbor graph.
|
||||
// Per #1287, schema migrations all live in the ingestor (see
|
||||
// dbschema.Apply). The server merely loads the snapshot here and
|
||||
// then refreshes it via the recompNeighborGraph slot every 60s.
|
||||
dbPath = database.path
|
||||
if err := ensureNeighborEdgesTable(dbPath); err != nil {
|
||||
log.Printf("[neighbor] warning: could not create neighbor_edges table: %v", err)
|
||||
}
|
||||
// Add resolved_path column if missing.
|
||||
// NOTE on startup ordering (review item #10): ensureResolvedPathColumn runs AFTER
|
||||
// OpenDB/detectSchema, so db.hasResolvedPath will be false on first run with a
|
||||
// pre-existing DB. This means Load() won't SELECT resolved_path from SQLite.
|
||||
// Async backfill runs after HTTP starts (see backfillResolvedPathsAsync below)
|
||||
// AND to SQLite. On next restart, detectSchema finds the column and Load() reads it.
|
||||
if err := ensureResolvedPathColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add resolved_path column: %v", err)
|
||||
} else {
|
||||
database.hasResolvedPath = true // detectSchema ran before column was added; fix the flag
|
||||
}
|
||||
|
||||
// Ensure observers.inactive column exists (PR #954 filters on it; ingestor migration
|
||||
// adds it but server may run against DBs ingestor never touched, e.g. e2e fixture).
|
||||
if err := ensureObserverInactiveColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add observers.inactive column: %v", err)
|
||||
}
|
||||
|
||||
// Ensure observers.last_packet_at column exists (PR #905 reads it; ingestor migration
|
||||
// adds it but server may run against DBs ingestor never touched, e.g. e2e fixture).
|
||||
if err := ensureLastPacketAtColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add observers.last_packet_at column: %v", err)
|
||||
}
|
||||
|
||||
// Ensure observers.iata column exists (#1188 read paths COALESCE(obs.iata, '')
|
||||
// in Store.Load() / IngestNewFromDB / IngestNewObservations; ingestor migration
|
||||
// adds it but server may run against DBs ingestor never touched (e2e fixture)
|
||||
// OR pre-iata operator DBs upgraded to this build — without this migration
|
||||
// the first SELECT crashes with "no such column: obs.iata" (#1189 R1).
|
||||
if err := ensureObserverIATAColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add observers.iata column: %v", err)
|
||||
}
|
||||
|
||||
// Ensure nodes.foreign_advert column exists (#730 reads it on every /api/nodes
|
||||
// scan; ingestor migration foreign_advert_v1 adds it but server may run against
|
||||
// DBs ingestor never touched, e.g. e2e fixture).
|
||||
if err := ensureForeignAdvertColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add nodes.foreign_advert column: %v", err)
|
||||
}
|
||||
|
||||
// Ensure transmissions.from_pubkey column + index exists (#1143). Backfill
|
||||
// for legacy NULL rows runs async after HTTP starts so it can't block boot
|
||||
// even on prod-sized DBs (100K+ transmissions).
|
||||
if err := ensureFromPubkeyColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add transmissions.from_pubkey column: %v", err)
|
||||
}
|
||||
|
||||
// Soft-delete observers that are in the blacklist (mark inactive=1) so
|
||||
// historical data from a prior unblocked window is hidden too.
|
||||
if len(cfg.ObserverBlacklist) > 0 {
|
||||
softDeleteBlacklistedObservers(dbPath, cfg.ObserverBlacklist)
|
||||
}
|
||||
database.hasResolvedPath = true // dbschema.AssertReady above already verified observations.resolved_path exists
|
||||
|
||||
// WaitGroup for background init steps that gate /api/healthz readiness.
|
||||
var initWg sync.WaitGroup
|
||||
@@ -253,8 +205,14 @@ func main() {
|
||||
store.graph.Store(loadNeighborEdgesFromDB(database.conn))
|
||||
log.Printf("[neighbor] loaded persisted neighbor graph")
|
||||
} else {
|
||||
log.Printf("[neighbor] no persisted edges found, will build in background...")
|
||||
store.graph.Store(NewNeighborGraph()) // empty graph — gets populated by background goroutine
|
||||
// No persisted snapshot yet (e.g. fresh DB before the ingestor
|
||||
// has run its first edge-build cycle). Build an in-memory graph
|
||||
// from the packets we already have so reads aren't empty. We
|
||||
// do NOT persist — the ingestor owns neighbor_edges writes per
|
||||
// #1287; the recompNeighborGraph recomputer will pick up the
|
||||
// real snapshot as soon as the ingestor populates it.
|
||||
log.Printf("[neighbor] no persisted edges found, will build in-memory in background...")
|
||||
store.graph.Store(NewNeighborGraph())
|
||||
initWg.Add(1)
|
||||
go func() {
|
||||
defer initWg.Done()
|
||||
@@ -263,14 +221,9 @@ func main() {
|
||||
log.Printf("[neighbor] graph build panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
rw, rwErr := cachedRW(dbPath)
|
||||
if rwErr == nil {
|
||||
edgeCount := buildAndPersistEdges(store, rw)
|
||||
log.Printf("[neighbor] persisted %d edges", edgeCount)
|
||||
}
|
||||
built := BuildFromStore(store)
|
||||
store.graph.Store(built)
|
||||
log.Printf("[neighbor] graph build complete")
|
||||
log.Printf("[neighbor] in-memory graph build complete")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -387,40 +340,22 @@ func main() {
|
||||
log.Printf("[bridge-recompute] background recompute enabled (interval=%s)",
|
||||
cfg.AnalyticsDefaultRecomputeInterval())
|
||||
|
||||
// Steady-state neighbor-graph snapshot recomputer (issue #1287).
|
||||
// Per Option 4: the ingestor owns neighbor_edges; the server
|
||||
// READS the snapshot every 60s and atomic-swaps it into s.graph.
|
||||
// This is the ONLY path that updates s.graph at steady state.
|
||||
stopNeighborRecomp := store.StartNeighborGraphRecomputer(NeighborGraphRecomputerDefaultInterval)
|
||||
defer stopNeighborRecomp()
|
||||
log.Printf("[neighbor-recompute] snapshot reload enabled (interval=%s)",
|
||||
NeighborGraphRecomputerDefaultInterval)
|
||||
|
||||
// Packet / metrics / observer retention moved to the ingestor in
|
||||
// #1283 (writes only belong on the writer process). The server no
|
||||
// longer schedules any of these; the ingestor's tickers handle them.
|
||||
// #1283 (writes only belong on the writer process). Neighbor-edge
|
||||
// pruning moved to the ingestor in #1287 for the same reason. The
|
||||
// server no longer schedules any of these; the ingestor's tickers
|
||||
// handle them.
|
||||
_ = cfg.IncrementalVacuumPages() // kept reachable for config validation; not used here
|
||||
var stopEdgePrune func()
|
||||
{
|
||||
maxAgeDays := cfg.NeighborMaxAgeDays()
|
||||
edgePruneTicker := time.NewTicker(24 * time.Hour)
|
||||
edgePruneDone := make(chan struct{})
|
||||
stopEdgePrune = func() {
|
||||
edgePruneTicker.Stop()
|
||||
close(edgePruneDone)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[neighbor-prune] panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
time.Sleep(4 * time.Minute) // stagger after metrics prune
|
||||
g := store.graph.Load()
|
||||
PruneNeighborEdges(dbPath, g, maxAgeDays)
|
||||
for {
|
||||
select {
|
||||
case <-edgePruneTicker.C:
|
||||
g := store.graph.Load()
|
||||
PruneNeighborEdges(dbPath, g, maxAgeDays)
|
||||
case <-edgePruneDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[neighbor-prune] auto-prune enabled: edges older than %d days", maxAgeDays)
|
||||
}
|
||||
_ = cfg.NeighborMaxAgeDays() // ditto — owned by ingestor now
|
||||
|
||||
// Graceful shutdown
|
||||
httpServer := &http.Server{
|
||||
@@ -440,11 +375,8 @@ func main() {
|
||||
// 1. Stop accepting new WebSocket/poll data
|
||||
poller.Stop()
|
||||
|
||||
// 1b. Stop auto-prune ticker (server-side packet/metrics/observer
|
||||
// prunes were removed in #1283; only neighbor-edge prune remains.)
|
||||
if stopEdgePrune != nil {
|
||||
stopEdgePrune()
|
||||
}
|
||||
// 1b. Auto-prune tickers were all relocated to the ingestor in
|
||||
// #1283/#1287 — nothing to stop here.
|
||||
|
||||
// 1c. Stop steady-state analytics recomputers (issue #1240).
|
||||
// Must happen before dbClose so any in-flight compute that
|
||||
@@ -472,13 +404,10 @@ func main() {
|
||||
|
||||
log.Printf("[server] CoreScope (Go) listening on http://localhost:%d", cfg.Port)
|
||||
|
||||
// Start async backfill in background — HTTP is now available.
|
||||
go backfillResolvedPathsAsync(store, dbPath, 5000, 100*time.Millisecond, cfg.BackfillHours())
|
||||
// #1143: backfill from_pubkey for legacy ADVERT rows. Async so even
|
||||
// 100K+ rows can't block boot; queries handle NULL gracefully.
|
||||
// startFromPubkeyBackfill wraps the goroutine dispatch so the async
|
||||
// contract is testable (see TestBackfillFromPubkey_DoesNotBlockBoot).
|
||||
startFromPubkeyBackfill(dbPath, 5000, 100*time.Millisecond)
|
||||
// Backfills (resolved_path, from_pubkey) moved to the ingestor in
|
||||
// #1287 — they are write operations and belong on the writer
|
||||
// process. The server reads the results via the periodic
|
||||
// recompNeighborGraph / fetchResolvedPathForObs paths.
|
||||
|
||||
// Migrate old content hashes in background (one-time, idempotent).
|
||||
go migrateContentHashesAsync(store, 5000, 100*time.Millisecond)
|
||||
|
||||
+38
-668
@@ -1,42 +1,33 @@
|
||||
// Package main: read-only neighbor-edges loader.
|
||||
//
|
||||
// Per issue #1287 (followup to #1283), cmd/server is the read path: it
|
||||
// LOADS the in-memory neighbor graph from the SQLite snapshot the
|
||||
// ingestor maintains, but never writes to it. The previous write-side
|
||||
// helpers in this file (buildAndPersistEdges, asyncPersistResolvedPaths
|
||||
// AndEdges, ensure*Column, softDeleteBlacklistedObservers,
|
||||
// PruneNeighborEdges, openRW) all moved to cmd/ingestor; cmd/ingestor
|
||||
// owns CREATE/ALTER/INSERT/UPDATE/DELETE on neighbor_edges and the
|
||||
// observations/resolved_path column.
|
||||
//
|
||||
// Server now refreshes its in-memory copy of the graph via the
|
||||
// recompNeighborGraph slot in analytics_recomputer.go: every 60s it
|
||||
// re-reads neighbor_edges and atomic-swaps the resulting NeighborGraph
|
||||
// into s.graph.
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// persistSem limits concurrent async persistence goroutines to 1.
|
||||
// Without this, each ingest cycle spawns a goroutine that opens a new
|
||||
// SQLite RW connection; under sustained load goroutines pile up with
|
||||
// no backpressure, causing contention and busy-timeout cascades.
|
||||
var persistSem = make(chan struct{}, 1)
|
||||
|
||||
// ─── neighbor_edges table ──────────────────────────────────────────────────────
|
||||
|
||||
// ensureNeighborEdgesTable creates the neighbor_edges table if it doesn't exist.
|
||||
// Uses a separate read-write connection since the main DB is read-only.
|
||||
func ensureNeighborEdgesTable(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open rw for neighbor_edges: %w", err)
|
||||
}
|
||||
|
||||
_, err = rw.Exec(`CREATE TABLE IF NOT EXISTS neighbor_edges (
|
||||
node_a TEXT NOT NULL,
|
||||
node_b TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
last_seen TEXT,
|
||||
PRIMARY KEY (node_a, node_b)
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
// ─── neighbor_edges loader (read-only) ─────────────────────────────────────────
|
||||
|
||||
// loadNeighborEdgesFromDB loads all edges from the neighbor_edges table
|
||||
// and builds an in-memory NeighborGraph.
|
||||
// and builds an in-memory NeighborGraph. Called on server startup and
|
||||
// from the recompNeighborGraph background recomputer (#1287).
|
||||
func loadNeighborEdgesFromDB(conn *sql.DB) *NeighborGraph {
|
||||
g := NewNeighborGraph()
|
||||
|
||||
@@ -59,7 +50,6 @@ func loadNeighborEdgesFromDB(conn *sql.DB) *NeighborGraph {
|
||||
if lastSeen.Valid {
|
||||
ts = parseTimestamp(lastSeen.String)
|
||||
}
|
||||
// Build edge directly (both nodes are full pubkeys from persisted data)
|
||||
key := makeEdgeKey(a, b)
|
||||
g.mu.Lock()
|
||||
e, exists := g.edges[key]
|
||||
@@ -95,391 +85,28 @@ func loadNeighborEdgesFromDB(conn *sql.DB) *NeighborGraph {
|
||||
return g
|
||||
}
|
||||
|
||||
// ─── shared async persistence helper ───────────────────────────────────────────
|
||||
|
||||
// persistObsUpdate holds data for a resolved_path SQLite update.
|
||||
type persistObsUpdate struct {
|
||||
obsID int
|
||||
resolvedPath string
|
||||
}
|
||||
|
||||
// persistEdgeUpdate holds data for a neighbor_edges SQLite upsert.
|
||||
type persistEdgeUpdate struct {
|
||||
a, b, ts string
|
||||
}
|
||||
|
||||
// asyncPersistResolvedPathsAndEdges writes resolved_path updates and neighbor
|
||||
// edge upserts to SQLite in a background goroutine. Shared between
|
||||
// IngestNewFromDB and IngestNewObservations to avoid DRY violation.
|
||||
func asyncPersistResolvedPathsAndEdges(dbPath string, obsUpdates []persistObsUpdate, edgeUpdates []persistEdgeUpdate, logPrefix string) {
|
||||
if len(obsUpdates) == 0 && len(edgeUpdates) == 0 {
|
||||
return
|
||||
}
|
||||
// Try-acquire semaphore BEFORE spawning goroutine. If another
|
||||
// persistence operation is already running, drop this batch —
|
||||
// data lives in memory and will be backfilled on restart.
|
||||
select {
|
||||
case persistSem <- struct{}{}:
|
||||
// Acquired — spawn goroutine to do the work.
|
||||
default:
|
||||
log.Printf("[store] %s skipped: persistence already in progress", logPrefix)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer func() { <-persistSem }()
|
||||
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[store] %s rw open error: %v", logPrefix, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(obsUpdates) > 0 {
|
||||
sqlTx, err := rw.Begin()
|
||||
if err == nil {
|
||||
stmt, err := sqlTx.Prepare("UPDATE observations SET resolved_path = ? WHERE id = ?")
|
||||
if err == nil {
|
||||
var firstErr error
|
||||
for _, u := range obsUpdates {
|
||||
if _, err := stmt.Exec(u.resolvedPath, u.obsID); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
if firstErr != nil {
|
||||
log.Printf("[store] %s resolved_path error (first): %v", logPrefix, firstErr)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[store] %s resolved_path prepare error: %v", logPrefix, err)
|
||||
}
|
||||
sqlTx.Commit()
|
||||
}
|
||||
}
|
||||
|
||||
if len(edgeUpdates) > 0 {
|
||||
sqlTx, err := rw.Begin()
|
||||
if err == nil {
|
||||
stmt, err := sqlTx.Prepare(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||||
count = count + 1, last_seen = MAX(last_seen, excluded.last_seen)`)
|
||||
if err == nil {
|
||||
var firstErr error
|
||||
for _, e := range edgeUpdates {
|
||||
if _, err := stmt.Exec(e.a, e.b, e.ts); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
if firstErr != nil {
|
||||
log.Printf("[store] %s edge error (first): %v", logPrefix, firstErr)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[store] %s edge prepare error: %v", logPrefix, err)
|
||||
}
|
||||
sqlTx.Commit()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// neighborEdgesTableExists checks if the neighbor_edges table has any data.
|
||||
// neighborEdgesTableExists returns true when neighbor_edges contains at
|
||||
// least one row. Used by main.go to decide between "load snapshot" and
|
||||
// "start with empty graph and wait for the ingestor to populate it".
|
||||
func neighborEdgesTableExists(conn *sql.DB) bool {
|
||||
var cnt int
|
||||
err := conn.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&cnt)
|
||||
if err != nil {
|
||||
return false // table doesn't exist
|
||||
return false
|
||||
}
|
||||
return cnt > 0
|
||||
}
|
||||
|
||||
// buildAndPersistEdges scans all packets in the store, extracts edges per
|
||||
// ADVERT/non-ADVERT rules, and persists them to SQLite.
|
||||
func buildAndPersistEdges(store *PacketStore, rw *sql.DB) int {
|
||||
store.mu.RLock()
|
||||
packets := make([]*StoreTx, len(store.packets))
|
||||
copy(packets, store.packets)
|
||||
store.mu.RUnlock()
|
||||
// ─── resolved_path helpers (read-only / in-memory only) ────────────────────────
|
||||
|
||||
_, pm := store.getCachedNodesAndPM()
|
||||
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[neighbor] begin tx error: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||||
count = count + 1, last_seen = MAX(last_seen, excluded.last_seen)`)
|
||||
if err != nil {
|
||||
log.Printf("[neighbor] prepare stmt error: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
edgeCount := 0
|
||||
var firstErr error
|
||||
for _, pkt := range packets {
|
||||
for _, obs := range pkt.Observations {
|
||||
for _, ec := range extractEdgesFromObs(obs, pkt, pm) {
|
||||
if _, err := stmt.Exec(ec.A, ec.B, ec.Timestamp); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
edgeCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
log.Printf("[neighbor] edge exec error (first): %v", firstErr)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[neighbor] commit error: %v", err)
|
||||
return 0
|
||||
}
|
||||
return edgeCount
|
||||
}
|
||||
|
||||
// ─── resolved_path column ──────────────────────────────────────────────────────
|
||||
|
||||
// ensureResolvedPathColumn adds the resolved_path column to observations if missing.
|
||||
func ensureResolvedPathColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if column already exists
|
||||
rows, err := rw.Query("PRAGMA table_info(observations)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "resolved_path" {
|
||||
return nil // already exists
|
||||
}
|
||||
}
|
||||
|
||||
_, err = rw.Exec("ALTER TABLE observations ADD COLUMN resolved_path TEXT")
|
||||
if err != nil {
|
||||
return fmt.Errorf("add resolved_path column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added resolved_path column to observations")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureObserverInactiveColumn adds the inactive column to observers if missing.
|
||||
// The column was originally added by ingestor migration (cmd/ingestor/db.go:344) to
|
||||
// support soft-delete via RemoveStaleObservers + filtered reads (PR #954). When the
|
||||
// server starts against a DB that was never touched by the ingestor (e.g. the e2e
|
||||
// fixture), the column is missing and read queries that filter on it (GetObservers,
|
||||
// GetStats) silently fail with "no such column: inactive" — leaving /api/observers
|
||||
// returning empty.
|
||||
func ensureObserverInactiveColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := rw.Query("PRAGMA table_info(observers)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "inactive" {
|
||||
return nil // already exists
|
||||
}
|
||||
}
|
||||
|
||||
_, err = rw.Exec("ALTER TABLE observers ADD COLUMN inactive INTEGER DEFAULT 0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("add inactive column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added inactive column to observers")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureLastPacketAtColumn adds the last_packet_at column to observers if missing.
|
||||
// The column was originally added by ingestor migration (observers_last_packet_at_v1)
|
||||
// to track the most recent packet observation time separately from status updates.
|
||||
// When the server starts against a DB that was never touched by the ingestor (e.g.
|
||||
// the e2e fixture), the column is missing and read queries that reference it
|
||||
// (GetObservers, GetObserverByID) fail with "no such column: last_packet_at".
|
||||
func ensureLastPacketAtColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := rw.Query("PRAGMA table_info(observers)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "last_packet_at" {
|
||||
return nil // already exists
|
||||
}
|
||||
}
|
||||
|
||||
_, err = rw.Exec("ALTER TABLE observers ADD COLUMN last_packet_at TEXT")
|
||||
if err != nil {
|
||||
return fmt.Errorf("add last_packet_at column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added last_packet_at column to observers")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureObserverIATAColumn adds the iata column to observers if missing.
|
||||
// The column was originally added by ingestor migration (cmd/ingestor/db.go) to
|
||||
// label each observer with a 3-letter regional IATA code. When the server starts
|
||||
// against a DB that was never touched by the ingestor (e.g. the e2e fixture,
|
||||
// or a pre-iata operator DB upgraded to this build), every SELECT that joins
|
||||
// COALESCE(obs.iata, '') panics with "no such column: obs.iata" — crashing
|
||||
// Store.Load() / IngestNewFromDB / IngestNewObservations on startup (#1189 R1).
|
||||
func ensureObserverIATAColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := rw.Query("PRAGMA table_info(observers)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "iata" {
|
||||
return nil // already exists
|
||||
}
|
||||
}
|
||||
|
||||
_, err = rw.Exec("ALTER TABLE observers ADD COLUMN iata TEXT")
|
||||
if err != nil {
|
||||
return fmt.Errorf("add iata column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added iata column to observers")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureForeignAdvertColumn adds the foreign_advert column to nodes/inactive_nodes
|
||||
// if missing (#730). The column is added by the ingestor migration foreign_advert_v1
|
||||
// — but the server may run against a DB the ingestor has never touched (e2e fixture,
|
||||
// fresh installs where the server boots first), in which case scanNodeRow fails
|
||||
// with "no such column: foreign_advert" and /api/nodes silently returns nothing.
|
||||
func ensureForeignAdvertColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, table := range []string{"nodes", "inactive_nodes"} {
|
||||
has, err := tableHasColumn(rw, table, "foreign_advert")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s: %w", table, err)
|
||||
}
|
||||
if has {
|
||||
continue
|
||||
}
|
||||
if _, err := rw.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN foreign_advert INTEGER DEFAULT 0", table)); err != nil {
|
||||
return fmt.Errorf("add foreign_advert to %s: %w", table, err)
|
||||
}
|
||||
log.Printf("[store] Added foreign_advert column to %s", table)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableHasColumn reports whether the named table has the named column.
|
||||
func tableHasColumn(rw *sql.DB, table, column string) (bool, error) {
|
||||
rows, err := rw.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// softDeleteBlacklistedObservers marks observers matching the blacklist as
|
||||
// inactive=1 so they are hidden from API responses. Runs once at startup.
|
||||
func softDeleteBlacklistedObservers(dbPath string, blacklist []string) {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[observer-blacklist] warning: could not open DB for soft-delete: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
placeholders := make([]string, 0, len(blacklist))
|
||||
args := make([]interface{}, 0, len(blacklist))
|
||||
for _, pk := range blacklist {
|
||||
trimmed := strings.TrimSpace(pk)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
placeholders = append(placeholders, "LOWER(?)")
|
||||
args = append(args, trimmed)
|
||||
}
|
||||
if len(placeholders) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
query := "UPDATE observers SET inactive = 1 WHERE LOWER(id) IN (" + strings.Join(placeholders, ",") + ") AND (inactive IS NULL OR inactive = 0)"
|
||||
result, err := rw.Exec(query, args...)
|
||||
if err != nil {
|
||||
log.Printf("[observer-blacklist] warning: soft-delete failed: %v", err)
|
||||
return
|
||||
}
|
||||
if n, _ := result.RowsAffected(); n > 0 {
|
||||
log.Printf("[observer-blacklist] soft-deleted %d blacklisted observer(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePathForObs resolves hop prefixes to full pubkeys for an observation.
|
||||
// Returns nil if path is empty.
|
||||
// resolvePathForObs resolves hop prefixes to full pubkeys for an
|
||||
// observation. Pure compute — does NOT persist (the ingestor owns
|
||||
// writes to observations.resolved_path).
|
||||
func resolvePathForObs(pathJSON, observerID string, tx *StoreTx, pm *prefixMap, graph *NeighborGraph) []*string {
|
||||
hops := parsePathJSON(pathJSON)
|
||||
if len(hops) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build context pubkeys: observer + originator (if known)
|
||||
contextPKs := make([]string, 0, 3)
|
||||
if observerID != "" {
|
||||
contextPKs = append(contextPKs, strings.ToLower(observerID))
|
||||
@@ -488,28 +115,23 @@ func resolvePathForObs(pathJSON, observerID string, tx *StoreTx, pm *prefixMap,
|
||||
if fromNode != "" {
|
||||
contextPKs = append(contextPKs, strings.ToLower(fromNode))
|
||||
}
|
||||
|
||||
resolved := make([]*string, len(hops))
|
||||
for i, hop := range hops {
|
||||
// Add adjacent hops as context for disambiguation
|
||||
ctx := make([]string, len(contextPKs), len(contextPKs)+2)
|
||||
copy(ctx, contextPKs)
|
||||
// Add previously resolved hops as context
|
||||
if i > 0 && resolved[i-1] != nil {
|
||||
ctx = append(ctx, *resolved[i-1])
|
||||
}
|
||||
|
||||
node, _, _ := pm.resolveWithContext(hop, ctx, graph)
|
||||
if node != nil {
|
||||
pk := strings.ToLower(node.PublicKey)
|
||||
resolved[i] = &pk
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// marshalResolvedPath converts []*string to JSON for storage.
|
||||
// marshalResolvedPath converts []*string to JSON for in-memory caching.
|
||||
func marshalResolvedPath(rp []*string) string {
|
||||
if len(rp) == 0 {
|
||||
return ""
|
||||
@@ -533,225 +155,22 @@ func unmarshalResolvedPath(s string) []*string {
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Shared edge-extraction helper (used by ingestor + tests) ──────────────────
|
||||
|
||||
// backfillResolvedPathsAsync processes observations with NULL resolved_path in
|
||||
// chunks, yielding between batches so HTTP handlers remain responsive. It sets
|
||||
// store.backfillComplete when finished and re-picks best observations for any
|
||||
// transmissions affected by newly resolved paths.
|
||||
func backfillResolvedPathsAsync(store *PacketStore, dbPath string, chunkSize int, yieldDuration time.Duration, backfillHours int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[store] backfillResolvedPathsAsync panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
// Collect ALL pending obs refs upfront in one pass under a single RLock (fix A).
|
||||
type obsRef struct {
|
||||
obsID int
|
||||
pathJSON string
|
||||
observerID string
|
||||
txJSON string
|
||||
payloadType *int
|
||||
txHash string // to re-pick best obs
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(backfillHours) * time.Hour)
|
||||
|
||||
store.mu.RLock()
|
||||
pm := store.nodePM
|
||||
var allPending []obsRef
|
||||
for _, tx := range store.packets {
|
||||
// Skip transmissions older than the backfill window.
|
||||
if tx.FirstSeen != "" {
|
||||
if ts, err := time.Parse(time.RFC3339Nano, tx.FirstSeen); err == nil && ts.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
// Also try the common SQLite format
|
||||
if ts, err := time.Parse("2006-01-02 15:04:05", tx.FirstSeen); err == nil && ts.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, obs := range tx.Observations {
|
||||
// Check if this observation has been resolved: look up in the index.
|
||||
// If the tx has no reverse-map entries AND path is non-empty, it needs backfill.
|
||||
hasRP := false
|
||||
if _, ok := store.resolvedPubkeyReverse[tx.ID]; ok {
|
||||
hasRP = true
|
||||
}
|
||||
if !hasRP && obs.PathJSON != "" && obs.PathJSON != "[]" {
|
||||
allPending = append(allPending, obsRef{
|
||||
obsID: obs.ID,
|
||||
pathJSON: obs.PathJSON,
|
||||
observerID: obs.ObserverID,
|
||||
txJSON: tx.DecodedJSON,
|
||||
payloadType: tx.PayloadType,
|
||||
txHash: tx.Hash,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
store.mu.RUnlock()
|
||||
|
||||
totalPending := len(allPending)
|
||||
if totalPending == 0 || pm == nil {
|
||||
store.backfillComplete.Store(true)
|
||||
log.Printf("[store] async resolved_path backfill: nothing to do")
|
||||
return
|
||||
}
|
||||
|
||||
store.backfillTotal.Store(int64(totalPending))
|
||||
store.backfillProcessed.Store(0)
|
||||
log.Printf("[store] async resolved_path backfill starting: %d observations", totalPending)
|
||||
|
||||
// Open RW connection once before the chunk loop (fix B).
|
||||
var rw *sql.DB
|
||||
if dbPath != "" {
|
||||
var err error
|
||||
rw, err = cachedRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[store] async backfill: open rw error: %v", err)
|
||||
}
|
||||
}
|
||||
// rw is cached process-wide; do not close
|
||||
|
||||
totalProcessed := 0
|
||||
for totalProcessed < totalPending {
|
||||
end := totalProcessed + chunkSize
|
||||
if end > totalPending {
|
||||
end = totalPending
|
||||
}
|
||||
chunk := allPending[totalProcessed:end]
|
||||
|
||||
// Re-read graph at the start of each chunk so we pick up a freshly-
|
||||
// built graph once the background build goroutine completes, instead
|
||||
// of using the potentially-empty graph captured at cold start.
|
||||
// (Dead RLock wrap removed PR #1208: store.graph is atomic.Pointer.)
|
||||
graph := store.graph.Load()
|
||||
|
||||
// Resolve paths outside any lock.
|
||||
type resolved struct {
|
||||
obsID int
|
||||
rp []*string
|
||||
rpJSON string
|
||||
txHash string
|
||||
}
|
||||
var results []resolved
|
||||
for _, ref := range chunk {
|
||||
fakeTx := &StoreTx{DecodedJSON: ref.txJSON, PayloadType: ref.payloadType}
|
||||
rp := resolvePathForObs(ref.pathJSON, ref.observerID, fakeTx, pm, graph)
|
||||
if len(rp) > 0 {
|
||||
rpJSON := marshalResolvedPath(rp)
|
||||
if rpJSON != "" {
|
||||
results = append(results, resolved{ref.obsID, rp, rpJSON, ref.txHash})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to SQLite using the shared connection.
|
||||
if len(results) > 0 && rw != nil {
|
||||
sqlTx, err := rw.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[store] async backfill: begin tx error: %v", err)
|
||||
} else {
|
||||
stmt, err := sqlTx.Prepare("UPDATE observations SET resolved_path = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
log.Printf("[store] async backfill: prepare error: %v", err)
|
||||
sqlTx.Rollback()
|
||||
} else {
|
||||
var execErr error
|
||||
for _, r := range results {
|
||||
if _, e := stmt.Exec(r.rpJSON, r.obsID); e != nil && execErr == nil {
|
||||
execErr = e
|
||||
}
|
||||
}
|
||||
if execErr != nil {
|
||||
log.Printf("[store] async backfill: exec error (first): %v", execErr)
|
||||
}
|
||||
stmt.Close()
|
||||
if err := sqlTx.Commit(); err != nil {
|
||||
log.Printf("[store] async backfill: commit error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update in-memory state: update resolved pubkey index, re-pick best observation,
|
||||
// and invalidate LRU cache entries for backfilled observations (#800).
|
||||
//
|
||||
// Lock ordering: always take s.mu BEFORE lruMu. The read path
|
||||
// (fetchResolvedPathForObs) takes lruMu independently of s.mu,
|
||||
// so we must NOT hold s.mu while taking lruMu. Instead, collect
|
||||
// obsIDs to invalidate under s.mu, release it, then take lruMu.
|
||||
store.mu.Lock()
|
||||
affectedSet := make(map[string]bool)
|
||||
lruInvalidate := make([]int, 0, len(results))
|
||||
for _, r := range results {
|
||||
// Remove old index entries for this tx, then re-add with new pubkeys
|
||||
if !affectedSet[r.txHash] {
|
||||
affectedSet[r.txHash] = true
|
||||
if tx, ok := store.byHash[r.txHash]; ok {
|
||||
store.removeFromResolvedPubkeyIndex(tx.ID)
|
||||
}
|
||||
}
|
||||
// Add new resolved pubkeys to index
|
||||
if tx, ok := store.byHash[r.txHash]; ok {
|
||||
pks := extractResolvedPubkeys(r.rp)
|
||||
store.addToResolvedPubkeyIndex(tx.ID, pks)
|
||||
// Update byNode for relay nodes
|
||||
for _, pk := range pks {
|
||||
store.addToByNode(tx, pk)
|
||||
}
|
||||
// Update byPathHop resolved-key entries
|
||||
hopsSeen := make(map[string]bool)
|
||||
for _, hop := range txGetParsedPath(tx) {
|
||||
hopsSeen[strings.ToLower(hop)] = true
|
||||
}
|
||||
for _, pk := range pks {
|
||||
if !hopsSeen[pk] {
|
||||
hopsSeen[pk] = true
|
||||
store.byPathHop[pk] = append(store.byPathHop[pk], tx)
|
||||
}
|
||||
}
|
||||
}
|
||||
lruInvalidate = append(lruInvalidate, r.obsID)
|
||||
}
|
||||
// Re-pick best observation for affected transmissions
|
||||
for txHash := range affectedSet {
|
||||
if tx, ok := store.byHash[txHash]; ok {
|
||||
pickBestObservation(tx)
|
||||
}
|
||||
}
|
||||
store.mu.Unlock()
|
||||
|
||||
// Invalidate LRU entries AFTER releasing s.mu to maintain lock
|
||||
// ordering (lruMu must never be taken while s.mu is held).
|
||||
store.lruMu.Lock()
|
||||
for _, obsID := range lruInvalidate {
|
||||
store.lruDelete(obsID)
|
||||
}
|
||||
store.lruMu.Unlock()
|
||||
}
|
||||
|
||||
totalProcessed += len(chunk)
|
||||
store.backfillProcessed.Store(int64(totalProcessed))
|
||||
pct := float64(totalProcessed) / float64(totalPending) * 100
|
||||
log.Printf("[store] backfill progress: %d/%d observations (%.1f%%)", totalProcessed, totalPending, pct)
|
||||
|
||||
time.Sleep(yieldDuration)
|
||||
}
|
||||
|
||||
store.backfillComplete.Store(true)
|
||||
log.Printf("[store] async resolved_path backfill complete: %d observations processed", totalProcessed)
|
||||
}
|
||||
|
||||
// ─── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// edgeCandidate represents an extracted edge to be persisted.
|
||||
// edgeCandidate represents an extracted edge. The ingestor uses the
|
||||
// same logic when computing edges from observations.
|
||||
type edgeCandidate struct {
|
||||
A, B, Timestamp string
|
||||
}
|
||||
|
||||
// extractEdgesFromObs extracts neighbor edge candidates from a single observation.
|
||||
// For ADVERTs: originator↔path[0] (if unambiguous). For ALL types: observer↔path[last] (if unambiguous).
|
||||
// Also handles zero-hop ADVERTs (originator↔observer direct link).
|
||||
// extractEdgesFromObs extracts neighbor edge candidates from a single
|
||||
// observation. For ADVERTs: originator↔path[0] (if unambiguous). For
|
||||
// ALL types: observer↔path[last] (if unambiguous). Also handles
|
||||
// zero-hop ADVERTs (originator↔observer direct link).
|
||||
//
|
||||
// Kept in cmd/server because the in-memory graph builder
|
||||
// (neighbor_graph.go) also calls it; it is pure compute and does not
|
||||
// touch the DB.
|
||||
func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandidate {
|
||||
isAdvert := tx.PayloadType != nil && *tx.PayloadType == PayloadADVERT
|
||||
fromNode := extractFromNode(tx)
|
||||
@@ -774,7 +193,6 @@ func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandid
|
||||
return edges
|
||||
}
|
||||
|
||||
// Edge 1: originator ↔ path[0] — ADVERTs only (resolve prefix to full pubkey)
|
||||
if isAdvert && fromNode != "" && pm != nil {
|
||||
firstHop := strings.ToLower(path[0])
|
||||
fromLower := strings.ToLower(fromNode)
|
||||
@@ -791,7 +209,6 @@ func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandid
|
||||
}
|
||||
}
|
||||
|
||||
// Edge 2: observer ↔ path[last] — ALL packet types
|
||||
if pm != nil {
|
||||
lastHop := strings.ToLower(path[len(path)-1])
|
||||
candidates := pm.m[lastHop]
|
||||
@@ -809,50 +226,3 @@ func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandid
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
// openRW opens a read-write SQLite connection (same pattern as PruneOldPackets).
|
||||
func openRW(dbPath string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL", dbPath)
|
||||
rw, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rw.SetMaxOpenConns(1)
|
||||
// DSN _busy_timeout may not be honored by all drivers; set via PRAGMA
|
||||
// to guarantee SQLite retries for up to 5s before returning SQLITE_BUSY.
|
||||
if _, err := rw.Exec("PRAGMA busy_timeout = 5000"); err != nil {
|
||||
rw.Close()
|
||||
return nil, fmt.Errorf("set busy_timeout: %w", err)
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
// PruneNeighborEdges removes edges older than maxAgeDays from both SQLite and
|
||||
// the in-memory graph. Uses openRW internally because the shared database.conn
|
||||
// is opened with mode=ro and DELETEs would silently fail.
|
||||
func PruneNeighborEdges(dbPath string, graph *NeighborGraph, maxAgeDays int) (int, error) {
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(maxAgeDays) * 24 * time.Hour)
|
||||
|
||||
// 1. Prune from SQLite using a read-write connection
|
||||
var dbPruned int64
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune neighbor_edges: open rw: %w", err)
|
||||
}
|
||||
res, err := rw.Exec("DELETE FROM neighbor_edges WHERE last_seen < ?", cutoff.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune neighbor_edges: %w", err)
|
||||
}
|
||||
dbPruned, _ = res.RowsAffected()
|
||||
|
||||
// 2. Prune from in-memory graph
|
||||
memPruned := 0
|
||||
if graph != nil {
|
||||
memPruned = graph.PruneOlderThan(cutoff)
|
||||
}
|
||||
|
||||
if dbPruned > 0 || memPruned > 0 {
|
||||
log.Printf("[neighbor-prune] removed %d DB rows, %d in-memory edges older than %d days", dbPruned, memPruned, maxAgeDays)
|
||||
}
|
||||
return int(dbPruned), nil
|
||||
}
|
||||
@@ -1,687 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// createTestDBWithSchema creates a temp SQLite DB with the standard schema + resolved_path column.
|
||||
func createTestDBWithSchema(t *testing.T) (*DB, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
conn, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create tables
|
||||
conn.Exec(`CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
|
||||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
|
||||
decoded_json TEXT, channel_hash TEXT DEFAULT NULL
|
||||
)`)
|
||||
conn.Exec(`CREATE TABLE observers (
|
||||
id TEXT PRIMARY KEY, name TEXT, iata TEXT
|
||||
)`)
|
||||
conn.Exec(`CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
observer_id TEXT, observer_name TEXT, direction TEXT,
|
||||
snr REAL, rssi REAL, score INTEGER,
|
||||
path_json TEXT, timestamp TEXT,
|
||||
resolved_path TEXT, raw_hex TEXT
|
||||
)`)
|
||||
conn.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
|
||||
)`)
|
||||
|
||||
conn.Close()
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db, dbPath
|
||||
}
|
||||
|
||||
func TestResolvePathForObs(t *testing.T) {
|
||||
// Build a prefix map with known nodes
|
||||
nodes := []nodeInfo{
|
||||
{Role: "repeater", PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
|
||||
{Role: "repeater", PublicKey: "bbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb11", Name: "Node-BB"},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
graph := NewNeighborGraph()
|
||||
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey": "originator1234567890"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
|
||||
// Unambiguous prefixes should resolve
|
||||
rp := resolvePathForObs(`["aa","bb"]`, "observer1", tx, pm, graph)
|
||||
if len(rp) != 2 {
|
||||
t.Fatalf("expected 2 resolved hops, got %d", len(rp))
|
||||
}
|
||||
if rp[0] == nil || !strings.HasPrefix(*rp[0], "aabbcc") {
|
||||
t.Errorf("expected first hop to resolve to Node-AA, got %v", rp[0])
|
||||
}
|
||||
if rp[1] == nil || !strings.HasPrefix(*rp[1], "bbccdd") {
|
||||
t.Errorf("expected second hop to resolve to Node-BB, got %v", rp[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePathForObs_EmptyPath(t *testing.T) {
|
||||
pm := buildPrefixMap(nil)
|
||||
rp := resolvePathForObs(`[]`, "", &StoreTx{}, pm, nil)
|
||||
if rp != nil {
|
||||
t.Errorf("expected nil for empty path, got %v", rp)
|
||||
}
|
||||
|
||||
rp = resolvePathForObs("", "", &StoreTx{}, pm, nil)
|
||||
if rp != nil {
|
||||
t.Errorf("expected nil for empty string, got %v", rp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePathForObs_Unresolvable(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{Role: "repeater", PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
// "zz" prefix doesn't match any node
|
||||
rp := resolvePathForObs(`["zz"]`, "", &StoreTx{}, pm, nil)
|
||||
if len(rp) != 1 {
|
||||
t.Fatalf("expected 1 hop, got %d", len(rp))
|
||||
}
|
||||
if rp[0] != nil {
|
||||
t.Errorf("expected nil for unresolvable hop, got %v", *rp[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalUnmarshalResolvedPath(t *testing.T) {
|
||||
pk1 := "aabbccdd"
|
||||
var rp []*string
|
||||
rp = append(rp, &pk1, nil)
|
||||
|
||||
j := marshalResolvedPath(rp)
|
||||
if j == "" {
|
||||
t.Fatal("expected non-empty JSON")
|
||||
}
|
||||
|
||||
parsed := unmarshalResolvedPath(j)
|
||||
if len(parsed) != 2 {
|
||||
t.Fatalf("expected 2 elements, got %d", len(parsed))
|
||||
}
|
||||
if parsed[0] == nil || *parsed[0] != "aabbccdd" {
|
||||
t.Errorf("first element wrong: %v", parsed[0])
|
||||
}
|
||||
if parsed[1] != nil {
|
||||
t.Errorf("second element should be nil, got %v", *parsed[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalResolvedPath_Empty(t *testing.T) {
|
||||
if marshalResolvedPath(nil) != "" {
|
||||
t.Error("expected empty for nil")
|
||||
}
|
||||
if marshalResolvedPath([]*string{}) != "" {
|
||||
t.Error("expected empty for empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalResolvedPath_Invalid(t *testing.T) {
|
||||
if unmarshalResolvedPath("") != nil {
|
||||
t.Error("expected nil for empty string")
|
||||
}
|
||||
if unmarshalResolvedPath("not json") != nil {
|
||||
t.Error("expected nil for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNeighborEdgesTable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create initial DB
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY)")
|
||||
conn.Close()
|
||||
|
||||
if err := ensureNeighborEdgesTable(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify table exists
|
||||
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
|
||||
defer conn.Close()
|
||||
var cnt int
|
||||
if err := conn.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&cnt); err != nil {
|
||||
t.Fatalf("neighbor_edges table not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNeighborEdgesFromDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec(`CREATE TABLE neighbor_edges (
|
||||
node_a TEXT NOT NULL, node_b TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1, last_seen TEXT,
|
||||
PRIMARY KEY (node_a, node_b)
|
||||
)`)
|
||||
conn.Exec("INSERT INTO neighbor_edges VALUES ('aaa', 'bbb', 5, '2024-01-01T00:00:00Z')")
|
||||
conn.Exec("INSERT INTO neighbor_edges VALUES ('ccc', 'ddd', 3, '2024-01-02T00:00:00Z')")
|
||||
|
||||
g := loadNeighborEdgesFromDB(conn)
|
||||
conn.Close()
|
||||
|
||||
// Should have 2 edges
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 2 {
|
||||
t.Errorf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
|
||||
// Check neighbors
|
||||
n := g.Neighbors("aaa")
|
||||
if len(n) != 1 {
|
||||
t.Errorf("expected 1 neighbor for aaa, got %d", len(n))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreObsResolvedPathInBroadcast(t *testing.T) {
|
||||
// After #800 refactor, resolved_path is no longer stored on StoreTx/StoreObs structs.
|
||||
// Broadcast maps carry resolved_path from the decode-window, not from struct fields.
|
||||
// This test verifies pickBestObservation no longer sets ResolvedPath on tx.
|
||||
obs := &StoreObs{
|
||||
ID: 1,
|
||||
ObserverID: "obs1",
|
||||
ObserverName: "Observer 1",
|
||||
PathJSON: `["aa"]`,
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc123",
|
||||
Observations: []*StoreObs{obs},
|
||||
}
|
||||
pickBestObservation(tx)
|
||||
|
||||
// tx should NOT have a ResolvedPath field anymore (compile-time guard)
|
||||
// Verify the best observation's fields are propagated correctly
|
||||
if tx.ObserverID != "obs1" {
|
||||
t.Errorf("expected ObserverID=obs1, got %s", tx.ObserverID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathInTxToMap(t *testing.T) {
|
||||
// After #800, txToMap no longer includes resolved_path from the struct.
|
||||
// resolved_path is only available via on-demand SQL fetch (txToMapWithRP).
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc123",
|
||||
PathJSON: `["aa"]`,
|
||||
obsKeys: make(map[string]bool),
|
||||
}
|
||||
|
||||
m := txToMap(tx)
|
||||
if _, ok := m["resolved_path"]; ok {
|
||||
t.Error("resolved_path should not be in txToMap output (removed in #800)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathOmittedWhenNil(t *testing.T) {
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc123",
|
||||
obsKeys: make(map[string]bool),
|
||||
}
|
||||
|
||||
m := txToMap(tx)
|
||||
if _, ok := m["resolved_path"]; ok {
|
||||
t.Error("resolved_path should not be in map when nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureResolvedPathColumn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec(`CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY, transmission_id INTEGER,
|
||||
observer_id TEXT, path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||||
)`)
|
||||
conn.Close()
|
||||
|
||||
if err := ensureResolvedPathColumn(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify column exists
|
||||
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
|
||||
defer conn.Close()
|
||||
rows, _ := conn.Query("PRAGMA table_info(observations)")
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk)
|
||||
if colName == "resolved_path" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if !found {
|
||||
t.Error("resolved_path column not added")
|
||||
}
|
||||
|
||||
// Running again should be idempotent
|
||||
if err := ensureResolvedPathColumn(dbPath); err != nil {
|
||||
t.Fatal("second call should be idempotent:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBDetectsResolvedPathColumn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create DB without resolved_path
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY, observer_idx INTEGER)`)
|
||||
conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY)`)
|
||||
conn.Close()
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db.hasResolvedPath {
|
||||
t.Error("should not detect resolved_path when column missing")
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Add resolved_path column
|
||||
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec("ALTER TABLE observations ADD COLUMN resolved_path TEXT")
|
||||
conn.Close()
|
||||
|
||||
db, err = OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.hasResolvedPath {
|
||||
t.Error("should detect resolved_path when column exists")
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
|
||||
func TestLoadWithResolvedPath(t *testing.T) {
|
||||
db, dbPath := createTestDBWithSchema(t)
|
||||
defer db.Close()
|
||||
|
||||
// Insert test data
|
||||
rw, _ := openRW(dbPath)
|
||||
rw.Exec(`INSERT INTO transmissions (id, hash, first_seen, payload_type, decoded_json)
|
||||
VALUES (1, 'hash1', '2024-01-01T00:00:00Z', 4, '{"pubKey":"origpk"}')`)
|
||||
rw.Exec(`INSERT INTO observations (id, transmission_id, observer_id, observer_name, path_json, timestamp, resolved_path)
|
||||
VALUES (1, 1, 'obs1', 'Observer1', '["aa"]', '2024-01-01T00:00:00Z', '["aabbccdd"]')`)
|
||||
rw.Close()
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(store.packets) != 1 {
|
||||
t.Fatalf("expected 1 packet, got %d", len(store.packets))
|
||||
}
|
||||
|
||||
tx := store.packets[0]
|
||||
if len(tx.Observations) != 1 {
|
||||
t.Fatalf("expected 1 observation, got %d", len(tx.Observations))
|
||||
}
|
||||
|
||||
// After #800, ResolvedPath is not stored on StoreObs struct.
|
||||
// Instead, resolved pubkeys are in the membership index.
|
||||
_ = tx.Observations[0] // obs exists
|
||||
h := resolvedPubkeyHash("aabbccdd")
|
||||
if len(store.resolvedPubkeyIndex[h]) != 1 {
|
||||
t.Fatal("expected resolved pubkey to be indexed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathInAPIResponse(t *testing.T) {
|
||||
// After #800, TransmissionResp no longer has ResolvedPath field.
|
||||
// resolved_path is included dynamically in map-based API responses.
|
||||
resp := TransmissionResp{
|
||||
ID: 1,
|
||||
Hash: "test",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal(data, &m)
|
||||
|
||||
// resolved_path should NOT be in the marshaled JSON
|
||||
if _, ok := m["resolved_path"]; ok {
|
||||
t.Error("resolved_path should not be in TransmissionResp JSON (#800)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathOmittedWhenEmpty(t *testing.T) {
|
||||
resp := TransmissionResp{
|
||||
ID: 1,
|
||||
Hash: "test",
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(resp)
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal(data, &m)
|
||||
|
||||
if _, ok := m["resolved_path"]; ok {
|
||||
t.Error("resolved_path should be omitted when nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_AdvertNoPath(t *testing.T) {
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey":"aaaa1111"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
obs := &StoreObs{
|
||||
ObserverID: "bbbb2222",
|
||||
PathJSON: "",
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
edges := extractEdgesFromObs(obs, tx, nil)
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge for zero-hop advert, got %d", len(edges))
|
||||
}
|
||||
// Canonical ordering: aaaa < bbbb
|
||||
if edges[0].A != "aaaa1111" || edges[0].B != "bbbb2222" {
|
||||
t.Errorf("unexpected edge: %+v", edges[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_NonAdvertNoPath(t *testing.T) {
|
||||
tx := &StoreTx{PayloadType: intPtr(1)}
|
||||
obs := &StoreObs{ObserverID: "obs1", PathJSON: ""}
|
||||
edges := extractEdgesFromObs(obs, tx, nil)
|
||||
if len(edges) != 0 {
|
||||
t.Errorf("expected 0 edges for non-advert without path, got %d", len(edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_WithPath(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{Role: "repeater", PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
|
||||
{Role: "repeater", PublicKey: "ffgghhii1234567890aabbccddee1234567890aabbccddee1234567890aabb11", Name: "Node-FF"},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey":"originator00"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
obs := &StoreObs{
|
||||
ObserverID: "observer00",
|
||||
PathJSON: `["aa","ff"]`,
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
edges := extractEdgesFromObs(obs, tx, pm)
|
||||
// Should get: originator↔aa (advert), observer↔ff (last hop)
|
||||
if len(edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_SameNodeNoEdge(t *testing.T) {
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey":"same1234"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
obs := &StoreObs{
|
||||
ObserverID: "same1234",
|
||||
PathJSON: "",
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
edges := extractEdgesFromObs(obs, tx, nil)
|
||||
if len(edges) != 0 {
|
||||
t.Errorf("expected 0 edges when originator == observer, got %d", len(edges))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func TestPersistSemaphoreTryAcquireSkipsBatch(t *testing.T) {
|
||||
// Verify that persistSem is a buffered channel of size 1.
|
||||
if cap(persistSem) != 1 {
|
||||
t.Errorf("persistSem capacity = %d, want 1", cap(persistSem))
|
||||
}
|
||||
// Acquire the semaphore to simulate an in-progress persistence.
|
||||
persistSem <- struct{}{}
|
||||
|
||||
// asyncPersistResolvedPathsAndEdges should skip (not block, not
|
||||
// spawn a goroutine) when the semaphore is already held.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
asyncPersistResolvedPathsAndEdges(
|
||||
"/nonexistent/path.db",
|
||||
[]persistObsUpdate{{obsID: 1, resolvedPath: "x"}},
|
||||
nil,
|
||||
"test",
|
||||
)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// If the function blocks on the semaphore instead of skipping,
|
||||
// this select will hit the timeout.
|
||||
select {
|
||||
case <-done:
|
||||
// Expected: returned immediately because semaphore was busy.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
<-persistSem
|
||||
t.Fatal("asyncPersistResolvedPathsAndEdges blocked instead of skipping when semaphore was held")
|
||||
}
|
||||
|
||||
<-persistSem // release
|
||||
}
|
||||
|
||||
func TestOpenRW_BusyTimeout(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create the DB file first
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Exec("CREATE TABLE dummy (id INTEGER)")
|
||||
db.Close()
|
||||
|
||||
// Open via openRW and verify busy_timeout is set
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("openRW failed: %v", err)
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
var timeout int
|
||||
if err := rw.QueryRow("PRAGMA busy_timeout").Scan(&timeout); err != nil {
|
||||
t.Fatalf("query busy_timeout: %v", err)
|
||||
}
|
||||
if timeout != 5000 {
|
||||
t.Errorf("expected busy_timeout=5000, got %d", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLastPacketAtColumn(t *testing.T) {
|
||||
// Create a temp DB with observers table missing last_packet_at
|
||||
dir := t.TempDir()
|
||||
dbPath := dir + "/test.db"
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE TABLE observers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
last_seen TEXT,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
inactive INTEGER DEFAULT 0
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// First call: should add the column
|
||||
if err := ensureLastPacketAtColumn(dbPath); err != nil {
|
||||
t.Fatalf("first call failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify column exists
|
||||
db2, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db2.Close()
|
||||
|
||||
var found bool
|
||||
rows, err := db2.Query("PRAGMA table_info(observers)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "last_packet_at" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("last_packet_at column not found after migration")
|
||||
}
|
||||
|
||||
// Idempotency: second call should succeed without error
|
||||
if err := ensureLastPacketAtColumn(dbPath); err != nil {
|
||||
t.Fatalf("idempotent call failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureObserverIATAColumn validates the #1189 R1 fix: an operator with
|
||||
// a pre-iata observers schema (no `iata TEXT` column) must not panic on
|
||||
// startup. The migration must idempotently ALTER TABLE ADD COLUMN, and
|
||||
// queries that COALESCE(obs.iata, '') must succeed after the migration runs.
|
||||
func TestEnsureObserverIATAColumn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := dir + "/test.db"
|
||||
|
||||
// Pre-iata schema (matches what shipped before #1188 landed).
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE TABLE observers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
last_seen TEXT,
|
||||
first_seen TEXT,
|
||||
packet_count INTEGER DEFAULT 0,
|
||||
inactive INTEGER DEFAULT 0,
|
||||
last_packet_at TEXT DEFAULT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO observers (id, name) VALUES ('obs1', 'Observer One')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Prove the bug exists pre-migration: a SELECT that COALESCEs obs.iata must fail.
|
||||
db0, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var probe string
|
||||
preErr := db0.QueryRow(`SELECT COALESCE(iata, '') FROM observers WHERE id='obs1'`).Scan(&probe)
|
||||
db0.Close()
|
||||
if preErr == nil {
|
||||
t.Fatal("expected SELECT on missing iata column to fail BEFORE migration; got success")
|
||||
}
|
||||
|
||||
// First call: should add the column.
|
||||
if err := ensureObserverIATAColumn(dbPath); err != nil {
|
||||
t.Fatalf("first call failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify column exists.
|
||||
db2, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db2.Close()
|
||||
|
||||
var found bool
|
||||
rows, err := db2.Query("PRAGMA table_info(observers)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "iata" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if !found {
|
||||
t.Fatal("iata column not found after migration")
|
||||
}
|
||||
|
||||
// The query that previously panicked must now succeed (empty string default).
|
||||
if err := db2.QueryRow(`SELECT COALESCE(iata, '') FROM observers WHERE id='obs1'`).Scan(&probe); err != nil {
|
||||
t.Fatalf("post-migration SELECT failed: %v", err)
|
||||
}
|
||||
if probe != "" {
|
||||
t.Fatalf("expected empty iata for legacy row, got %q", probe)
|
||||
}
|
||||
|
||||
// Idempotency: second call must succeed.
|
||||
if err := ensureObserverIATAColumn(dbPath); err != nil {
|
||||
t.Fatalf("idempotent call failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package main: neighbor-graph snapshot recomputer (issue #1287).
|
||||
//
|
||||
// Per #1287 Option 4: the ingestor owns the neighbor_edges table —
|
||||
// it computes the graph from observations it ingests and persists
|
||||
// snapshots there. The server READS the snapshot and atomic-swaps
|
||||
// it into s.graph; that swap is exactly what this recomputer does.
|
||||
//
|
||||
// Cadence: 60s default. Staleness budget matches the existing
|
||||
// analytics recomputer (#1240) — operators already accept that
|
||||
// derived analytics lag the wire by tens of seconds.
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeighborGraphRecomputerDefaultInterval is how often the server
|
||||
// re-reads the neighbor_edges snapshot. 60s is the standard
|
||||
// staleness budget for derived analytics (#1240 / #1262 / #672 axis 2).
|
||||
const NeighborGraphRecomputerDefaultInterval = 60 * time.Second
|
||||
|
||||
var (
|
||||
neighborRecompStartedMu sync.Mutex
|
||||
neighborRecompStarted bool
|
||||
)
|
||||
|
||||
// StartNeighborGraphRecomputer launches the background goroutine that
|
||||
// re-reads neighbor_edges every `interval` and atomic-swaps the
|
||||
// resulting NeighborGraph into s.graph. Idempotent — subsequent calls
|
||||
// are no-ops and return a no-op stop closure.
|
||||
//
|
||||
// Server NEVER writes to neighbor_edges; the ingestor owns those
|
||||
// writes per #1287. This recomputer is the ONLY thing that updates
|
||||
// s.graph at steady state (the initial startup load in main.go is the
|
||||
// other writer to s.graph, only at boot).
|
||||
func (s *PacketStore) StartNeighborGraphRecomputer(interval time.Duration) func() {
|
||||
if interval <= 0 {
|
||||
interval = NeighborGraphRecomputerDefaultInterval
|
||||
}
|
||||
|
||||
neighborRecompStartedMu.Lock()
|
||||
if neighborRecompStarted {
|
||||
neighborRecompStartedMu.Unlock()
|
||||
return func() {}
|
||||
}
|
||||
neighborRecompStarted = true
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
neighborRecompStartedMu.Unlock()
|
||||
|
||||
var stopOnce sync.Once
|
||||
go func() {
|
||||
defer close(done)
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
s.refreshNeighborGraphFromSnapshot()
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
stopOnce.Do(func() { close(stop) })
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshNeighborGraphFromSnapshot re-reads neighbor_edges through
|
||||
// the read-only DB handle and atomic-swaps a freshly built graph.
|
||||
// Panics are swallowed defensively — the previous snapshot remains
|
||||
// valid if a read fails.
|
||||
func (s *PacketStore) refreshNeighborGraphFromSnapshot() {
|
||||
defer func() { _ = recover() }()
|
||||
if s.db == nil || s.db.conn == nil {
|
||||
return
|
||||
}
|
||||
g := loadNeighborEdgesFromDB(s.db.conn)
|
||||
if g != nil {
|
||||
s.graph.Store(g)
|
||||
}
|
||||
}
|
||||
|
||||
// resetNeighborRecomputerForTest is a test helper — production code
|
||||
// MUST NOT call this.
|
||||
func resetNeighborRecomputerForTest() {
|
||||
neighborRecompStartedMu.Lock()
|
||||
neighborRecompStarted = false
|
||||
neighborRecompStartedMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestNeighborGraphRecomputerLoadsSnapshot enforces #1287 Option 4:
|
||||
// the server LOADS its in-memory neighbor graph from the SQLite
|
||||
// snapshot the ingestor writes. After a write to neighbor_edges (here
|
||||
// done synthetically), the recomputer's atomic-swap must reflect it.
|
||||
func TestNeighborGraphRecomputerLoadsSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "neighbor_recomp.db")
|
||||
|
||||
// Bootstrap a WAL DB with the neighbor_edges table.
|
||||
rw, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rw.Close()
|
||||
if _, err := rw.Exec(`CREATE TABLE neighbor_edges (
|
||||
node_a TEXT NOT NULL,
|
||||
node_b TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
last_seen TEXT,
|
||||
PRIMARY KEY (node_a, node_b)
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Stage one edge.
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := rw.Exec(
|
||||
`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, ?, ?)`,
|
||||
"aaa", "bbb", 5, now,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Server opens read-only and refreshes via the recomputer.
|
||||
d, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer d.conn.Close()
|
||||
store := &PacketStore{db: d}
|
||||
store.graph.Store(NewNeighborGraph())
|
||||
|
||||
store.refreshNeighborGraphFromSnapshot()
|
||||
g := store.graph.Load()
|
||||
if g == nil {
|
||||
t.Fatal("graph nil after refresh")
|
||||
}
|
||||
if got := len(g.AllEdges()); got != 1 {
|
||||
t.Fatalf("expected 1 edge after first refresh, got %d", got)
|
||||
}
|
||||
|
||||
// Add another row, refresh, assert the new total.
|
||||
if _, err := rw.Exec(
|
||||
`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, ?, ?)`,
|
||||
"ccc", "ddd", 2, now,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.refreshNeighborGraphFromSnapshot()
|
||||
g = store.graph.Load()
|
||||
if got := len(g.AllEdges()); got != 2 {
|
||||
t.Fatalf("expected 2 edges after second refresh, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerStartupRequiresMigratedSchema enforces #1287: the server
|
||||
// MUST refuse to start if the ingestor hasn't run schema migrations.
|
||||
// AssertReady on a DB missing the required columns returns an error
|
||||
// listing every missing surface; main.go then calls log.Fatalf.
|
||||
func TestServerStartupRequiresMigratedSchema(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "unmigrated.db")
|
||||
|
||||
// Bootstrap with ONLY transmissions/observations (the things
|
||||
// server tries to read) but WITHOUT the columns dbschema asserts
|
||||
// (resolved_path, inactive, last_packet_at, iata, foreign_advert,
|
||||
// from_pubkey, neighbor_edges).
|
||||
rw, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rw.Close()
|
||||
for _, s := range []string{
|
||||
`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT, payload_type INTEGER)`,
|
||||
`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER)`,
|
||||
`CREATE TABLE observers (id TEXT PRIMARY KEY, name TEXT)`,
|
||||
`CREATE TABLE nodes (public_key TEXT PRIMARY KEY)`,
|
||||
`CREATE TABLE inactive_nodes (public_key TEXT PRIMARY KEY)`,
|
||||
} {
|
||||
if _, err := rw.Exec(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Open the read-only server handle and call AssertReady directly
|
||||
// (production path: main.go does this before any business logic).
|
||||
d, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer d.conn.Close()
|
||||
|
||||
// The package-level dbschema.AssertReady requires every missing
|
||||
// surface to be reported. We hit it directly through the same
|
||||
// path main.go uses.
|
||||
if err := assertReadyForTest(d); err == nil {
|
||||
t.Fatal("expected AssertReady to fail against an unmigrated DB; server would have started against an incomplete schema")
|
||||
}
|
||||
}
|
||||
|
||||
// assertReadyForTest is the same call main.go makes — declared here so
|
||||
// the test stays decoupled from any future inlining or rename.
|
||||
func assertReadyForTest(d *DB) error {
|
||||
return dbschemaAssertReadyShim(d)
|
||||
}
|
||||
|
||||
// dbschemaAssertReadyShim wraps the package import so tests don't
|
||||
// directly depend on the import being present (production wires it
|
||||
// via main.go).
|
||||
func dbschemaAssertReadyShim(d *DB) error { return dbschema.AssertReady(d.conn) }
|
||||
@@ -3,12 +3,63 @@ package main
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestServerSourceHasNoCachedRWCalls enforces issue #1287: after the
|
||||
// follow-up to #1283, cmd/server/ must contain ZERO writer call sites.
|
||||
// Specifically, no `cachedRW(`, no `mode=rw`, and no `sql.Open(...rw...)`
|
||||
// in non-test source files. All schema migrations, backfills, and
|
||||
// neighbor-edge persistence must live in cmd/ingestor or a shared
|
||||
// package — the server is the read path.
|
||||
func TestServerSourceHasNoCachedRWCalls(t *testing.T) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("read cmd/server dir: %v", err)
|
||||
}
|
||||
// Patterns that indicate write-side DB usage on the server.
|
||||
patterns := []*regexp.Regexp{
|
||||
regexp.MustCompile(`\bcachedRW\s*\(`),
|
||||
regexp.MustCompile(`mode=rw`),
|
||||
regexp.MustCompile(`sql\.Open\([^)]*\?[^)]*_journal_mode=WAL[^)]*\)`),
|
||||
}
|
||||
violations := []string{}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(name, ".go") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(".", name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
for _, p := range patterns {
|
||||
if loc := p.FindIndex(b); loc != nil {
|
||||
// Get line number
|
||||
line := 1 + strings.Count(string(b[:loc[0]]), "\n")
|
||||
violations = append(violations, fmt.Sprintf("%s:%d: %s", name, line, p.String()))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(violations) > 0 {
|
||||
t.Errorf("cmd/server/ contains forbidden writer call sites (#1287):\n %s",
|
||||
strings.Join(violations, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerDBHasNoWriteMethods enforces the architectural invariant from
|
||||
// issue #1283: cmd/server is the read path. All write/maintenance methods
|
||||
// (PruneOldPackets, PruneOldMetrics, RemoveStaleObservers) MUST live on
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// rwCache holds a process-wide cached RW connection per database path.
|
||||
// Instead of opening and closing a new RW connection on every call to openRW,
|
||||
// we cache a single *sql.DB (which internally manages one connection due to
|
||||
// SetMaxOpenConns(1)). This eliminates repeated open/close overhead for
|
||||
// vacuum, prune, persist operations that run frequently (#921).
|
||||
var rwCache = struct {
|
||||
mu sync.Mutex
|
||||
conns map[string]*sql.DB
|
||||
}{conns: make(map[string]*sql.DB)}
|
||||
|
||||
// cachedRW returns a cached read-write connection for the given dbPath.
|
||||
// The connection is created on first call and reused thereafter.
|
||||
// Callers MUST NOT call Close() on the returned *sql.DB.
|
||||
func cachedRW(dbPath string) (*sql.DB, error) {
|
||||
rwCache.mu.Lock()
|
||||
defer rwCache.mu.Unlock()
|
||||
|
||||
if db, ok := rwCache.conns[dbPath]; ok {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL", dbPath)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("set busy_timeout: %w", err)
|
||||
}
|
||||
rwCache.conns[dbPath] = db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// closeRWCache closes all cached RW connections (for tests/shutdown).
|
||||
func closeRWCache() {
|
||||
rwCache.mu.Lock()
|
||||
defer rwCache.mu.Unlock()
|
||||
for k, db := range rwCache.conns {
|
||||
db.Close()
|
||||
delete(rwCache.conns, k)
|
||||
}
|
||||
}
|
||||
|
||||
// rwCacheLen returns the number of cached connections (for testing).
|
||||
func rwCacheLen() int {
|
||||
rwCache.mu.Lock()
|
||||
defer rwCache.mu.Unlock()
|
||||
return len(rwCache.conns)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCachedRW_ReturnsSameHandle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create the DB file
|
||||
f, _ := os.Create(dbPath)
|
||||
f.Close()
|
||||
|
||||
defer closeRWCache()
|
||||
|
||||
db1, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("first cachedRW: %v", err)
|
||||
}
|
||||
db2, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("second cachedRW: %v", err)
|
||||
}
|
||||
if db1 != db2 {
|
||||
t.Fatalf("cachedRW returned different handles: %p vs %p", db1, db2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedRW_100Calls_SingleConnection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
f, _ := os.Create(dbPath)
|
||||
f.Close()
|
||||
|
||||
defer closeRWCache()
|
||||
|
||||
var first interface{}
|
||||
for i := 0; i < 100; i++ {
|
||||
db, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
if i == 0 {
|
||||
first = db
|
||||
} else if db != first {
|
||||
t.Fatalf("call %d returned different handle", i)
|
||||
}
|
||||
}
|
||||
if rwCacheLen() != 1 {
|
||||
t.Fatalf("expected 1 cached connection, got %d", rwCacheLen())
|
||||
}
|
||||
}
|
||||
+15
-67
@@ -2380,41 +2380,13 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
s.invalidateCachesFor(inv)
|
||||
}
|
||||
|
||||
// Persist resolved paths and neighbor edges asynchronously (don't block ingest).
|
||||
if len(broadcastTxs) > 0 && s.db != nil {
|
||||
dbPath := s.db.path
|
||||
var obsUpdates []persistObsUpdate
|
||||
var edgeUpdates []persistEdgeUpdate
|
||||
|
||||
_, pm := s.getCachedNodesAndPM()
|
||||
// graph is *atomic.Pointer[NeighborGraph]; the Load itself is
|
||||
// lock-free. (Earlier comment claimed "set during startup, not
|
||||
// replaced after" — that's no longer true: #1203 made rebuilds
|
||||
// async via ensureNeighborGraph. Dropping the dead s.mu RLock
|
||||
// wrap — review PR #1208.)
|
||||
graphRef := s.graph.Load()
|
||||
for _, tx := range broadcastTxs {
|
||||
for _, obs := range tx.Observations {
|
||||
// Use decode-window resolved path for persist
|
||||
if broadcastRP != nil {
|
||||
if rp, ok := broadcastRP[obs.ID]; ok && rp != nil {
|
||||
rpJSON := marshalResolvedPath(rp)
|
||||
if rpJSON != "" {
|
||||
obsUpdates = append(obsUpdates, persistObsUpdate{obs.ID, rpJSON})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ec := range extractEdgesFromObs(obs, tx, pm) {
|
||||
edgeUpdates = append(edgeUpdates, persistEdgeUpdate{ec.A, ec.B, ec.Timestamp})
|
||||
if graphRef != nil {
|
||||
graphRef.upsertEdge(ec.A, ec.B, "", obs.ObserverID, obs.SNR, parseTimestamp(ec.Timestamp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
asyncPersistResolvedPathsAndEdges(dbPath, obsUpdates, edgeUpdates, "persist")
|
||||
}
|
||||
// Per #1287 (Option 4): the server NEVER writes to the DB and
|
||||
// NEVER mutates the in-memory neighbor graph incrementally. The
|
||||
// ingestor owns neighbor_edges; recompNeighborGraph re-reads the
|
||||
// snapshot every 60s and atomic-swaps it into s.graph. We also no
|
||||
// longer persist resolved_path here — the ingestor (which already
|
||||
// sees every observation) owns that write too.
|
||||
_ = broadcastRP // resolved path is still computed in-memory (above) for live broadcast; no SQL write.
|
||||
|
||||
return result, newMaxID
|
||||
}
|
||||
@@ -2727,38 +2699,14 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
})
|
||||
}
|
||||
|
||||
// Persist resolved paths and neighbor edges asynchronously (review fix #3).
|
||||
// Only process NEW observations — not all observations of each updated tx —
|
||||
// to avoid edge count inflation and unnecessary UPDATEs for pre-existing data.
|
||||
if len(newObs) > 0 && s.db != nil {
|
||||
dbPath := s.db.path
|
||||
var obsUpdates []persistObsUpdate
|
||||
var edgeUpdates []persistEdgeUpdate
|
||||
|
||||
for _, obs := range newObs {
|
||||
tx := s.byTxID[obs.TransmissionID]
|
||||
if tx == nil {
|
||||
continue
|
||||
}
|
||||
// Use decode-window resolved path for persist
|
||||
if obsRPMap != nil {
|
||||
if rp, ok := obsRPMap[obs.ID]; ok && rp != nil {
|
||||
rpJSON := marshalResolvedPath(rp)
|
||||
if rpJSON != "" {
|
||||
obsUpdates = append(obsUpdates, persistObsUpdate{obs.ID, rpJSON})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ec := range extractEdgesFromObs(obs, tx, pm) {
|
||||
edgeUpdates = append(edgeUpdates, persistEdgeUpdate{ec.A, ec.B, ec.Timestamp})
|
||||
if graphRef != nil {
|
||||
graphRef.upsertEdge(ec.A, ec.B, "", obs.ObserverID, obs.SNR, parseTimestamp(ec.Timestamp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
asyncPersistResolvedPathsAndEdges(dbPath, obsUpdates, edgeUpdates, "obs-persist")
|
||||
}
|
||||
// Per #1287 (Option 4): server never writes to the DB and never
|
||||
// mutates the in-memory neighbor graph incrementally — the
|
||||
// ingestor owns both. recompNeighborGraph re-reads the snapshot
|
||||
// every 60s and atomic-swaps into s.graph.
|
||||
_ = obsRPMap // resolved path stays in-memory for broadcast; no SQL write.
|
||||
_ = newObs
|
||||
_ = pm
|
||||
_ = graphRef
|
||||
|
||||
return broadcastMaps
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// Package dbschema centralizes schema migrations and read-side schema
|
||||
// assertions for the CoreScope SQLite DB. Per issue #1287 the writer
|
||||
// (cmd/ingestor) owns ALL CREATE/ALTER/INSERT/UPDATE/DELETE on schema
|
||||
// objects; the server (cmd/server) only ASSERTS that the schema is in
|
||||
// the expected shape and refuses to start otherwise.
|
||||
//
|
||||
// Apply(rw, log) runs from the ingestor at startup BEFORE subscribing to
|
||||
// MQTT. AssertReady(ro) runs from the server at startup and returns an
|
||||
// error listing every missing column/index/table.
|
||||
package dbschema
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Logger is the minimal logging surface used by Apply. Both cmd/server
|
||||
// and cmd/ingestor satisfy this with the stdlib `log` package's Printf
|
||||
// (passed as a closure to avoid an indirect log dependency here).
|
||||
type Logger func(format string, args ...interface{})
|
||||
|
||||
// Apply runs every server-side ensure_* migration against the given
|
||||
// read-write SQLite connection. Each operation is idempotent
|
||||
// (IF NOT EXISTS / column-probe-before-ALTER). Safe to call repeatedly.
|
||||
//
|
||||
// Called by the ingestor at startup. The server MUST NOT call this —
|
||||
// it only calls AssertReady.
|
||||
func Apply(rw *sql.DB, logf Logger) error {
|
||||
if logf == nil {
|
||||
logf = func(string, ...interface{}) {}
|
||||
}
|
||||
if err := ensureServerIndexes(rw); err != nil {
|
||||
return fmt.Errorf("ensure server indexes: %w", err)
|
||||
}
|
||||
if err := ensureNeighborEdgesTable(rw); err != nil {
|
||||
return fmt.Errorf("ensure neighbor_edges: %w", err)
|
||||
}
|
||||
if err := ensureInactiveNodesTable(rw); err != nil {
|
||||
return fmt.Errorf("ensure inactive_nodes: %w", err)
|
||||
}
|
||||
if err := ensureResolvedPathColumn(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure resolved_path: %w", err)
|
||||
}
|
||||
if err := ensureObserverInactiveColumn(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure observers.inactive: %w", err)
|
||||
}
|
||||
if err := ensureLastPacketAtColumn(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure observers.last_packet_at: %w", err)
|
||||
}
|
||||
if err := ensureObserverIATAColumn(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure observers.iata: %w", err)
|
||||
}
|
||||
if err := ensureForeignAdvertColumn(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure foreign_advert: %w", err)
|
||||
}
|
||||
if err := ensureFromPubkeyColumn(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure from_pubkey: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssertReady verifies the schema is in the expected shape. The server
|
||||
// calls this at startup against a read-only connection; if it returns
|
||||
// non-nil, the server MUST fatal-log and exit so the operator restarts
|
||||
// the ingestor (which owns migrations).
|
||||
func AssertReady(ro *sql.DB) error {
|
||||
var missing []string
|
||||
|
||||
mustCol := func(table, col string) {
|
||||
has, err := TableHasColumn(ro, table, col)
|
||||
if err != nil {
|
||||
missing = append(missing, fmt.Sprintf("%s.%s (probe error: %v)", table, col, err))
|
||||
return
|
||||
}
|
||||
if !has {
|
||||
missing = append(missing, fmt.Sprintf("%s.%s", table, col))
|
||||
}
|
||||
}
|
||||
mustTable := func(name string) {
|
||||
var n int
|
||||
err := ro.QueryRow(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
missing = append(missing, "table:"+name)
|
||||
} else if err != nil {
|
||||
missing = append(missing, fmt.Sprintf("table:%s (probe error: %v)", name, err))
|
||||
}
|
||||
}
|
||||
|
||||
mustTable("neighbor_edges")
|
||||
mustCol("observations", "resolved_path")
|
||||
mustCol("observers", "inactive")
|
||||
mustCol("observers", "last_packet_at")
|
||||
mustCol("observers", "iata")
|
||||
mustCol("nodes", "foreign_advert")
|
||||
mustCol("inactive_nodes", "foreign_advert")
|
||||
mustCol("transmissions", "from_pubkey")
|
||||
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("schema not migrated by ingestor; restart ingestor first. missing: %s",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TableHasColumn reports whether the given table has the given column.
|
||||
// Exported because tests and the read-side need it without re-implementing.
|
||||
func TableHasColumn(db *sql.DB, table, column string) (bool, error) {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name string
|
||||
var ctype sql.NullString
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
// ─── ensure_* helpers (writer side) ────────────────────────────────────────
|
||||
|
||||
func ensureServerIndexes(rw *sql.DB) error {
|
||||
stmts := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_transmissions_first_seen ON transmissions(first_seen)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_transmissions_hash ON transmissions(hash)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := rw.Exec(s); err != nil {
|
||||
return fmt.Errorf("ensure index %q: %w", s, err)
|
||||
}
|
||||
}
|
||||
// observer_idx (v3) vs observer_id (v2) — probe + index the matching one.
|
||||
hasIdx, err := TableHasColumn(rw, "observations", "observer_idx")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasIdx {
|
||||
if _, err := rw.Exec(`CREATE INDEX IF NOT EXISTS idx_observations_observer_idx ON observations(observer_idx)`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
hasID, err := TableHasColumn(rw, "observations", "observer_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasID {
|
||||
if _, err := rw.Exec(`CREATE INDEX IF NOT EXISTS idx_observations_observer_id ON observations(observer_id)`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureNeighborEdgesTable(rw *sql.DB) error {
|
||||
_, err := rw.Exec(`CREATE TABLE IF NOT EXISTS neighbor_edges (
|
||||
node_a TEXT NOT NULL,
|
||||
node_b TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
last_seen TEXT,
|
||||
PRIMARY KEY (node_a, node_b)
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
|
||||
// ensureInactiveNodesTable creates the inactive_nodes table if missing.
|
||||
// The ingestor's applySchema also creates this table — duplicating it
|
||||
// here makes dbschema.Apply self-sufficient when called against a
|
||||
// fixture DB that pre-dates the soft-delete feature (e.g. CI's
|
||||
// test-fixtures/e2e-fixture.db, which never had any inactive rows).
|
||||
// Schema kept in sync with cmd/ingestor/db.go:applySchema.
|
||||
func ensureInactiveNodesTable(rw *sql.DB) error {
|
||||
_, err := rw.Exec(`CREATE TABLE IF NOT EXISTS inactive_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,
|
||||
battery_mv INTEGER,
|
||||
temperature_c REAL,
|
||||
foreign_advert INTEGER DEFAULT 0
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = rw.Exec(`CREATE INDEX IF NOT EXISTS idx_inactive_nodes_last_seen ON inactive_nodes(last_seen)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureResolvedPathColumn(rw *sql.DB, logf Logger) error {
|
||||
has, err := TableHasColumn(rw, "observations", "resolved_path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return nil
|
||||
}
|
||||
if _, err := rw.Exec("ALTER TABLE observations ADD COLUMN resolved_path TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("[dbschema] added resolved_path column to observations")
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureObserverInactiveColumn(rw *sql.DB, logf Logger) error {
|
||||
has, err := TableHasColumn(rw, "observers", "inactive")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return nil
|
||||
}
|
||||
if _, err := rw.Exec("ALTER TABLE observers ADD COLUMN inactive INTEGER DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("[dbschema] added inactive column to observers")
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureLastPacketAtColumn(rw *sql.DB, logf Logger) error {
|
||||
has, err := TableHasColumn(rw, "observers", "last_packet_at")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return nil
|
||||
}
|
||||
if _, err := rw.Exec("ALTER TABLE observers ADD COLUMN last_packet_at TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("[dbschema] added last_packet_at column to observers")
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureObserverIATAColumn(rw *sql.DB, logf Logger) error {
|
||||
has, err := TableHasColumn(rw, "observers", "iata")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return nil
|
||||
}
|
||||
if _, err := rw.Exec("ALTER TABLE observers ADD COLUMN iata TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("[dbschema] added iata column to observers")
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureForeignAdvertColumn(rw *sql.DB, logf Logger) error {
|
||||
for _, table := range []string{"nodes", "inactive_nodes"} {
|
||||
has, err := TableHasColumn(rw, table, "foreign_advert")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s: %w", table, err)
|
||||
}
|
||||
if has {
|
||||
continue
|
||||
}
|
||||
if _, err := rw.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN foreign_advert INTEGER DEFAULT 0", table)); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("[dbschema] added foreign_advert column to %s", table)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureFromPubkeyColumn(rw *sql.DB, logf Logger) error {
|
||||
has, err := TableHasColumn(rw, "transmissions", "from_pubkey")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has {
|
||||
if _, err := rw.Exec("ALTER TABLE transmissions ADD COLUMN from_pubkey TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("[dbschema] added from_pubkey column to transmissions (#1143)")
|
||||
}
|
||||
if _, err := rw.Exec("CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDeleteBlacklistedObservers marks the given observer IDs as
|
||||
// inactive=1 (case-insensitive match). Returns count affected.
|
||||
// Writer-side helper; ingestor calls it at startup with the operator
|
||||
// blacklist (read from config).
|
||||
func SoftDeleteBlacklistedObservers(rw *sql.DB, blacklist []string) (int64, error) {
|
||||
placeholders := make([]string, 0, len(blacklist))
|
||||
args := make([]interface{}, 0, len(blacklist))
|
||||
for _, pk := range blacklist {
|
||||
t := strings.TrimSpace(pk)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
placeholders = append(placeholders, "LOWER(?)")
|
||||
args = append(args, t)
|
||||
}
|
||||
if len(placeholders) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
q := "UPDATE observers SET inactive = 1 WHERE LOWER(id) IN (" +
|
||||
strings.Join(placeholders, ",") + ") AND (inactive IS NULL OR inactive = 0)"
|
||||
res, err := rw.Exec(q, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/meshcore-analyzer/dbschema
|
||||
|
||||
go 1.22
|
||||
Reference in new issue
Block a user