mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 19:37:23 +00:00
Refs #2146 (item: `/api/nodes/:pubkey/paths`). ## Situation Per-step timing in a staging build (3 most-connected nodes, 2 calls each) showed where a `/paths` request spends its time: | Step | Time | |---|---| | build candidates under the read lock | 73-115 ms | | collision check: one `INSTR(LOWER(resolved_path), ?)` query per index hit, 13,367-16,790 per call | 1.3-2.7 s (first call after start: 13.5 s) | | canonical resolved_path per survivor (`bestResolvedPath`, one or two queries each) | 1.7-2.9 s | | aggregation under the read lock | 82-120 ms | So 3.2-4.1 s of a 3.4-5.1 s request was SQL. On a production database (busiest node, 27k transmissions, read-only): the collision check costs 1.85 s one by one and still 1.69 s batched, because SQLite reads and lowercases every stored path either way. Reading the canonical path (the longest observation's stored path) costs 0.18 s one by one and 0.09 s batched by id. ## My changes in this PR - **The collision check runs only where it decides something.** The aggregation keeps a transmission with a canonical path only if that path names the node (`if !containsTarget { continue }`). If no stored path names the node, the canonical one does not either. So for index hits with a canonical path the check changes nothing, and it now runs only for index hits without one, as before. - **Canonical paths are read in batches.** `loadCanonicalResolvedPaths` reads the stored path of each candidate's longest observation, one query per 500 transmissions. When that path is missing, empty or unparseable it calls `bestResolvedPath` for that transmission, so the result is the same as before. - The snapshot of observation IDs and `path_json` strings is taken in the existing candidate read lock (string copies, no parsing). The PR has two commits: a first version that batched both queries and did not get faster on staging, and the change described above, whose message has the measurements. Squash-merging is fine. ## Measurements Staging, after the ingestor's startup `ANALYZE`, same probe for both builds (ms): | | without this PR | with this PR | |---|---|---| | node 1 (degree 55), call 1 / call 2 | 22,237 / 3,364 | 1,793 / 1,686 | | node 2 (degree 47) | 4,427 / 3,615 | 1,828 / 1,809 | | node 3 (degree 46) | 5,171 / 4,035 | 3,050 / 2,408 | | 4 concurrent clients over 40 nodes, 120 s: calls / p50 / p95 | 73 / 6,717 / 15,160 | 125 / 2,861 / 10,630 | Both staging builds also contain #2147 and #2155. ## Tests - `TestLoadCanonicalResolvedPathsMatchesBestResolvedPath` compares the batched result with `bestResolvedPath` on seven cases: the #810 case (longest observation without a stored path), equal lengths, an empty stored path, case, an observation missing from the snapshot, longest wins, nothing stored. - `TestLoadCanonicalResolvedPathsBatchesQueries`: 1,001 transmissions take 3 batched queries and no per-transmission lookups. - `TestHandleNodePaths_CanonicalPathDecidesIndexHits`: the longest observation names A, a shorter one names B; both are index hits. A gets the transmission, B does not, with no per-transmission query. Run against master, the A/B results are the same and only the query-count check fails (1 there, 0 here). - The existing `/paths` tests pass unchanged (#810, #1278, #1352 collision and fallback cases, anchor bias). - `cmd/server`: `go test ./...` and `go test -race` green. ## Not in this PR - `nodeInResolvedPathViaIndex` still calls the per-transmission check; it has no production caller, only tests. - The concurrent p95 is still 10.6 s on staging. What is left there is the 4-connection read pool shared with other endpoints; a staging A/B with 8 connections gave no gain (`/paths` throughput dropped from 133-146 to 81-103 calls per 120 s), so the pool stays at 4. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
6.1 KiB
Go
174 lines
6.1 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
// #2146: /api/nodes/{pk}/paths looked up each candidate's canonical resolved
|
|
// path with bestResolvedPath, one or two queries per transmission. The batched
|
|
// loader must return the same path for every transmission.
|
|
|
|
func newPathsBatchStore(t *testing.T) (*PacketStore, map[int][]rpObs) {
|
|
t.Helper()
|
|
conn, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { conn.Close() })
|
|
if _, err := conn.Exec(`CREATE TABLE observations (
|
|
id INTEGER PRIMARY KEY, transmission_id INTEGER, path_json TEXT, resolved_path TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows := []struct {
|
|
id, tx int
|
|
path, rp interface{}
|
|
}{
|
|
// tx 1 (#810): the longest path has no stored resolved_path.
|
|
{1, 1, `["a","b","c"]`, nil},
|
|
{2, 1, `["a","b"]`, `["aa11aa11","bb22bb22"]`},
|
|
// tx 2: equal lengths, the first in snapshot order wins.
|
|
{3, 2, `["a","b"]`, `["cc33cc33","dd44dd44"]`},
|
|
{4, 2, `["a","c"]`, `["aa11aa11","ee55ee55"]`},
|
|
// tx 3: nothing stored.
|
|
{5, 3, `["a"]`, nil},
|
|
// tx 4: an empty stored path counts as none.
|
|
{6, 4, `["a"]`, ``},
|
|
// tx 5: the match is case-insensitive.
|
|
{7, 5, `["a","b"]`, `["AA11AA11","ff66ff66"]`},
|
|
// tx 6: observation 9 is in the database but not in the store's
|
|
// snapshot; it still counts for the mention, not for the best path.
|
|
{8, 6, `["a","b"]`, `["ff66ff66","bb22bb22"]`},
|
|
{9, 6, `["a","b","c"]`, `["aa11aa11","x","y"]`},
|
|
// tx 7: the longest path wins over an earlier shorter one.
|
|
{10, 7, `["a"]`, `["bb22bb22"]`},
|
|
{11, 7, `["a","b","c"]`, `["cc33cc33","aa11aa11","dd44dd44"]`},
|
|
}
|
|
for _, r := range rows {
|
|
if _, err := conn.Exec(`INSERT INTO observations (id, transmission_id, path_json, resolved_path) VALUES (?,?,?,?)`,
|
|
r.id, r.tx, r.path, r.rp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
snapshots := map[int][]rpObs{
|
|
1: {{1, `["a","b","c"]`}, {2, `["a","b"]`}},
|
|
2: {{3, `["a","b"]`}, {4, `["a","c"]`}},
|
|
3: {{5, `["a"]`}},
|
|
4: {{6, `["a"]`}},
|
|
5: {{7, `["a","b"]`}},
|
|
6: {{8, `["a","b"]`}},
|
|
7: {{10, `["a"]`}, {11, `["a","b","c"]`}},
|
|
}
|
|
return &PacketStore{db: &DB{conn: conn}}, snapshots
|
|
}
|
|
|
|
func TestLoadCanonicalResolvedPathsMatchesBestResolvedPath(t *testing.T) {
|
|
s, snapshots := newPathsBatchStore(t)
|
|
got := s.loadCanonicalResolvedPaths(snapshots)
|
|
for txID, snap := range snapshots {
|
|
want := s.bestResolvedPath(txID, snap)
|
|
if !reflect.DeepEqual(got[txID], want) {
|
|
t.Errorf("tx %d: batched %v, bestResolvedPath %v", txID, derefAll(got[txID]), derefAll(want))
|
|
}
|
|
if _, present := got[txID]; present != (want != nil) {
|
|
t.Errorf("tx %d: present in result = %v, want %v", txID, present, want != nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
func derefAll(rp []*string) []string {
|
|
out := make([]string, len(rp))
|
|
for i, p := range rp {
|
|
if p != nil {
|
|
out[i] = *p
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestLoadCanonicalResolvedPathsBatchesQueries(t *testing.T) {
|
|
conn, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
defer conn.Close()
|
|
if _, err := conn.Exec(`CREATE TABLE observations (
|
|
id INTEGER PRIMARY KEY, transmission_id INTEGER, path_json TEXT, resolved_path TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const n = 2*resolvedPathBatchSize + 1
|
|
snapshots := make(map[int][]rpObs, n)
|
|
for i := 1; i <= n; i++ {
|
|
conn.Exec(`INSERT INTO observations (id, transmission_id, path_json, resolved_path) VALUES (?,?,?,?)`,
|
|
i, i, `["a"]`, `["aa11aa11"]`)
|
|
snapshots[i] = []rpObs{{i, `["a"]`}}
|
|
}
|
|
s := &PacketStore{db: &DB{conn: conn}}
|
|
batched, single := 0, 0
|
|
s.cacheLoadHook = func(kind string) {
|
|
switch kind {
|
|
case "resolvedPathBatch":
|
|
batched++
|
|
case "resolvedPath":
|
|
single++
|
|
}
|
|
}
|
|
got := s.loadCanonicalResolvedPaths(snapshots)
|
|
if batched != 3 || single != 0 {
|
|
t.Errorf("want 3 batched and 0 single queries for %d transmissions, got %d and %d", n, batched, single)
|
|
}
|
|
if len(got) != n {
|
|
t.Errorf("want %d canonical paths, got %d", n, len(got))
|
|
}
|
|
}
|
|
|
|
// #2146: an index hit with a canonical path no longer gets the per-
|
|
// transmission SQL collision check; the aggregation decides from the
|
|
// canonical path, which is what it did after that check anyway. Here the
|
|
// longest observation (the canonical one) names A and a shorter one names B:
|
|
// both A and B are index hits, only A is on the canonical path.
|
|
func TestHandleNodePaths_CanonicalPathDecidesIndexHits(t *testing.T) {
|
|
sc := setupCollisionScenario(t, false)
|
|
mustExec(t, sc.db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (43, 'BEEF', 'hash_2146', ?)`, sc.recent)
|
|
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
|
VALUES (43, NULL, '["c0","aa"]', ?, ?)`, sc.recentEpoch, `["`+sc.nodeAPK+`","aaaa0000aaaa0000"]`)
|
|
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
|
VALUES (43, NULL, '["c0"]', ?, ?)`, sc.recentEpoch-10, `["`+sc.nodeBPK+`"]`)
|
|
sc.reloadStore(t)
|
|
|
|
queries := 0
|
|
sc.srv.store.cacheLoadHook = func(kind string) {
|
|
if kind == "resolvedPath" {
|
|
queries++
|
|
}
|
|
}
|
|
if got := sc.query(t, sc.nodeAPK).TotalTransmissions; got != 1 {
|
|
t.Errorf("A is on the canonical path: want 1 transmission, got %d", got)
|
|
}
|
|
if got := sc.query(t, sc.nodeBPK).TotalTransmissions; got != 0 {
|
|
t.Errorf("B is only on a non-canonical observation: want 0 transmissions, got %d", got)
|
|
}
|
|
if queries != 0 {
|
|
t.Errorf("want no per-transmission resolved_path queries, got %d", queries)
|
|
}
|
|
}
|
|
|
|
// An observation already in the resolved-path LRU is served from it, as
|
|
// bestResolvedPath does, even when the stored path changed since.
|
|
func TestLoadCanonicalResolvedPathsUsesLRU(t *testing.T) {
|
|
s, snapshots := newPathsBatchStore(t)
|
|
s.apiResolvedPathLRU = make(map[int][]*string)
|
|
cached := "cached00cached00"
|
|
s.lruMu.Lock()
|
|
s.lruPut(11, []*string{&cached}) // longest observation of tx 7
|
|
s.lruMu.Unlock()
|
|
got := s.loadCanonicalResolvedPaths(map[int][]rpObs{7: snapshots[7]})
|
|
want := s.bestResolvedPath(7, snapshots[7])
|
|
if !reflect.DeepEqual(got[7], want) || len(want) != 1 || *want[0] != cached {
|
|
t.Fatalf("batched %v, bestResolvedPath %v, want the LRU entry", derefAll(got[7]), derefAll(want))
|
|
}
|
|
}
|