Files
meshcore-analyzer/cmd/server/tracked_bytes_test.go
T
Alex BandClaude Opus 5.5 dc7ebcd6f5 perf(store): check observation duplicates without per-transmission maps (#2181)
## Problem
Every `StoreTx` carried two maps for observation de-duplication:
- `obsKeys`, keyed by `observerID + "|" + pathJSON` (a concatenated
string allocated for every observation);
- `observerSet`, for `UniqueObserverCount`.

On a busy regional network's 7-day window (64k transmissions, 400k
observations), these maps and their concatenated keys held about 60 MB
of heap. Most transmissions are heard a handful of times; the busiest in
that week was heard 40 times. The maps cost a few hundred bytes per
transmission to de-duplicate lists that are usually under ten long.

## Change
- `hasObservation` and `hasObserver` scan `tx.Observations`, comparing
the observer ID and path strings the observations already hold. These
are the same two values the old map key was built from, so the answers
are identical.
- Once a transmission passes 64 observations (`obsIndexThreshold`), it
builds the maps. They're keyed by a struct that shares the observations'
strings, so no key bytes are allocated, and busy transmissions keep O(1)
checks (the case #543 / #355 addressed).
- The five places that appended an observation now call
`addObservation`, which keeps `UniqueObserverCount` and
`ObservationCount` in step. Those three lines were previously copied at
each site.
- The memory estimate no longer charges for the per-transmission maps or
the concatenated keys.

## Perf justification (AGENTS.md rule 0)
- **Complexity.**
- Below the threshold, a duplicate check is O(k) for a transmission with
k < 64 observations, so building it costs at most 63·64/2 = 2016
string-header comparisons.
- At and above 64 observations, checks are O(1) map lookups, as before.
- Nothing is O(n²) in the number of transmissions or observations
overall: the scan is bounded per transmission.
- **Scale.** Real data peaks at 40 observations per transmission. The
scan compares string headers (length first, then bytes), and
64-character observer IDs differ early.

`BenchmarkObsDedup` (a miss, the common case on ingest):

| observations on tx | check used | time |
|---|---|---|
| 4 | scan | 15 ns |
| 16 | scan | 70 ns |
| 40 | scan | 151 ns |
| 63 | scan | 233 ns |
| 64+ | map (struct key) | 15 ns |

For comparison, the old map lookup was about 15 ns too, plus building
and allocating the `observerID|path` key string on every check.

At 40 observations that's about 3 µs extra to build a whole
transmission, against roughly 2 KB of maps no longer allocated for it.

**Measured on a copy of that 7-day window** (`GOMEMLIMIT=750MiB`, same
queries):

| | before | after |
|---|---|---|
| in-use heap after load | 612 MB | 568 MB |
| HeapAlloc after load | 660 MB | 564 MB |
| peak RSS | 1077 MB | 1009 MB |
| startup to ready | 31 s | 21 s |

It has also run on a staging instance (live MQTT ingest) for about nine
hours alongside other store changes, with no change in observation or
observer counts.

## Tests
- `obs_dedup_test.go`:
- duplicates (same observer and path) are rejected, while a new path or
a new observer is kept;
  - a transmission under the threshold carries no maps;
  - across the threshold the maps take over with the same answers;
  - empty observer IDs aren't counted as observers;
  - `BenchmarkObsDedup`.
- Red commit first (stubs, failing on assertions), then green.
- `cd cmd/server && go test ./...` passes.
- SonarQube: quality gate passed, no issues on added lines.

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-11 14:31:12 +02:00

203 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"testing"
"time"
)
// TestEstimateStoreTxBytes_ReasonableValues verifies the estimate function
// returns reasonable values for different packet sizes.
func TestEstimateStoreTxBytes_ReasonableValues(t *testing.T) {
tx := &StoreTx{
Hash: "abcdef1234567890",
RawHex: "deadbeef",
DecodedJSON: `{"type":"GRP_TXT"}`,
PathJSON: `["hop1","hop2","hop3"]`,
parsedPath: []string{"hop1", "hop2", "hop3"},
pathParsed: true,
}
got := estimateStoreTxBytes(tx)
// Should be at least base (384) + maps (200) + indexes + path/subpath costs
if got < 700 {
t.Errorf("estimate too low for 3-hop tx: %d", got)
}
if got > 5000 {
t.Errorf("estimate unreasonably high for 3-hop tx: %d", got)
}
}
// TestEstimateStoreTxBytes_ManyHopsSubpaths verifies that packets with many
// hops estimate significantly more due to O(path²) subpath index entries.
func TestEstimateStoreTxBytes_ManyHopsSubpaths(t *testing.T) {
tx2 := &StoreTx{
Hash: "aabb",
parsedPath: []string{"a", "b"},
pathParsed: true,
}
tx10 := &StoreTx{
Hash: "aabb",
parsedPath: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"},
pathParsed: true,
}
est2 := estimateStoreTxBytes(tx2)
est10 := estimateStoreTxBytes(tx10)
// 10 hops → 45 subpath combos × 40 = 1800 bytes just for subpaths
if est10 <= est2 {
t.Errorf("10-hop (%d) should estimate more than 2-hop (%d)", est10, est2)
}
if est10 < est2+1500 {
t.Errorf("10-hop (%d) should estimate at least 1500 more than 2-hop (%d)", est10, est2)
}
}
// TestEstimateStoreObsBytes_AfterRefactor verifies that after #800 refactor,
// observations no longer have ResolvedPath overhead in their estimate.
func TestEstimateStoreObsBytes_AfterRefactor(t *testing.T) {
obs := &StoreObs{
ObserverID: "obs1",
PathJSON: `["a","b"]`,
}
est := estimateStoreObsBytes(obs)
if est <= 0 {
t.Errorf("estimate should be positive, got %d", est)
}
// After #800, all obs estimates should be the same (no RP field variation)
obs2 := &StoreObs{
ObserverID: "obs1",
PathJSON: `["a","b"]`,
}
est2 := estimateStoreObsBytes(obs2)
if est != est2 {
t.Errorf("estimates should be equal after #800 (no RP field), got %d vs %d", est, est2)
}
}
// TestEstimateStoreObsBytes_ManyObservations verifies that 15 observations
// estimate significantly more than 1.
func TestEstimateStoreObsBytes_ManyObservations(t *testing.T) {
est1 := estimateStoreObsBytes(&StoreObs{ObserverID: "a", PathJSON: `["x"]`})
est15 := int64(0)
for i := 0; i < 15; i++ {
est15 += estimateStoreObsBytes(&StoreObs{ObserverID: "a", PathJSON: `["x"]`})
}
if est15 <= est1*10 {
t.Errorf("15 obs total (%d) should be >10x single obs (%d)", est15, est1)
}
}
// TestTrackedBytesMatchesSumAfterInsert verifies that trackedBytes equals the
// sum of individual estimates after inserting packets via makeTestStore.
func TestTrackedBytesMatchesSumAfterInsert(t *testing.T) {
store := makeTestStore(20, time.Now().Add(-2*time.Hour), 5)
// Manually compute trackedBytes as sum of estimates
var expectedSum int64
for _, tx := range store.packets {
expectedSum += estimateStoreTxBytes(tx)
for _, obs := range tx.Observations {
expectedSum += estimateStoreObsBytes(obs)
}
}
if store.trackedBytes != expectedSum {
t.Errorf("trackedBytes=%d, expected sum=%d", store.trackedBytes, expectedSum)
}
}
// TestEvictionTriggersWithImprovedEstimates verifies that eviction triggers
// at the right point with the improved (higher) estimates.
func TestEvictionTriggersWithImprovedEstimates(t *testing.T) {
store := makeTestStore(100, time.Now().Add(-10*time.Hour), 5)
// trackedBytes for 100 packets is small — artificially set maxMemoryMB
// so highWatermark is just below trackedBytes to trigger eviction.
highWatermarkBytes := store.trackedBytes - 1000
if highWatermarkBytes < 1 {
highWatermarkBytes = 1
}
// maxMemoryMB * 1048576 = highWatermark, so maxMemoryMB = ceil(highWatermarkBytes / 1048576)
// But that'll be 0 for small values. Instead, directly set trackedBytes high.
store.trackedBytes = 6 * 1048576 // 6MB
store.maxMemoryMB = 3 // 3MB limit
beforeCount := len(store.packets)
store.RunEviction()
afterCount := len(store.packets)
if afterCount >= beforeCount {
t.Errorf("expected eviction to remove packets: before=%d, after=%d, trackedBytes=%d, maxMB=%d",
beforeCount, afterCount, store.trackedBytes, store.maxMemoryMB)
}
// trackedBytes should have decreased
if store.trackedBytes >= 6*1048576 {
t.Errorf("trackedBytes should have decreased after eviction")
}
}
// BenchmarkEstimateStoreTxBytes verifies the estimate function is fast.
func BenchmarkEstimateStoreTxBytes(b *testing.B) {
tx := &StoreTx{
Hash: "abcdef1234567890",
RawHex: "deadbeefdeadbeef",
DecodedJSON: `{"type":"GRP_TXT","payload":"hello"}`,
PathJSON: `["hop1","hop2","hop3","hop4","hop5"]`,
parsedPath: []string{"hop1", "hop2", "hop3", "hop4", "hop5"},
pathParsed: true,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
estimateStoreTxBytes(tx)
}
}
// BenchmarkEstimateStoreObsBytes verifies the obs estimate function is fast.
func BenchmarkEstimateStoreObsBytes(b *testing.B) {
obs := &StoreObs{
ObserverID: "observer1234",
PathJSON: `["a","b","c"]`,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
estimateStoreObsBytes(obs)
}
}
// TestTrackedBytes_PathKnownAfterCreate reproduces the load/ingest order: a tx
// is charged when it is created, before its observations set the path. Once
// pickBestObservation has run, recharging must pick up the path costs, and
// eviction must subtract exactly what was charged.
func TestTrackedBytes_PathKnownAfterCreate(t *testing.T) {
store := makeTestStore(0, time.Now().UTC(), 0)
store.retentionHours = 1
tx := &StoreTx{ID: 1, Hash: "aabb", FirstSeen: time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339)}
store.trackedBytes += rechargeTx(tx)
bare := store.trackedBytes
obs := &StoreObs{ID: 1, TransmissionID: 1, ObserverID: "o1", PathJSON: `["a1","b2","c3","d4","e5","f6"]`}
tx.Observations = append(tx.Observations, obs)
store.trackedBytes += estimateStoreObsBytes(obs)
pickBestObservation(tx)
store.trackedBytes += rechargeTx(tx)
store.packets = append(store.packets, tx)
store.byHash[tx.Hash] = tx
store.byTxID[tx.ID] = tx
store.byObsID[obs.ID] = obs
if want := estimateStoreTxBytes(tx) + estimateStoreObsBytes(obs); store.trackedBytes != want {
t.Fatalf("trackedBytes = %d, want %d (path costs missing?)", store.trackedBytes, want)
}
if tx.accountedBytes <= bare {
t.Fatalf("recharge did not add the path costs: %d <= %d", tx.accountedBytes, bare)
}
if n := store.EvictStale(); n != 1 {
t.Fatalf("evicted %d, want 1", n)
}
if store.trackedBytes != 0 {
t.Fatalf("trackedBytes after evicting everything = %d, want 0", store.trackedBytes)
}
}