From ea1af990173199fd8f97f3774353a2c4d61801e2 Mon Sep 17 00:00:00 2001 From: R0ck Date: Sun, 11 Oct 2026 06:32:03 +0100 Subject: [PATCH 1/4] test(store): bound the node sets behind byNode's de-dup Red: nodeHashes keeps 25.8MB for 500k (node, packet) pairs, keyed by the packets' hash strings. newNodeSets and nodeHasTx wrap today's map. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- cmd/server/node_set_memory_test.go | 52 ++++++++++++++++++++++++++++++ cmd/server/store.go | 6 ++++ 2 files changed, 58 insertions(+) create mode 100644 cmd/server/node_set_memory_test.go diff --git a/cmd/server/node_set_memory_test.go b/cmd/server/node_set_memory_test.go new file mode 100644 index 000000000..984862bef --- /dev/null +++ b/cmd/server/node_set_memory_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "testing" +) + +// nodeHashes is the set behind byNode's de-dup: for every node, the packets +// already listed under it. On a busy week it held about 650k entries and +// 21.5MB keyed by the packets' 16-character hashes; keyed by packet ID it +// keeps the same membership in a fraction of that. +func TestNodeSetRetainedHeap(t *testing.T) { + const nodes, perNode = 2000, 250 // 500k (node, packet) pairs + txs := make([]*StoreTx, perNode*4) + for i := range txs { + txs[i] = &StoreTx{ID: i + 1, Hash: fmt.Sprintf("%016x", i+1)} + } + pks := make([]string, nodes) + for i := range pks { + pks[i] = fmt.Sprintf("%064x", i) + } + mb := retainedHeapMB(func() interface{} { + s := &PacketStore{byNode: map[string][]*StoreTx{}, nodeHashes: newNodeSets()} + for n, pk := range pks { + for j := 0; j < perNode; j++ { + s.addToByNode(txs[(n+j)%len(txs)], pk) + } + } + s.byNode = nil // measure the set alone + return s + }) + t.Logf("node sets for 500k (node, packet) pairs keep %.1f MB", mb) + const limitMB = 9 + if mb > limitMB { + t.Fatalf("node sets keep %.1f MB for 500k pairs, limit %d MB", mb, limitMB) + } +} + +// Adding the same packet under a node twice lists it once. +func TestAddToByNodeDedup(t *testing.T) { + s := &PacketStore{byNode: map[string][]*StoreTx{}, nodeHashes: newNodeSets()} + tx := &StoreTx{ID: 7, Hash: "abc"} + if !s.addToByNode(tx, "pk") { + t.Fatal("first add should report a new node") + } + if s.addToByNode(tx, "pk") || len(s.byNode["pk"]) != 1 { + t.Fatalf("second add listed it again: %d", len(s.byNode["pk"])) + } + if !s.nodeHasTx("pk", tx) || s.nodeHasTx("pk", &StoreTx{ID: 8, Hash: "def"}) { + t.Fatal("nodeHasTx disagrees with what was added") + } +} diff --git a/cmd/server/store.go b/cmd/server/store.go index acd86c636..90d1b5ce4 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -1914,6 +1914,12 @@ func (s *PacketStore) indexByNode(tx *StoreTx) bool { return foundNew } +// newNodeSets returns an empty nodeHashes. +func newNodeSets() map[string]map[string]bool { return make(map[string]map[string]bool) } + +// nodeHasTx reports whether tx is listed under pubkey. +func (s *PacketStore) nodeHasTx(pubkey string, tx *StoreTx) bool { return s.nodeHashes[pubkey][tx.Hash] } + // addToByNode adds tx to byNode[pubkey] with dedup via nodeHashes. // Returns true if this is a genuinely new node (pubkey not seen before). func (s *PacketStore) addToByNode(tx *StoreTx, pubkey string) bool { From fcc74512c991cbb2b2ae9407c7a8c26682c9cda8 Mon Sep 17 00:00:00 2001 From: R0ck Date: Sun, 11 Oct 2026 06:36:01 +0100 Subject: [PATCH 2/4] perf(store): key byNode's de-dup sets by transmission ID nodeHashes is the set behind byNode's de-dup: for every node, the transmissions already listed under it. It was keyed by each transmission's 16-character hash string: ~650k entries and 21.5MB on a busy week. It is now keyed by transmission ID, the same membership (a store holds one transmission per hash); eviction deletes by ID, and the health region filter matches regional observations by transmission ID. nodeHasTx reads the set for callers and tests. On a copy of that week: the sets' live heap 21.5MB -> 13.2MB. Fixture (500k pairs): 25.8MB -> 18.0MB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- cmd/server/area_filter_test.go | 2 +- cmd/server/channel_analytics_test.go | 2 +- cmd/server/coverage_test.go | 8 ++-- cmd/server/eviction_test.go | 10 ++--- cmd/server/neighbor_graph_test.go | 2 +- cmd/server/node_set_memory_test.go | 6 +-- cmd/server/relay_airtime_share_test.go | 2 +- cmd/server/resolved_index_test.go | 38 ++++++++--------- cmd/server/routes_test.go | 6 +-- cmd/server/store.go | 58 ++++++++++++++------------ 10 files changed, 69 insertions(+), 65 deletions(-) diff --git a/cmd/server/area_filter_test.go b/cmd/server/area_filter_test.go index d76e4d82c..8355e2eba 100644 --- a/cmd/server/area_filter_test.go +++ b/cmd/server/area_filter_test.go @@ -85,7 +85,7 @@ func newTestStoreWithDB(t testing.TB, db *DB, cfg *Config) *PacketStore { byObserver: make(map[string][]*StoreObs), byHash: make(map[string]*StoreTx), byPayloadType: make(map[int][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPathHop: make(map[string][]*StoreTx), advertPubkeys: make(map[string]int), rfCache: make(map[string]*cachedResult), diff --git a/cmd/server/channel_analytics_test.go b/cmd/server/channel_analytics_test.go index 0cc4b0399..5de5d8a07 100644 --- a/cmd/server/channel_analytics_test.go +++ b/cmd/server/channel_analytics_test.go @@ -18,7 +18,7 @@ func newChannelTestStore(packets []*StoreTx) *PacketStore { byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), byPathHop: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPayloadType: make(map[int][]*StoreTx), rfCache: make(map[string]*cachedResult), topoCache: make(map[string]*cachedResult), diff --git a/cmd/server/coverage_test.go b/cmd/server/coverage_test.go index 87bf3caf3..e65566da3 100644 --- a/cmd/server/coverage_test.go +++ b/cmd/server/coverage_test.go @@ -4326,7 +4326,7 @@ func TestResolveRegionObserversCacheMissNewRegion(t *testing.T) { func TestIndexByNodePreCheck(t *testing.T) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), } t.Run("indexes ADVERT with pubKey", func(t *testing.T) { @@ -4385,7 +4385,7 @@ func TestIndexByNodePreCheck(t *testing.T) { func TestIndexByNodeResolvedPath(t *testing.T) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), } t.Run("decoded JSON pubkeys still indexed", func(t *testing.T) { @@ -4435,7 +4435,7 @@ func BenchmarkIndexByNode(b *testing.B) { b.Run("no_pubkey_skip", func(b *testing.B) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), } b.ResetTimer() for i := 0; i < b.N; i++ { @@ -4450,7 +4450,7 @@ func BenchmarkIndexByNode(b *testing.B) { b.Run("with_pubkey_parse", func(b *testing.B) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), } b.ResetTimer() for i := 0; i < b.N; i++ { diff --git a/cmd/server/eviction_test.go b/cmd/server/eviction_test.go index 57ac2408f..761fc02f4 100644 --- a/cmd/server/eviction_test.go +++ b/cmd/server/eviction_test.go @@ -17,7 +17,7 @@ func makeTestStore(count int, startTime time.Time, intervalMin int) *PacketStore byObsID: make(map[int]*StoreObs, count*2), byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPayloadType: make(map[int][]*StoreTx), distHops: make([]distHopRecord, 0), distPaths: make([]distPathRecord, 0), @@ -73,9 +73,9 @@ func makeTestStore(count int, startTime time.Time, intervalMin int) *PacketStore // Index by node pk := fmt.Sprintf("pk%04d", i) if store.nodeHashes[pk] == nil { - store.nodeHashes[pk] = make(map[string]bool) + store.nodeHashes[pk] = make(map[int]struct{}) } - store.nodeHashes[pk][hash] = true + store.nodeHashes[pk][txID] = struct{}{} store.byNode[pk] = append(store.byNode[pk], tx) // Add to distance index @@ -255,7 +255,7 @@ func TestEvictStale_CleansResolvedPathNodeIndexes(t *testing.T) { byObsID: make(map[int]*StoreObs), byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPayloadType: make(map[int][]*StoreTx), distHops: make([]distHopRecord, 0), distPaths: make([]distPathRecord, 0), @@ -309,7 +309,7 @@ func TestEvictStale_CleansResolvedPathNodeIndexes(t *testing.T) { if len(store.byNode[relayPK]) != 1 { t.Fatalf("expected 1 entry in byNode[%s], got %d", relayPK, len(store.byNode[relayPK])) } - if !store.nodeHashes[relayPK][tx.Hash] { + if !store.nodeHasTx(relayPK, tx) { t.Fatalf("expected nodeHashes[%s] to contain %s", relayPK, tx.Hash) } diff --git a/cmd/server/neighbor_graph_test.go b/cmd/server/neighbor_graph_test.go index 5f332fc25..959289caa 100644 --- a/cmd/server/neighbor_graph_test.go +++ b/cmd/server/neighbor_graph_test.go @@ -26,7 +26,7 @@ func ngTestStore(nodes []nodeInfo, packets []*StoreTx) *PacketStore { byObsID: make(map[int]*StoreObs), byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPayloadType: make(map[int][]*StoreTx), rfCache: make(map[string]*cachedResult), topoCache: make(map[string]*cachedResult), diff --git a/cmd/server/node_set_memory_test.go b/cmd/server/node_set_memory_test.go index 984862bef..e1044263c 100644 --- a/cmd/server/node_set_memory_test.go +++ b/cmd/server/node_set_memory_test.go @@ -7,8 +7,8 @@ import ( // nodeHashes is the set behind byNode's de-dup: for every node, the packets // already listed under it. On a busy week it held about 650k entries and -// 21.5MB keyed by the packets' 16-character hashes; keyed by packet ID it -// keeps the same membership in a fraction of that. +// 21.5MB keyed by the packets' 16-character hashes. Keyed by packet ID the +// same membership takes about 30% less: 25.8MB -> 18MB for this fixture. func TestNodeSetRetainedHeap(t *testing.T) { const nodes, perNode = 2000, 250 // 500k (node, packet) pairs txs := make([]*StoreTx, perNode*4) @@ -30,7 +30,7 @@ func TestNodeSetRetainedHeap(t *testing.T) { return s }) t.Logf("node sets for 500k (node, packet) pairs keep %.1f MB", mb) - const limitMB = 9 + const limitMB = 21 if mb > limitMB { t.Fatalf("node sets keep %.1f MB for 500k pairs, limit %d MB", mb, limitMB) } diff --git a/cmd/server/relay_airtime_share_test.go b/cmd/server/relay_airtime_share_test.go index 4ae0edad5..1460b758c 100644 --- a/cmd/server/relay_airtime_share_test.go +++ b/cmd/server/relay_airtime_share_test.go @@ -19,7 +19,7 @@ func newRelayAirtimeShareTestStore(packets []*StoreTx) *PacketStore { byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), byPathHop: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPayloadType: make(map[int][]*StoreTx), rfCache: make(map[string]*cachedResult), topoCache: make(map[string]*cachedResult), diff --git a/cmd/server/resolved_index_test.go b/cmd/server/resolved_index_test.go index 6ea746a1e..9ff385020 100644 --- a/cmd/server/resolved_index_test.go +++ b/cmd/server/resolved_index_test.go @@ -34,7 +34,7 @@ func TestStoreTx_ResolvedPathFieldAbsent(t *testing.T) { func TestResolvedPubkeyIndex_BuildFromLoad(t *testing.T) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), useResolvedPathIndex: true, } store.initResolvedPathIndex() @@ -76,7 +76,7 @@ func TestResolvedPubkeyIndex_HashCollision(t *testing.T) { store := &PacketStore{ db: db, byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), useResolvedPathIndex: true, } store.initResolvedPathIndex() @@ -113,7 +113,7 @@ func TestResolvedPubkeyIndex_HashCollision(t *testing.T) { func TestResolvedPubkeyIndex_IngestUpdate(t *testing.T) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), useResolvedPathIndex: true, } store.initResolvedPathIndex() @@ -140,7 +140,7 @@ func TestResolvedPubkeyIndex_IngestUpdate(t *testing.T) { func TestResolvedPubkeyIndex_RemoveOnEvict(t *testing.T) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), useResolvedPathIndex: true, } store.initResolvedPathIndex() @@ -173,7 +173,7 @@ func TestResolvedPubkeyIndex_RemoveOnEvict(t *testing.T) { func TestResolvedPubkeyIndex_PerObsCoverage(t *testing.T) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), useResolvedPathIndex: true, } store.initResolvedPathIndex() @@ -204,7 +204,7 @@ func TestResolvedPubkeyIndex_PerObsCoverage(t *testing.T) { func TestAddToByNode_WithoutResolvedPathField(t *testing.T) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), } tx := &StoreTx{ID: 1, Hash: "h1"} @@ -286,7 +286,7 @@ func TestEviction_ByNodeCleanup_OnDemandSQL(t *testing.T) { byObsID: make(map[int]*StoreObs), byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPayloadType: make(map[int][]*StoreTx), distHops: make([]distHopRecord, 0), distPaths: make([]distPathRecord, 0), @@ -359,7 +359,7 @@ func TestRunEviction_NoLockDuringSQLFetch(t *testing.T) { byObsID: make(map[int]*StoreObs), byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPayloadType: make(map[int][]*StoreTx), byPathHop: make(map[string][]*StoreTx), distHops: make([]distHopRecord, 0), @@ -437,7 +437,7 @@ func TestRunEviction_NoLockDuringSQLFetch(t *testing.T) { func TestPathsThroughNode_NilResolvedPathFallback(t *testing.T) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPathHop: make(map[string][]*StoreTx), useResolvedPathIndex: true, } @@ -536,7 +536,7 @@ func TestPacketsAPI_OnDemandResolvedPath_LRUHit(t *testing.T) { func TestFeatureFlag_OffPath_PreservesOldBehavior(t *testing.T) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), useResolvedPathIndex: false, } store.initResolvedPathIndex() @@ -562,7 +562,7 @@ func TestFeatureFlag_Toggle_NoStateLeak(t *testing.T) { // the off-path just stops reading/writing it. store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), useResolvedPathIndex: true, } store.initResolvedPathIndex() @@ -587,7 +587,7 @@ func TestFeatureFlag_Toggle_NoStateLeak(t *testing.T) { func TestReverseMap_NoLeakOnPartialFailure(t *testing.T) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), useResolvedPathIndex: true, } store.initResolvedPathIndex() @@ -612,7 +612,7 @@ func TestReverseMap_NoLeakOnPartialFailure(t *testing.T) { func TestDecodeWindow_LockHoldTimeBounded(t *testing.T) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPathHop: make(map[string][]*StoreTx), useResolvedPathIndex: true, } @@ -647,7 +647,7 @@ func BenchmarkResolvedPubkeyIndex_Memory(b *testing.B) { for n := 0; n < b.N; n++ { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), useResolvedPathIndex: true, } store.initResolvedPathIndex() @@ -684,7 +684,7 @@ func BenchmarkLoad_BeforeAfter(b *testing.B) { byTxID: make(map[int]*StoreTx, numTx), byObsID: make(map[int]*StoreObs, numTx*numObsPerTx), byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPathHop: make(map[string][]*StoreTx), useResolvedPathIndex: true, } @@ -839,7 +839,7 @@ func BenchmarkHopsSeen_Reuse(b *testing.B) { func BenchmarkPathsThroughNode_Latency(b *testing.B) { store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), useResolvedPathIndex: true, } store.initResolvedPathIndex() @@ -1147,7 +1147,7 @@ func TestResolvedPubkeyIndex_BoundedByEviction(t *testing.T) { byObsID: make(map[int]*StoreObs), byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPayloadType: make(map[int][]*StoreTx), byPathHop: make(map[string][]*StoreTx), distHops: make([]distHopRecord, 0), @@ -1271,7 +1271,7 @@ func TestRunEviction_TriggersCompaction(t *testing.T) { byObsID: make(map[int]*StoreObs), byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPayloadType: make(map[int][]*StoreTx), byPathHop: make(map[string][]*StoreTx), distHops: make([]distHopRecord, 0), @@ -1357,7 +1357,7 @@ func TestResolvedPubkeyIndex_MemoryStableThroughEvictionCycles(t *testing.T) { byObsID: make(map[int]*StoreObs), byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPayloadType: make(map[int][]*StoreTx), byPathHop: make(map[string][]*StoreTx), distHops: make([]distHopRecord, 0), diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 81aecf7b9..bac94ed0a 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -829,12 +829,12 @@ func TestNodeHealthPartialFromPackets(t *testing.T) { srv.store.byNode = make(map[string][]*StoreTx) } if srv.store.nodeHashes == nil { - srv.store.nodeHashes = make(map[string]map[string]bool) + srv.store.nodeHashes = newNodeSets() } srv.store.byNode[ghostPubkey] = []*StoreTx{ {Hash: "abc123", FirstSeen: now, SNR: &snr, ObservationCount: 1}, } - srv.store.nodeHashes[ghostPubkey] = map[string]bool{"abc123": true} + srv.store.nodeHashes[ghostPubkey] = map[int]struct{}{0: {}} srv.store.mu.Unlock() req := httptest.NewRequest("GET", "/api/nodes/"+ghostPubkey+"/health", nil) @@ -4011,7 +4011,7 @@ func TestNodeInResolvedPath(t *testing.T) { // which uses the membership index. Test the index-based approach. store := &PacketStore{ byNode: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), useResolvedPathIndex: true, } store.initResolvedPathIndex() diff --git a/cmd/server/store.go b/cmd/server/store.go index 90d1b5ce4..b7c8eb2d9 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -189,17 +189,17 @@ type PacketStore struct { mu sync.RWMutex db *DB - packets []*StoreTx // sorted by first_seen ASC (oldest first; newest at tail) - byHash map[string]*StoreTx // hash → *StoreTx - byTxID map[int]*StoreTx // transmission_id → *StoreTx - byObsID map[int]*StoreObs // observation_id → *StoreObs - maxTxID int // highest transmission_id in store - maxObsID int // highest observation_id in store - byObserver map[string][]*StoreObs // observer_id → observations - byNode map[string][]*StoreTx // pubkey → transmissions - nodeHashes map[string]map[string]bool // pubkey → Set - byPathHop map[string][]*StoreTx // lowercase hop/pubkey → transmissions with that hop in path - byPayloadType map[int][]*StoreTx // payload_type → transmissions + packets []*StoreTx // sorted by first_seen ASC (oldest first; newest at tail) + byHash map[string]*StoreTx // hash → *StoreTx + byTxID map[int]*StoreTx // transmission_id → *StoreTx + byObsID map[int]*StoreObs // observation_id → *StoreObs + maxTxID int // highest transmission_id in store + maxObsID int // highest observation_id in store + byObserver map[string][]*StoreObs // observer_id → observations + byNode map[string][]*StoreTx // pubkey → transmissions + nodeHashes map[string]map[int]struct{} // pubkey → set of tx IDs listed in byNode[pubkey] + byPathHop map[string][]*StoreTx // lowercase hop/pubkey → transmissions with that hop in path + byPayloadType map[int][]*StoreTx // payload_type → transmissions loaded bool totalObs int insertCount int64 @@ -722,7 +722,7 @@ func NewPacketStore(db *DB, cfg *PacketStoreConfig, cacheTTLs ...map[string]inte byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), byPathHop: make(map[string][]*StoreTx), - nodeHashes: make(map[string]map[string]bool), + nodeHashes: newNodeSets(), byPayloadType: make(map[int][]*StoreTx), rfCache: make(map[string]*cachedResult), topoCache: make(map[string]*cachedResult), @@ -1914,23 +1914,28 @@ func (s *PacketStore) indexByNode(tx *StoreTx) bool { return foundNew } -// newNodeSets returns an empty nodeHashes. -func newNodeSets() map[string]map[string]bool { return make(map[string]map[string]bool) } +// newNodeSets returns an empty nodeHashes. The sets hold transmission IDs: +// on a busy week they have ~650k entries, and int keys take about 30% less +// than the 16-character hash strings they used to be keyed by. +func newNodeSets() map[string]map[int]struct{} { return make(map[string]map[int]struct{}) } // nodeHasTx reports whether tx is listed under pubkey. -func (s *PacketStore) nodeHasTx(pubkey string, tx *StoreTx) bool { return s.nodeHashes[pubkey][tx.Hash] } +func (s *PacketStore) nodeHasTx(pubkey string, tx *StoreTx) bool { + _, ok := s.nodeHashes[pubkey][tx.ID] + return ok +} // addToByNode adds tx to byNode[pubkey] with dedup via nodeHashes. // Returns true if this is a genuinely new node (pubkey not seen before). func (s *PacketStore) addToByNode(tx *StoreTx, pubkey string) bool { isNew := s.nodeHashes[pubkey] == nil if isNew { - s.nodeHashes[pubkey] = make(map[string]bool) + s.nodeHashes[pubkey] = make(map[int]struct{}) } - if s.nodeHashes[pubkey][tx.Hash] { + if _, ok := s.nodeHashes[pubkey][tx.ID]; ok { return false } - s.nodeHashes[pubkey][tx.Hash] = true + s.nodeHashes[pubkey][tx.ID] = struct{}{} s.byNode[pubkey] = append(s.byNode[pubkey], tx) return isNew } @@ -5180,7 +5185,7 @@ func (s *PacketStore) evictStaleInternal(rpBatch map[int][]string) int { for _, field := range []string{"pubKey", "destPubKey", "srcPubKey"} { if v, ok := decoded[field].(string); ok && v != "" { if hashes, ok := s.nodeHashes[v]; ok { - delete(hashes, tx.Hash) + delete(hashes, tx.ID) if len(hashes) == 0 { delete(s.nodeHashes, v) } @@ -5202,7 +5207,7 @@ func (s *PacketStore) evictStaleInternal(rpBatch map[int][]string) int { continue } if hashes, ok := s.nodeHashes[pk]; ok { - delete(hashes, tx.Hash) + delete(hashes, tx.ID) if len(hashes) == 0 { delete(s.nodeHashes, pk) } @@ -9801,20 +9806,19 @@ func (s *PacketStore) GetBulkHealth(limit int, region, area string) []map[string var regionNodeKeys map[string]bool if region != "" { if regionObs != nil { - regionalHashes := make(map[string]bool) + regionalTxIDs := make(map[int]bool) for obsID := range regionObs { obsList := s.byObserver[obsID] for _, o := range obsList { - tx := s.byTxID[o.TransmissionID] - if tx != nil { - regionalHashes[tx.Hash] = true + if s.byTxID[o.TransmissionID] != nil { + regionalTxIDs[o.TransmissionID] = true } } } regionNodeKeys = make(map[string]bool) - for pk, hashes := range s.nodeHashes { - for h := range hashes { - if regionalHashes[h] { + for pk, txIDs := range s.nodeHashes { + for id := range txIDs { + if regionalTxIDs[id] { regionNodeKeys[pk] = true break } From 77fee316cffff47d240c73582f40cabfffbec27f Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 15 Oct 2026 10:09:39 +0100 Subject: [PATCH 3/4] test(store): GetBulkHealth's region filter keeps exactly the nodes heard there Observation IDs differ from transmission IDs in the fixture, so a filter keyed by the wrong ID returns nothing and fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- cmd/server/bulk_health_region_test.go | 84 +++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 cmd/server/bulk_health_region_test.go diff --git a/cmd/server/bulk_health_region_test.go b/cmd/server/bulk_health_region_test.go new file mode 100644 index 000000000..70c5bb1fa --- /dev/null +++ b/cmd/server/bulk_health_region_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "sort" + "testing" + "time" +) + +// GetBulkHealth's region filter keeps a node when one of its transmissions +// (an advert from it, or a packet whose path it relayed) was heard by an +// observer in the region. It matches the region's observations to the node +// sets by transmission ID, so this fixture gives observations IDs that differ +// from their transmissions' IDs: a filter keyed by the wrong ID fails here. +func TestGetBulkHealthRegionKeepsNodesHeardThere(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + now := time.Now().UTC() + ts, epoch := now.Add(-time.Hour).Format(time.RFC3339), now.Add(-time.Hour).Unix() + + const ( + a = "aaaa000000000001" // heard only in EDI + b = "bbbb000000000002" // heard only in GLA + c = "cccc000000000003" // adverts in GLA, and relays a packet heard in EDI + ) + exec := func(q string, args ...interface{}) { + t.Helper() + if _, err := db.conn.Exec(q, args...); err != nil { + t.Fatalf("%v\n%s", err, q) + } + } + exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) VALUES ('obsE', 'Edinburgh', 'EDI', ?, ?, 1)`, ts, ts) + exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) VALUES ('obsG', 'Glasgow', 'GLA', ?, ?, 1)`, ts, ts) + for _, pk := range []string{a, b, c} { + exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) VALUES (?, ?, 'repeater', 56.0, -3.5, ?, ?, 1)`, pk, "R-"+pk[:4], ts, ts) + } + advert := func(id int, pk string) { + exec(`INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey) VALUES (?, 'AA00', ?, ?, 1, 4, ?, ?)`, + id, "h"+pk, ts, `{"pubKey":"`+pk+`","name":"R","type":"ADVERT"}`, pk) + } + advert(10, a) + advert(20, b) + advert(30, c) + // A channel message heard in EDI that c relayed. + exec(`INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, decoded_json) VALUES (40, 'CC00', 'hchan', ?, 1, 5, '{"type":"CHAN","text":"x"}')`, ts) + + // Observation IDs 1-4 never equal a transmission ID (10-40). observer_idx + // is the observers rowid: 1 = obsE (EDI), 2 = obsG (GLA). + exec(`INSERT INTO observations (id, transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (1, 10, 1, 5, -90, '[]', ?)`, epoch) + exec(`INSERT INTO observations (id, transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (2, 20, 2, 5, -90, '[]', ?)`, epoch) + exec(`INSERT INTO observations (id, transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (3, 30, 2, 5, -90, '[]', ?)`, epoch) + exec(`INSERT INTO observations (id, transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path) VALUES (4, 40, 1, 5, -90, '["cc"]', ?, ?)`, epoch, `["`+c+`"]`) + + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + keys := func(region string) []string { + var out []string + for _, r := range store.GetBulkHealth(50, region, "") { + out = append(out, r["public_key"].(string)) + } + sort.Strings(out) + return out + } + for _, tc := range []struct { + region string + want []string + }{ + {"EDI", []string{a, c}}, + {"GLA", []string{b, c}}, + } { + got := keys(tc.region) + if len(got) != len(tc.want) { + t.Errorf("region %s: got %v, want %v", tc.region, got, tc.want) + continue + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("region %s: got %v, want %v", tc.region, got, tc.want) + break + } + } + } +} From 41f6912b8ecf0d138e2d1a02a0a6216db179d841 Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 15 Oct 2026 10:09:39 +0100 Subject: [PATCH 4/4] docs(store): leave measured sizes to the PR description Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- cmd/server/node_set_memory_test.go | 6 +++--- cmd/server/store.go | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cmd/server/node_set_memory_test.go b/cmd/server/node_set_memory_test.go index e1044263c..faf062d2a 100644 --- a/cmd/server/node_set_memory_test.go +++ b/cmd/server/node_set_memory_test.go @@ -6,9 +6,9 @@ import ( ) // nodeHashes is the set behind byNode's de-dup: for every node, the packets -// already listed under it. On a busy week it held about 650k entries and -// 21.5MB keyed by the packets' 16-character hashes. Keyed by packet ID the -// same membership takes about 30% less: 25.8MB -> 18MB for this fixture. +// already listed under it. Keyed by packet ID rather than by the packets' +// 16-character hashes, the same membership takes markedly less memory; the +// limit below sits between the two. func TestNodeSetRetainedHeap(t *testing.T) { const nodes, perNode = 2000, 250 // 500k (node, packet) pairs txs := make([]*StoreTx, perNode*4) diff --git a/cmd/server/store.go b/cmd/server/store.go index b7c8eb2d9..2b3f0833f 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -1914,9 +1914,8 @@ func (s *PacketStore) indexByNode(tx *StoreTx) bool { return foundNew } -// newNodeSets returns an empty nodeHashes. The sets hold transmission IDs: -// on a busy week they have ~650k entries, and int keys take about 30% less -// than the 16-character hash strings they used to be keyed by. +// newNodeSets returns an empty nodeHashes. The sets hold transmission IDs, +// which take less memory than the 16-character hashes they replaced. func newNodeSets() map[string]map[int]struct{} { return make(map[string]map[int]struct{}) } // nodeHasTx reports whether tx is listed under pubkey.