mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 20:49:57 +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>
114 lines
3.9 KiB
Go
114 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
)
|
|
|
|
func addTestObs(tx *StoreTx, observerID, pathJSON string) bool {
|
|
if tx.hasObservation(observerID, pathJSON) {
|
|
return false
|
|
}
|
|
tx.addObservation(&StoreObs{ObserverID: observerID, PathJSON: pathJSON})
|
|
return true
|
|
}
|
|
|
|
// TestObsDedupCorrectness: the same observer with the same path is a
|
|
// duplicate; a new path or a new observer is not.
|
|
func TestObsDedupCorrectness(t *testing.T) {
|
|
tx := &StoreTx{ID: 1, Hash: "abc123"}
|
|
for i := 0; i < 5; i++ {
|
|
if !addTestObs(tx, fmt.Sprintf("obs-%d", i), fmt.Sprintf(`["path-%d"]`, i)) {
|
|
t.Fatalf("observation %d should not be a duplicate", i)
|
|
}
|
|
}
|
|
if tx.ObservationCount != 5 || len(tx.Observations) != 5 {
|
|
t.Fatalf("expected 5 observations, got count=%d len=%d", tx.ObservationCount, len(tx.Observations))
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
if !tx.hasObservation(fmt.Sprintf("obs-%d", i), fmt.Sprintf(`["path-%d"]`, i)) {
|
|
t.Fatalf("observation %d should be detected as duplicate", i)
|
|
}
|
|
}
|
|
if tx.hasObservation("obs-0", `["different-path"]`) {
|
|
t.Fatal("different path should not be a duplicate")
|
|
}
|
|
if tx.hasObservation("obs-new", `["path-0"]`) {
|
|
t.Fatal("different observer should not be a duplicate")
|
|
}
|
|
}
|
|
|
|
// TestObsDedupSmallTxCarriesNoMaps: most transmissions have a handful of
|
|
// observations, and two maps each cost them a few hundred bytes. Below the
|
|
// threshold the checks scan tx.Observations instead.
|
|
func TestObsDedupSmallTxCarriesNoMaps(t *testing.T) {
|
|
tx := &StoreTx{ID: 1}
|
|
for i := 0; i < obsIndexThreshold-1; i++ {
|
|
addTestObs(tx, fmt.Sprintf("obs-%d", i%4), fmt.Sprintf(`["p%d"]`, i))
|
|
}
|
|
if len(tx.Observations) != obsIndexThreshold-1 {
|
|
t.Fatalf("len = %d, want %d", len(tx.Observations), obsIndexThreshold-1)
|
|
}
|
|
if tx.obsKeys != nil || tx.observerSet != nil {
|
|
t.Fatal("a transmission under the threshold should not carry dedup maps")
|
|
}
|
|
if tx.UniqueObserverCount != 4 {
|
|
t.Fatalf("UniqueObserverCount = %d, want 4", tx.UniqueObserverCount)
|
|
}
|
|
}
|
|
|
|
// TestObsDedupAcrossTheThreshold: past the threshold the maps take over and
|
|
// give the same answers as the scan did.
|
|
func TestObsDedupAcrossTheThreshold(t *testing.T) {
|
|
tx := &StoreTx{ID: 1}
|
|
n := obsIndexThreshold * 3
|
|
for i := 0; i < n; i++ {
|
|
if !addTestObs(tx, fmt.Sprintf("obs-%d", i%10), fmt.Sprintf(`["p%d"]`, i)) {
|
|
t.Fatalf("observation %d rejected", i)
|
|
}
|
|
}
|
|
if tx.obsKeys == nil || tx.observerSet == nil {
|
|
t.Fatal("a busy transmission should index its observations")
|
|
}
|
|
if tx.UniqueObserverCount != 10 || tx.ObservationCount != n {
|
|
t.Fatalf("unique=%d count=%d, want 10 and %d", tx.UniqueObserverCount, tx.ObservationCount, n)
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
if addTestObs(tx, fmt.Sprintf("obs-%d", i%10), fmt.Sprintf(`["p%d"]`, i)) {
|
|
t.Fatalf("duplicate %d accepted after the threshold", i)
|
|
}
|
|
}
|
|
if !addTestObs(tx, "obs-new", `["p0"]`) || tx.UniqueObserverCount != 11 {
|
|
t.Fatalf("new observer: unique=%d, want 11", tx.UniqueObserverCount)
|
|
}
|
|
}
|
|
|
|
// TestObsDedupEmptyObserverNotCounted: an observation without an observer
|
|
// ID is kept but does not count as a distinct observer.
|
|
func TestObsDedupEmptyObserverNotCounted(t *testing.T) {
|
|
tx := &StoreTx{ID: 1}
|
|
addTestObs(tx, "", `["a"]`)
|
|
addTestObs(tx, "", `["b"]`)
|
|
if tx.UniqueObserverCount != 0 || tx.ObservationCount != 2 {
|
|
t.Fatalf("unique=%d count=%d, want 0 and 2", tx.UniqueObserverCount, tx.ObservationCount)
|
|
}
|
|
}
|
|
|
|
// BenchmarkObsDedup measures a miss (the common case on ingest) at sizes
|
|
// either side of the threshold.
|
|
func BenchmarkObsDedup(b *testing.B) {
|
|
for _, obsCount := range []int{4, 16, 40, 63, 64, 500} {
|
|
b.Run(fmt.Sprintf("obs=%d", obsCount), func(b *testing.B) {
|
|
tx := &StoreTx{ID: 1}
|
|
for i := 0; i < obsCount; i++ {
|
|
addTestObs(tx, fmt.Sprintf("%064d", i), fmt.Sprintf(`["hop-%d"]`, i))
|
|
}
|
|
newID := fmt.Sprintf("%064d", obsCount+1)
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_ = tx.hasObservation(newID, `["new-path"]`)
|
|
}
|
|
})
|
|
}
|
|
}
|