mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 17:17:22 +00:00
## 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>
203 lines
6.7 KiB
Go
203 lines
6.7 KiB
Go
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)
|
||
}
|
||
}
|