Files
meshcore-analyzer/cmd/server/subpaths_on_demand_test.go
T
Alex BandClaude Opus 5.5 8d39a9d465 perf(store): count subpaths per request instead of indexing every run (#2182)
## 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>
2026-10-12 12:41:46 +02:00

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)
}
}