mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 22:09:56 +00:00
## Problem `spTxIndex` kept every 2–8 hop run of every stored path as a map key, with one transmission pointer per occurrence. On a busy regional network's 7-day window (59k transmissions) that's about 0.9–1.07 M keys and 130–160 MB of live heap, roughly a quarter of the store. It was also rebuilt at the end of every startup, which is when memory peaks. Two smaller issues came with it: - Eviction never decremented `spTotalPaths`, so the percentages were divided by paths that were no longer stored. - Route Patterns took 1.2–3.6 s uncached, because the fast path still split, resolved and re-joined about a million raw keys per request. ## Change - **Count on request.** Subpaths are counted when the analytics are asked for, from the stored paths. Results stay cached for `rfCacheTTL`, as before. - **Pointer-free tallies.** `tallyNamedSubpaths` counts runs in one pointer-free `map[uint64]subpathTally`, keyed by a `maphash` of the resolved name built in a reused buffer. The GC doesn't scan the map, and counting allocates nothing but the map. - **Names only for the top entries.** Each tally records the first transmission and offset where its run appeared, so names and raw hops are built only for each group's top entries. `topSubpathTallies` picks those with a `container/heap` min-heap of `limit` entries instead of sorting every name, and the existing `rankSubpaths` formats the response, so no new response maps are added. - **Hash collisions.** If two names shared a hash their counts would merge; the odds are about 1 in 10⁷ for a few million names. - **Subpath detail.** `GetSubpathDetail` finds the run with `txsWithSubpath`, a scan of the stored paths. - **Counts unchanged:** runs of 2–8 hops, counted once per occurrence, case-folded. The tests pin this. - **Totals.** `spTotalPaths` stays, and eviction now decrements it. - **Memory estimate.** The O(path²) subpath term is dropped from the estimate. ## Perf justification (AGENTS.md rule 0) - **Complexity.** A cold Route Patterns request is O(Σ runs), about 1.5 M for this window: one pass over the stored paths, with one map lookup per run against a reused buffer. Ranking is O(tallies · log limit). Results are cached for `rfCacheTTL`, so this runs at most once per TTL per group set. - **Subpath detail** is O(stored hops), one pass with no allocation per packet. It's an on-click endpoint, not a hot path. - **Ingest and eviction** no longer touch a subpath index at all. Removal used to be a linear search of every key's transmission list. **Measured on a copy of that window**, upstream master vs this branch, same queries: | | master | this PR | |---|---|---| | peak RSS (`GOMEMLIMIT=750MiB`) | 948 MB | 767 MB | | in-use heap after load (750MiB) | 592 MB | 456 MB | | `/api/analytics/subpaths` cold (750MiB) | 3.56 s | 0.64 s | | `/api/analytics/subpaths-bulk` cold (750MiB) | 3.33 s | 0.70 s | | `/api/analytics/subpaths` cold (1GiB) | 1.18 s | 0.61 s | | `/api/analytics/subpath-detail` | 0.6 ms | 8 ms | | cached responses | 0.6 ms | 0.5 ms | At 750 MiB, master's request time is mostly GC pressure from the index. On the production-shaped fixture that `subpath_index_memory_test.go` builds (41k transmissions): | | master | this PR | |---|---|---| | retained by the store for subpaths | 197 MB | 0 MB | | allocated per bulk request | 336 MB | 111 MB | This has also run on a staging instance with live MQTT ingest for about nine hours, with Route Patterns answering in about 1 s. ## Tests - `subpaths_on_demand_test.go`: - per-occurrence, case-folded counts; - the 8-hop cap; - bulk matches single; - detail counts, including repeats, case and the length limits; - eviction drops evicted paths from the totals. - `TestSubpathIndexRetainedHeap`: the bound is now 5 MB (it was 225 MB). - `TestSubpathRequestAllocations`: 150 MB per request. - Each change has its red commit first, failing on assertions. - Tests that reached into `spTxIndex` now assert through `txsWithSubpath` and the public counts. - `cd cmd/server && go test ./...` passes. - SonarQube: quality gate passed, no issues on added lines. **Overlap with #2181:** both edit the same memory-estimate comment block in `store.go`. Whichever lands second needs a trivial rebase there; there's no code dependency. Backend only: no UI, API shape or config change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
4.8 KiB
Go
138 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Subpaths are counted per request from the stored paths rather than kept in
|
|
// a key -> transmissions index. These tests pin the counts the index gave:
|
|
// every contiguous run of 2-8 hops, once per occurrence, keyed in lower case.
|
|
|
|
func setupSubpathCountDB(t *testing.T) *DB {
|
|
t.Helper()
|
|
db := setupTestDB(t)
|
|
now := time.Now().UTC()
|
|
seen := now.Add(-time.Hour).Format(time.RFC3339)
|
|
epoch := now.Add(-time.Hour).Unix()
|
|
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
|
VALUES ('obs1', 'Observer One', 'SJC', ?, '2025-01-01T00:00:00Z', 100)`, seen)
|
|
paths := []string{
|
|
`["AA","bb","aa","bb"]`, // mixed case; "aa,bb" twice in one path
|
|
`["aa","bb"]`,
|
|
`["01","02","03","04","05","06","07","08","09"]`, // longer than 8
|
|
`["cc"]`, // one hop: no subpaths, not a path for the totals
|
|
}
|
|
for i, pj := range paths {
|
|
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
|
VALUES ('01', ?, ?, 1, 4, '{}')`, "subpath_count_"+string(rune('a'+i)), seen)
|
|
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
|
VALUES (?, 1, 10.0, -90, ?, ?)`, i+1, pj, epoch)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func subpathCounts(t *testing.T, r map[string]interface{}) (map[string]int, int) {
|
|
t.Helper()
|
|
out := map[string]int{}
|
|
for _, sp := range r["subpaths"].([]map[string]interface{}) {
|
|
out[sp["path"].(string)] = sp["count"].(int)
|
|
}
|
|
return out, r["totalPaths"].(int)
|
|
}
|
|
|
|
func loadedSubpathStore(t *testing.T) *PacketStore {
|
|
t.Helper()
|
|
db := setupSubpathCountDB(t)
|
|
t.Cleanup(func() { db.Close() })
|
|
store := NewPacketStore(db, nil)
|
|
if err := store.Load(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !store.WaitIndexesReady(5 * time.Second) {
|
|
t.Fatal("indexes never became ready")
|
|
}
|
|
return store
|
|
}
|
|
|
|
func TestSubpathCountsPerOccurrenceCaseFolded(t *testing.T) {
|
|
store := loadedSubpathStore(t)
|
|
counts, total := subpathCounts(t, store.GetAnalyticsSubpaths("", 2, 2, 100))
|
|
if total != 3 {
|
|
t.Errorf("totalPaths = %d, want 3 (the one-hop path is not counted)", total)
|
|
}
|
|
if counts["aa → bb"] != 3 {
|
|
t.Errorf(`"aa → bb" = %d, want 3 (twice in the first path, once in the second): %v`, counts["aa → bb"], counts)
|
|
}
|
|
if counts["bb → aa"] != 1 {
|
|
t.Errorf(`"bb → aa" = %d, want 1`, counts["bb → aa"])
|
|
}
|
|
}
|
|
|
|
func TestSubpathCountsStopAtEightHops(t *testing.T) {
|
|
store := loadedSubpathStore(t)
|
|
counts, _ := subpathCounts(t, store.GetAnalyticsSubpaths("", 8, 20, 100))
|
|
if len(counts) != 2 {
|
|
t.Errorf("runs of 8+ hops: %v, want the two 8-hop runs of the 9-hop path only", counts)
|
|
}
|
|
}
|
|
|
|
func TestSubpathBulkMatchesSingle(t *testing.T) {
|
|
store := loadedSubpathStore(t)
|
|
groups := []subpathGroup{{2, 2, 50}, {3, 3, 50}, {4, 8, 50}}
|
|
bulk := store.GetAnalyticsSubpathsBulk("", groups)
|
|
store.cacheMu.Lock()
|
|
store.subpathCache = map[string]*cachedResult{}
|
|
store.cacheMu.Unlock()
|
|
for i, g := range groups {
|
|
b, bt := subpathCounts(t, bulk[i])
|
|
s, st := subpathCounts(t, store.GetAnalyticsSubpaths("", g.MinLen, g.MaxLen, g.Limit))
|
|
if bt != st || len(b) != len(s) {
|
|
t.Fatalf("group %v: bulk %d paths %v, single %d paths %v", g, bt, b, st, s)
|
|
}
|
|
for k, v := range s {
|
|
if b[k] != v {
|
|
t.Errorf("group %v: %q bulk %d single %d", g, k, b[k], v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSubpathDetailCountsOccurrences(t *testing.T) {
|
|
store := loadedSubpathStore(t)
|
|
for _, hops := range [][]string{{"aa", "bb"}, {"AA", "BB"}} {
|
|
d := store.GetSubpathDetail(hops)
|
|
if got := d["totalMatches"].(int); got != 3 {
|
|
t.Errorf("detail %v: totalMatches = %d, want 3", hops, got)
|
|
}
|
|
}
|
|
if got := store.GetSubpathDetail([]string{"01", "02", "03", "04", "05", "06", "07", "08", "09"})["totalMatches"].(int); got != 0 {
|
|
t.Errorf("a 9-hop run is not a subpath; totalMatches = %d, want 0", got)
|
|
}
|
|
if got := store.GetSubpathDetail([]string{"zz", "yy"})["totalMatches"].(int); got != 0 {
|
|
t.Errorf("unknown subpath: totalMatches = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// The totals the percentages divide by are counted with the subpaths, so
|
|
// evicted transmissions drop out of them.
|
|
func TestEvictStaleDropsEvictedPathsFromTotals(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store := makeTestStore(10, now.Add(-48*time.Hour), 0)
|
|
identity := func(h string) string { return h }
|
|
groups := []subpathGroup{{MinLen: 2, MaxLen: 8, Limit: 10}}
|
|
if _, total := store.topNamedSubpaths(groups, identity); total != 10 {
|
|
t.Fatalf("fixture: totalPaths = %d, want 10", total)
|
|
}
|
|
for i := 5; i < 10; i++ {
|
|
store.packets[i].FirstSeen = now.Add(-time.Hour).Format(time.RFC3339)
|
|
}
|
|
store.retentionHours = 24
|
|
if n := store.EvictStale(); n != 5 {
|
|
t.Fatalf("evicted %d, want 5", n)
|
|
}
|
|
if _, total := store.topNamedSubpaths(groups, identity); total != 5 {
|
|
t.Errorf("totalPaths = %d after evicting 5 of 10, want 5", total)
|
|
}
|
|
}
|