mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-06 11:41:40 +00:00
9383201c07
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>
229 lines
6.4 KiB
Go
229 lines
6.4 KiB
Go
// 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"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ─── neighbor_edges loader (read-only) ─────────────────────────────────────────
|
|
|
|
// loadNeighborEdgesFromDB loads all edges from the neighbor_edges table
|
|
// 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()
|
|
|
|
rows, err := conn.Query("SELECT node_a, node_b, count, last_seen FROM neighbor_edges")
|
|
if err != nil {
|
|
log.Printf("[neighbor] failed to load neighbor_edges: %v", err)
|
|
return g
|
|
}
|
|
defer rows.Close()
|
|
|
|
count := 0
|
|
for rows.Next() {
|
|
var a, b string
|
|
var cnt int
|
|
var lastSeen sql.NullString
|
|
if err := rows.Scan(&a, &b, &cnt, &lastSeen); err != nil {
|
|
continue
|
|
}
|
|
ts := time.Time{}
|
|
if lastSeen.Valid {
|
|
ts = parseTimestamp(lastSeen.String)
|
|
}
|
|
key := makeEdgeKey(a, b)
|
|
g.mu.Lock()
|
|
e, exists := g.edges[key]
|
|
if !exists {
|
|
e = &NeighborEdge{
|
|
NodeA: key.A,
|
|
NodeB: key.B,
|
|
Observers: make(map[string]bool),
|
|
FirstSeen: ts,
|
|
LastSeen: ts,
|
|
Count: cnt,
|
|
}
|
|
g.edges[key] = e
|
|
g.byNode[key.A] = append(g.byNode[key.A], e)
|
|
g.byNode[key.B] = append(g.byNode[key.B], e)
|
|
} else {
|
|
e.Count += cnt
|
|
if ts.After(e.LastSeen) {
|
|
e.LastSeen = ts
|
|
}
|
|
}
|
|
g.mu.Unlock()
|
|
count++
|
|
}
|
|
|
|
if count > 0 {
|
|
g.mu.Lock()
|
|
g.builtAt = time.Now()
|
|
g.mu.Unlock()
|
|
log.Printf("[neighbor] loaded %d edges from neighbor_edges table", count)
|
|
}
|
|
|
|
return g
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return cnt > 0
|
|
}
|
|
|
|
// ─── resolved_path helpers (read-only / in-memory only) ────────────────────────
|
|
|
|
// 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
|
|
}
|
|
contextPKs := make([]string, 0, 3)
|
|
if observerID != "" {
|
|
contextPKs = append(contextPKs, strings.ToLower(observerID))
|
|
}
|
|
fromNode := extractFromNode(tx)
|
|
if fromNode != "" {
|
|
contextPKs = append(contextPKs, strings.ToLower(fromNode))
|
|
}
|
|
resolved := make([]*string, len(hops))
|
|
for i, hop := range hops {
|
|
ctx := make([]string, len(contextPKs), len(contextPKs)+2)
|
|
copy(ctx, contextPKs)
|
|
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 in-memory caching.
|
|
func marshalResolvedPath(rp []*string) string {
|
|
if len(rp) == 0 {
|
|
return ""
|
|
}
|
|
b, err := json.Marshal(rp)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// unmarshalResolvedPath parses a resolved_path JSON string.
|
|
func unmarshalResolvedPath(s string) []*string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
var result []*string
|
|
if json.Unmarshal([]byte(s), &result) != nil {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ─── Shared edge-extraction helper (used by ingestor + tests) ──────────────────
|
|
|
|
// 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).
|
|
//
|
|
// 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)
|
|
path := parsePathJSON(obs.PathJSON)
|
|
observerPK := strings.ToLower(obs.ObserverID)
|
|
ts := obs.Timestamp
|
|
var edges []edgeCandidate
|
|
|
|
if len(path) == 0 {
|
|
if isAdvert && fromNode != "" {
|
|
fromLower := strings.ToLower(fromNode)
|
|
if fromLower != observerPK {
|
|
a, b := fromLower, observerPK
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
edges = append(edges, edgeCandidate{a, b, ts})
|
|
}
|
|
}
|
|
return edges
|
|
}
|
|
|
|
if isAdvert && fromNode != "" && pm != nil {
|
|
firstHop := strings.ToLower(path[0])
|
|
fromLower := strings.ToLower(fromNode)
|
|
candidates := pm.m[firstHop]
|
|
if len(candidates) == 1 {
|
|
resolved := strings.ToLower(candidates[0].PublicKey)
|
|
if resolved != fromLower {
|
|
a, b := fromLower, resolved
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
edges = append(edges, edgeCandidate{a, b, ts})
|
|
}
|
|
}
|
|
}
|
|
|
|
if pm != nil {
|
|
lastHop := strings.ToLower(path[len(path)-1])
|
|
candidates := pm.m[lastHop]
|
|
if len(candidates) == 1 {
|
|
resolved := strings.ToLower(candidates[0].PublicKey)
|
|
if resolved != observerPK {
|
|
a, b := observerPK, resolved
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
edges = append(edges, edgeCandidate{a, b, ts})
|
|
}
|
|
}
|
|
}
|
|
|
|
return edges
|
|
}
|