mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-04 21:46:01 +00:00
## Problem Every time new data is ingested (`IngestNewFromDB`, `IngestNewObservations`, `EvictStale`), **all 6 analytics caches** are wiped by creating new empty maps — regardless of what kind of data actually changed. With the poller running every 1 second, this means the 15s cache TTL is effectively bypassed because caches are cleared far more frequently than they expire. ## Fix Introduces a `cacheInvalidation` flags struct and `invalidateCachesFor()` method that selectively clears only the caches affected by the ingested data: | Flag | Caches Cleared | |------|----------------| | `hasNewObservations` | RF (SNR/RSSI data changed) | | `hasNewPaths` | Topology, Distance, Subpaths | | `hasNewTransmissions` | Hash sizes | | `hasChannelData` | Channels (GRP_TXT payload_type 5) + channels list cache | | `eviction` | All (data removed, everything potentially stale) | ### Impact For a typical ingest cycle with ADVERT/ACK/TXT_MSG packets (no GRP_TXT): - **Before:** All 6 caches cleared every cycle - **After:** Channel cache preserved (most common case), hash cache preserved on observation-only ingestion For observation-only ingestion (`IngestNewObservations`): - **Before:** All 6 caches cleared - **After:** Only RF cache cleared (+ topo/dist/subpath if paths actually changed) ## Tests 7 new unit tests in `cache_invalidation_test.go` covering: - Eviction clears all caches - Observation-only ingest preserves non-RF caches - Transmission-only ingest clears only hash cache - Channel data clears only channel cache - Path changes clear topo/dist/subpath - Combined flags work correctly - No flags = no invalidation All existing tests pass. ### Post-rebase fix Restored `channelsCacheRes` invalidation that was accidentally dropped during the refactor. The old code cleared this separate channels list cache on every ingest, but `invalidateCachesFor()` didn't include it. Now cleared on `hasChannelData` and `eviction`. Fixes #375 --------- Co-authored-by: you <you@example.com>
172 lines
4.6 KiB
Go
172 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// newTestStore creates a minimal PacketStore for cache invalidation testing.
|
|
func newTestStore(t *testing.T) *PacketStore {
|
|
t.Helper()
|
|
return &PacketStore{
|
|
rfCache: make(map[string]*cachedResult),
|
|
topoCache: make(map[string]*cachedResult),
|
|
hashCache: make(map[string]*cachedResult),
|
|
chanCache: make(map[string]*cachedResult),
|
|
distCache: make(map[string]*cachedResult),
|
|
subpathCache: make(map[string]*cachedResult),
|
|
rfCacheTTL: 15 * time.Second,
|
|
}
|
|
}
|
|
|
|
// populateAllCaches fills every analytics cache with a dummy entry so tests
|
|
// can verify which caches are cleared and which are preserved.
|
|
func populateAllCaches(s *PacketStore) {
|
|
s.cacheMu.Lock()
|
|
defer s.cacheMu.Unlock()
|
|
dummy := &cachedResult{data: map[string]interface{}{"test": true}, expiresAt: time.Now().Add(time.Hour)}
|
|
s.rfCache["global"] = dummy
|
|
s.topoCache["global"] = dummy
|
|
s.hashCache["global"] = dummy
|
|
s.chanCache["global"] = dummy
|
|
s.distCache["global"] = dummy
|
|
s.subpathCache["global"] = dummy
|
|
}
|
|
|
|
// cachePopulated returns which caches still have their "global" entry.
|
|
func cachePopulated(s *PacketStore) map[string]bool {
|
|
s.cacheMu.Lock()
|
|
defer s.cacheMu.Unlock()
|
|
return map[string]bool{
|
|
"rf": len(s.rfCache) > 0,
|
|
"topo": len(s.topoCache) > 0,
|
|
"hash": len(s.hashCache) > 0,
|
|
"chan": len(s.chanCache) > 0,
|
|
"dist": len(s.distCache) > 0,
|
|
"subpath": len(s.subpathCache) > 0,
|
|
}
|
|
}
|
|
|
|
func TestInvalidateCachesFor_Eviction(t *testing.T) {
|
|
s := newTestStore(t)
|
|
populateAllCaches(s)
|
|
|
|
s.invalidateCachesFor(cacheInvalidation{eviction: true})
|
|
|
|
pop := cachePopulated(s)
|
|
for name, has := range pop {
|
|
if has {
|
|
t.Errorf("eviction should clear %s cache", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidateCachesFor_NewObservationsOnly(t *testing.T) {
|
|
s := newTestStore(t)
|
|
populateAllCaches(s)
|
|
|
|
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
|
|
|
pop := cachePopulated(s)
|
|
if pop["rf"] {
|
|
t.Error("rf cache should be cleared on new observations")
|
|
}
|
|
// These should be preserved
|
|
for _, name := range []string{"topo", "hash", "chan", "dist", "subpath"} {
|
|
if !pop[name] {
|
|
t.Errorf("%s cache should NOT be cleared on observation-only ingest", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidateCachesFor_NewTransmissionsOnly(t *testing.T) {
|
|
s := newTestStore(t)
|
|
populateAllCaches(s)
|
|
|
|
s.invalidateCachesFor(cacheInvalidation{hasNewTransmissions: true})
|
|
|
|
pop := cachePopulated(s)
|
|
if pop["hash"] {
|
|
t.Error("hash cache should be cleared on new transmissions")
|
|
}
|
|
for _, name := range []string{"rf", "topo", "chan", "dist", "subpath"} {
|
|
if !pop[name] {
|
|
t.Errorf("%s cache should NOT be cleared on transmission-only ingest", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidateCachesFor_ChannelDataOnly(t *testing.T) {
|
|
s := newTestStore(t)
|
|
populateAllCaches(s)
|
|
|
|
s.invalidateCachesFor(cacheInvalidation{hasChannelData: true})
|
|
|
|
pop := cachePopulated(s)
|
|
if pop["chan"] {
|
|
t.Error("chan cache should be cleared on channel data")
|
|
}
|
|
for _, name := range []string{"rf", "topo", "hash", "dist", "subpath"} {
|
|
if !pop[name] {
|
|
t.Errorf("%s cache should NOT be cleared on channel-data-only ingest", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidateCachesFor_NewPaths(t *testing.T) {
|
|
s := newTestStore(t)
|
|
populateAllCaches(s)
|
|
|
|
s.invalidateCachesFor(cacheInvalidation{hasNewPaths: true})
|
|
|
|
pop := cachePopulated(s)
|
|
for _, name := range []string{"topo", "dist", "subpath"} {
|
|
if pop[name] {
|
|
t.Errorf("%s cache should be cleared on new paths", name)
|
|
}
|
|
}
|
|
for _, name := range []string{"rf", "hash", "chan"} {
|
|
if !pop[name] {
|
|
t.Errorf("%s cache should NOT be cleared on path-only ingest", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidateCachesFor_CombinedFlags(t *testing.T) {
|
|
s := newTestStore(t)
|
|
populateAllCaches(s)
|
|
|
|
// Simulate a typical ingest: new transmissions with observations but no GRP_TXT
|
|
s.invalidateCachesFor(cacheInvalidation{
|
|
hasNewObservations: true,
|
|
hasNewTransmissions: true,
|
|
hasNewPaths: true,
|
|
})
|
|
|
|
pop := cachePopulated(s)
|
|
// rf, topo, hash, dist, subpath should all be cleared
|
|
for _, name := range []string{"rf", "topo", "hash", "dist", "subpath"} {
|
|
if pop[name] {
|
|
t.Errorf("%s cache should be cleared with combined flags", name)
|
|
}
|
|
}
|
|
// chan should be preserved (no GRP_TXT)
|
|
if !pop["chan"] {
|
|
t.Error("chan cache should NOT be cleared without hasChannelData flag")
|
|
}
|
|
}
|
|
|
|
func TestInvalidateCachesFor_NoFlags(t *testing.T) {
|
|
s := newTestStore(t)
|
|
populateAllCaches(s)
|
|
|
|
s.invalidateCachesFor(cacheInvalidation{})
|
|
|
|
pop := cachePopulated(s)
|
|
for name, has := range pop {
|
|
if !has {
|
|
t.Errorf("%s cache should be preserved when no flags are set", name)
|
|
}
|
|
}
|
|
}
|