mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-10 19:56:56 +00:00
9e90548637
## Summary Remove `ResolvedPath []*string` field from `StoreTx` and `StoreObs` structs, replacing it with a compact membership index + on-demand SQL decode. This eliminates the dominant heap cost identified in profiling (#791, #799). **Spec:** #800 (consolidated from two rounds of expert + implementer review on #799) Closes #800 Closes #791 ## Design ### Removed - `StoreTx.ResolvedPath []*string` - `StoreObs.ResolvedPath []*string` - `TransmissionResp.ResolvedPath`, `ObservationResp.ResolvedPath` struct fields ### Added | Structure | Purpose | Est. cost at 1M obs | |---|---|---:| | `resolvedPubkeyIndex map[uint64][]int` | FNV-1a(pubkey) → []txID forward index | 50–120 MB | | `resolvedPubkeyReverse map[int][]uint64` | txID → []hashes for clean removal | ~40 MB | | `apiResolvedPathLRU` (10K entries) | FIFO cache for on-demand API decode | ~2 MB | ### Decode-window discipline `resolved_path` JSON decoded once per packet. Consumers fed in order, temp slice dropped — never stored on struct: 1. `addToByNode` — relay node indexing 2. `touchRelayLastSeen` — relay liveness DB updates 3. `byPathHop` resolved-key entries 4. `resolvedPubkeyIndex` + reverse insert 5. WebSocket broadcast map (raw JSON bytes) 6. Persist batch (raw JSON bytes for SQL UPDATE) ### Collision safety When the forward index returns candidates, a batched SQL query confirms exact pubkey presence using `LIKE '%"pubkey"%'` on the `resolved_path` column. ### Feature flag `useResolvedPathIndex` (default `true`). Off-path is conservative: all candidates kept, index not consulted. For one-release rollback safety. ## Files changed | File | Changes | |---|---| | `resolved_index.go` | **New** — index structures, LRU cache, on-demand SQL helpers, collision safety | | `store.go` | Remove RP fields, decode-window discipline in Load/Ingest, on-demand txToMap/obsToMap/enrichObs, eviction cleanup via SQL, memory accounting update | | `types.go` | Remove RP fields from TransmissionResp/ObservationResp | | `routes.go` | Replace `nodeInResolvedPath` with `nodeInResolvedPathViaIndex`, remove RP from mapSlice helpers | | `neighbor_persist.go` | Refactor backfill: reverse-map removal → forward+reverse insert → LRU invalidation | ## Tests added (27 new) **Unit:** - `TestStoreTx_ResolvedPathFieldAbsent` — reflection guard - `TestResolvedPubkeyIndex_BuildFromLoad` — forward+reverse consistency - `TestResolvedPubkeyIndex_HashCollision` — SQL collision safety - `TestResolvedPubkeyIndex_IngestUpdate` — maps reflect new ingests - `TestResolvedPubkeyIndex_RemoveOnEvict` — clean removal via reverse map - `TestResolvedPubkeyIndex_PerObsCoverage` — non-best obs pubkeys indexed - `TestAddToByNode_WithoutResolvedPathField` - `TestTouchRelayLastSeen_WithoutResolvedPathField` - `TestWebSocketBroadcast_IncludesResolvedPath` - `TestBackfill_InvalidatesLRU` - `TestEviction_ByNodeCleanup_OnDemandSQL` - `TestExtractResolvedPubkeys`, `TestMergeResolvedPubkeys` - `TestResolvedPubkeyHash_Deterministic` - `TestLRU_EvictionOnFull` **Endpoint:** - `TestPathsThroughNode_NilResolvedPathFallback` - `TestPacketsAPI_OnDemandResolvedPath` - `TestPacketsAPI_OnDemandResolvedPath_LRUHit` - `TestPacketsAPI_OnDemandResolvedPath_Empty` **Feature flag:** - `TestFeatureFlag_OffPath_PreservesOldBehavior` - `TestFeatureFlag_Toggle_NoStateLeak` **Concurrency:** - `TestReverseMap_NoLeakOnPartialFailure` - `TestDecodeWindow_LockHoldTimeBounded` - `TestLivePolling_LRUUnderConcurrentIngest` **Regression:** - `TestRepeaterLiveness_StillAccurate` **Benchmarks:** - `BenchmarkLoad_BeforeAfter` - `BenchmarkResolvedPubkeyIndex_Memory` - `BenchmarkPathsThroughNode_Latency` - `BenchmarkLivePolling_UnderIngest` ## Benchmark results ``` BenchmarkResolvedPubkeyIndex_Memory/pubkeys=50K 429ms 103MB 777K allocs BenchmarkResolvedPubkeyIndex_Memory/pubkeys=500K 4205ms 896MB 7.67M allocs BenchmarkLoad_BeforeAfter 65ms 20MB 202K allocs BenchmarkPathsThroughNode_Latency 3.9µs 0B 0 allocs BenchmarkLivePolling_UnderIngest 5.4µs 545B 7 allocs ``` Key: per-obs `[]*string` overhead completely eliminated. At 1M obs with 3 hops average, this saves ~72 bytes/obs × 1M = ~68 MB just from the slice headers + pointers, plus the JSON-decoded string data (~900 MB at scale per profiling). ## Design choices - **FNV-1a instead of xxhash**: stdlib availability, no external dependency. Performance is equivalent for this use case (pubkey strings are short). - **FIFO LRU instead of true LRU**: simpler implementation, adequate for the access pattern (mostly sequential obs IDs from live polling). - **Grouped packets view omits resolved_path**: cold path, not worth SQL round-trip per page render. - **Backfill pending check uses reverse-map presence** instead of per-obs field: if a tx has any indexed pubkeys, its observations are considered resolved. Closes #807 --------- Co-authored-by: you <you@example.com>
605 lines
19 KiB
Go
605 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// makeTestStore creates a PacketStore with fake packets for eviction testing.
|
|
// It does NOT use a DB — indexes are populated manually.
|
|
func makeTestStore(count int, startTime time.Time, intervalMin int) *PacketStore {
|
|
store := &PacketStore{
|
|
packets: make([]*StoreTx, 0, count),
|
|
byHash: make(map[string]*StoreTx, count),
|
|
byTxID: make(map[int]*StoreTx, count),
|
|
byObsID: make(map[int]*StoreObs, count*2),
|
|
byObserver: make(map[string][]*StoreObs),
|
|
byNode: make(map[string][]*StoreTx),
|
|
nodeHashes: make(map[string]map[string]bool),
|
|
byPayloadType: make(map[int][]*StoreTx),
|
|
spIndex: make(map[string]int),
|
|
distHops: make([]distHopRecord, 0),
|
|
distPaths: make([]distPathRecord, 0),
|
|
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,
|
|
}
|
|
|
|
obsID := 1000
|
|
for i := 0; i < count; i++ {
|
|
ts := startTime.Add(time.Duration(i*intervalMin) * time.Minute)
|
|
hash := fmt.Sprintf("hash%04d", i)
|
|
txID := i + 1
|
|
pt := 4 // ADVERT
|
|
decodedJSON := fmt.Sprintf(`{"pubKey":"pk%04d"}`, i)
|
|
|
|
tx := &StoreTx{
|
|
ID: txID,
|
|
Hash: hash,
|
|
FirstSeen: ts.UTC().Format(time.RFC3339),
|
|
PayloadType: &pt,
|
|
DecodedJSON: decodedJSON,
|
|
PathJSON: `["aa","bb","cc"]`,
|
|
}
|
|
|
|
// Add 2 observations per tx
|
|
for j := 0; j < 2; j++ {
|
|
obsID++
|
|
obsIDStr := fmt.Sprintf("obs%d", j)
|
|
obs := &StoreObs{
|
|
ID: obsID,
|
|
TransmissionID: txID,
|
|
ObserverID: obsIDStr,
|
|
ObserverName: fmt.Sprintf("Observer%d", j),
|
|
Timestamp: ts.UTC().Format(time.RFC3339),
|
|
}
|
|
tx.Observations = append(tx.Observations, obs)
|
|
tx.ObservationCount++
|
|
store.byObsID[obsID] = obs
|
|
store.byObserver[obsIDStr] = append(store.byObserver[obsIDStr], obs)
|
|
store.totalObs++
|
|
}
|
|
|
|
store.packets = append(store.packets, tx)
|
|
store.byHash[hash] = tx
|
|
store.byTxID[txID] = tx
|
|
store.byPayloadType[pt] = append(store.byPayloadType[pt], tx)
|
|
|
|
// Index by node
|
|
pk := fmt.Sprintf("pk%04d", i)
|
|
if store.nodeHashes[pk] == nil {
|
|
store.nodeHashes[pk] = make(map[string]bool)
|
|
}
|
|
store.nodeHashes[pk][hash] = true
|
|
store.byNode[pk] = append(store.byNode[pk], tx)
|
|
|
|
// Add to distance index
|
|
store.distHops = append(store.distHops, distHopRecord{tx: tx, Hash: hash})
|
|
store.distPaths = append(store.distPaths, distPathRecord{tx: tx, Hash: hash})
|
|
|
|
// Subpath index
|
|
addTxToSubpathIndex(store.spIndex, tx)
|
|
|
|
// Track bytes for self-accounting
|
|
store.trackedBytes += estimateStoreTxBytes(tx)
|
|
for _, obs := range tx.Observations {
|
|
store.trackedBytes += estimateStoreObsBytes(obs)
|
|
}
|
|
}
|
|
|
|
return store
|
|
}
|
|
|
|
func TestEvictStale_TimeBasedEviction(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
// 100 packets: first 50 are 48h old, last 50 are 1h old
|
|
store := makeTestStore(100, now.Add(-48*time.Hour), 0)
|
|
// Override: set first 50 to 48h ago, last 50 to 1h ago
|
|
for i := 0; i < 50; i++ {
|
|
store.packets[i].FirstSeen = now.Add(-48 * time.Hour).Format(time.RFC3339)
|
|
}
|
|
for i := 50; i < 100; i++ {
|
|
store.packets[i].FirstSeen = now.Add(-1 * time.Hour).Format(time.RFC3339)
|
|
}
|
|
|
|
store.retentionHours = 24
|
|
|
|
evicted := store.EvictStale()
|
|
if evicted != 50 {
|
|
t.Fatalf("expected 50 evicted, got %d", evicted)
|
|
}
|
|
if len(store.packets) != 50 {
|
|
t.Fatalf("expected 50 remaining, got %d", len(store.packets))
|
|
}
|
|
if len(store.byHash) != 50 {
|
|
t.Fatalf("expected 50 in byHash, got %d", len(store.byHash))
|
|
}
|
|
if len(store.byTxID) != 50 {
|
|
t.Fatalf("expected 50 in byTxID, got %d", len(store.byTxID))
|
|
}
|
|
// 50 remaining * 2 obs each = 100 obs
|
|
if store.totalObs != 100 {
|
|
t.Fatalf("expected 100 obs remaining, got %d", store.totalObs)
|
|
}
|
|
if len(store.byObsID) != 100 {
|
|
t.Fatalf("expected 100 in byObsID, got %d", len(store.byObsID))
|
|
}
|
|
if atomic.LoadInt64(&store.evicted) != 50 {
|
|
t.Fatalf("expected evicted counter=50, got %d", atomic.LoadInt64(&store.evicted))
|
|
}
|
|
|
|
// Verify evicted hashes are gone
|
|
if _, ok := store.byHash["hash0000"]; ok {
|
|
t.Fatal("hash0000 should have been evicted")
|
|
}
|
|
// Verify remaining hashes exist
|
|
if _, ok := store.byHash["hash0050"]; !ok {
|
|
t.Fatal("hash0050 should still exist")
|
|
}
|
|
|
|
// Verify distance indexes cleaned
|
|
if len(store.distHops) != 50 {
|
|
t.Fatalf("expected 50 distHops, got %d", len(store.distHops))
|
|
}
|
|
if len(store.distPaths) != 50 {
|
|
t.Fatalf("expected 50 distPaths, got %d", len(store.distPaths))
|
|
}
|
|
}
|
|
|
|
func TestEvictStale_NoEvictionWhenDisabled(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store := makeTestStore(10, now.Add(-48*time.Hour), 60)
|
|
// No retention set (defaults to 0)
|
|
|
|
evicted := store.EvictStale()
|
|
if evicted != 0 {
|
|
t.Fatalf("expected 0 evicted, got %d", evicted)
|
|
}
|
|
if len(store.packets) != 10 {
|
|
t.Fatalf("expected 10 remaining, got %d", len(store.packets))
|
|
}
|
|
}
|
|
|
|
func TestEvictStale_MemoryBasedEviction(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
|
// All packets are recent (1h old) so time-based won't trigger.
|
|
store.retentionHours = 24
|
|
store.maxMemoryMB = 3
|
|
// Set trackedBytes to simulate 6MB (over 3MB limit).
|
|
store.trackedBytes = 6 * 1048576
|
|
|
|
evicted := store.EvictStale()
|
|
if evicted == 0 {
|
|
t.Fatal("expected some evictions for memory cap")
|
|
}
|
|
// 25% safety cap should limit to 250 per pass
|
|
if evicted > 250 {
|
|
t.Fatalf("25%% safety cap violated: evicted %d", evicted)
|
|
}
|
|
// trackedBytes should have decreased
|
|
if store.trackedBytes >= 6*1048576 {
|
|
t.Fatal("trackedBytes should have decreased after eviction")
|
|
}
|
|
}
|
|
|
|
// TestEvictStale_MemoryBasedEviction_UnderestimatedHeap verifies that the 25%
|
|
// safety cap prevents cascading eviction even when trackedBytes is very high.
|
|
func TestEvictStale_MemoryBasedEviction_UnderestimatedHeap(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
|
store.retentionHours = 24
|
|
store.maxMemoryMB = 500
|
|
// Simulate trackedBytes 5x over budget.
|
|
store.trackedBytes = 2500 * 1048576
|
|
|
|
evicted := store.EvictStale()
|
|
if evicted == 0 {
|
|
t.Fatal("expected evictions when tracked is 5x over limit")
|
|
}
|
|
// Safety cap: max 25% per pass = 250
|
|
if evicted > 250 {
|
|
t.Fatalf("25%% safety cap violated: evicted %d of 1000", evicted)
|
|
}
|
|
if evicted != 250 {
|
|
t.Fatalf("expected exactly 250 evicted (25%% cap), got %d", evicted)
|
|
}
|
|
}
|
|
|
|
func TestEvictStale_CleansNodeIndexes(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store := makeTestStore(10, now.Add(-48*time.Hour), 0)
|
|
store.retentionHours = 24
|
|
|
|
// Verify node indexes exist before eviction
|
|
if len(store.byNode) != 10 {
|
|
t.Fatalf("expected 10 nodes indexed, got %d", len(store.byNode))
|
|
}
|
|
if len(store.nodeHashes) != 10 {
|
|
t.Fatalf("expected 10 nodeHashes, got %d", len(store.nodeHashes))
|
|
}
|
|
|
|
evicted := store.EvictStale()
|
|
if evicted != 10 {
|
|
t.Fatalf("expected 10 evicted, got %d", evicted)
|
|
}
|
|
|
|
// All should be cleaned
|
|
if len(store.byNode) != 0 {
|
|
t.Fatalf("expected 0 nodes, got %d", len(store.byNode))
|
|
}
|
|
if len(store.nodeHashes) != 0 {
|
|
t.Fatalf("expected 0 nodeHashes, got %d", len(store.nodeHashes))
|
|
}
|
|
if len(store.byPayloadType) != 0 {
|
|
t.Fatalf("expected 0 payload types, got %d", len(store.byPayloadType))
|
|
}
|
|
if len(store.byObserver) != 0 {
|
|
t.Fatalf("expected 0 observers, got %d", len(store.byObserver))
|
|
}
|
|
}
|
|
|
|
func TestEvictStale_CleansResolvedPathNodeIndexes(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
|
|
// Create a temp DB for on-demand SQL fetch during eviction
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
|
|
store := &PacketStore{
|
|
packets: make([]*StoreTx, 0),
|
|
byHash: make(map[string]*StoreTx),
|
|
byTxID: make(map[int]*StoreTx),
|
|
byObsID: make(map[int]*StoreObs),
|
|
byObserver: make(map[string][]*StoreObs),
|
|
byNode: make(map[string][]*StoreTx),
|
|
nodeHashes: make(map[string]map[string]bool),
|
|
byPayloadType: make(map[int][]*StoreTx),
|
|
spIndex: make(map[string]int),
|
|
distHops: make([]distHopRecord, 0),
|
|
distPaths: make([]distPathRecord, 0),
|
|
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,
|
|
retentionHours: 24,
|
|
db: db,
|
|
useResolvedPathIndex: true,
|
|
}
|
|
store.initResolvedPathIndex()
|
|
|
|
// Create a packet indexed via resolved_path pubkeys
|
|
relayPK := "relay0001abcdef"
|
|
txID := 1
|
|
obsID := 100
|
|
tx := &StoreTx{
|
|
ID: txID,
|
|
Hash: "hash_rp_001",
|
|
FirstSeen: now.Add(-48 * time.Hour).UTC().Format(time.RFC3339),
|
|
}
|
|
obs := &StoreObs{
|
|
ID: obsID,
|
|
TransmissionID: txID,
|
|
ObserverID: "obs0",
|
|
Timestamp: tx.FirstSeen,
|
|
}
|
|
tx.Observations = append(tx.Observations, obs)
|
|
|
|
// Insert into DB so on-demand SQL fetch works during eviction
|
|
db.conn.Exec("INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (?, '', ?, ?)",
|
|
txID, tx.Hash, tx.FirstSeen)
|
|
db.conn.Exec("INSERT INTO observations (id, transmission_id, observer_idx, path_json, timestamp, resolved_path) VALUES (?, ?, 1, ?, ?, ?)",
|
|
obsID, txID, `["aa"]`, now.Add(-48*time.Hour).Unix(), `["`+relayPK+`"]`)
|
|
|
|
store.packets = append(store.packets, tx)
|
|
store.byHash[tx.Hash] = tx
|
|
store.byTxID[tx.ID] = tx
|
|
store.byObsID[obs.ID] = obs
|
|
store.byObserver["obs0"] = append(store.byObserver["obs0"], obs)
|
|
|
|
// Index relay via decode-window simulation
|
|
store.addToByNode(tx, relayPK)
|
|
store.addToResolvedPubkeyIndex(txID, []string{relayPK})
|
|
|
|
// Verify indexed
|
|
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] {
|
|
t.Fatalf("expected nodeHashes[%s] to contain %s", relayPK, tx.Hash)
|
|
}
|
|
|
|
evicted := store.RunEviction()
|
|
if evicted != 1 {
|
|
t.Fatalf("expected 1 evicted, got %d", evicted)
|
|
}
|
|
|
|
// Verify resolved_path entries are cleaned up
|
|
if len(store.byNode[relayPK]) != 0 {
|
|
t.Fatalf("expected byNode[%s] to be empty after eviction, got %d", relayPK, len(store.byNode[relayPK]))
|
|
}
|
|
if _, exists := store.nodeHashes[relayPK]; exists {
|
|
t.Fatalf("expected nodeHashes[%s] to be deleted after eviction", relayPK)
|
|
}
|
|
// Verify resolved pubkey index is cleaned up
|
|
h := resolvedPubkeyHash(relayPK)
|
|
if len(store.resolvedPubkeyIndex[h]) != 0 {
|
|
t.Fatalf("expected resolvedPubkeyIndex to be empty after eviction")
|
|
}
|
|
if _, exists := store.resolvedPubkeyReverse[txID]; exists {
|
|
t.Fatalf("expected resolvedPubkeyReverse to be empty after eviction")
|
|
}
|
|
}
|
|
|
|
func TestEvictStale_RunEvictionThreadSafe(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store := makeTestStore(20, now.Add(-48*time.Hour), 0)
|
|
store.retentionHours = 24
|
|
|
|
evicted := store.RunEviction()
|
|
if evicted != 20 {
|
|
t.Fatalf("expected 20 evicted, got %d", evicted)
|
|
}
|
|
}
|
|
|
|
func TestStartEvictionTicker_NoopWhenDisabled(t *testing.T) {
|
|
store := &PacketStore{}
|
|
stop := store.StartEvictionTicker()
|
|
stop() // should not panic
|
|
}
|
|
|
|
func TestNewPacketStoreWithConfig(t *testing.T) {
|
|
cfg := &PacketStoreConfig{
|
|
RetentionHours: 48,
|
|
MaxMemoryMB: 512,
|
|
}
|
|
store := NewPacketStore(nil, cfg)
|
|
if store.retentionHours != 48 {
|
|
t.Fatalf("expected retentionHours=48, got %f", store.retentionHours)
|
|
}
|
|
if store.maxMemoryMB != 512 {
|
|
t.Fatalf("expected maxMemoryMB=512, got %d", store.maxMemoryMB)
|
|
}
|
|
}
|
|
|
|
func TestNewPacketStoreNilConfig(t *testing.T) {
|
|
store := NewPacketStore(nil, nil)
|
|
if store.retentionHours != 0 {
|
|
t.Fatalf("expected retentionHours=0, got %f", store.retentionHours)
|
|
}
|
|
}
|
|
|
|
func TestCacheTTLFromConfig(t *testing.T) {
|
|
// With config values: analyticsHashSizes and analyticsRF should override defaults.
|
|
cacheTTL := map[string]interface{}{
|
|
"analyticsHashSizes": float64(7200),
|
|
"analyticsRF": float64(300),
|
|
}
|
|
store := NewPacketStore(nil, nil, cacheTTL)
|
|
if store.collisionCacheTTL != 7200*time.Second {
|
|
t.Fatalf("expected collisionCacheTTL=7200s, got %v", store.collisionCacheTTL)
|
|
}
|
|
if store.rfCacheTTL != 300*time.Second {
|
|
t.Fatalf("expected rfCacheTTL=300s, got %v", store.rfCacheTTL)
|
|
}
|
|
}
|
|
|
|
func TestCacheTTLDefaults(t *testing.T) {
|
|
// Without config, defaults should apply.
|
|
store := NewPacketStore(nil, nil)
|
|
if store.collisionCacheTTL != 3600*time.Second {
|
|
t.Fatalf("expected default collisionCacheTTL=3600s, got %v", store.collisionCacheTTL)
|
|
}
|
|
if store.rfCacheTTL != 15*time.Second {
|
|
t.Fatalf("expected default rfCacheTTL=15s, got %v", store.rfCacheTTL)
|
|
}
|
|
}
|
|
|
|
// --- Self-accounting memory tracking tests ---
|
|
|
|
func TestTrackedBytes_IncreasesOnInsert(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store := makeTestStore(0, now, 0)
|
|
if store.trackedBytes != 0 {
|
|
t.Fatalf("expected 0 trackedBytes for empty store, got %d", store.trackedBytes)
|
|
}
|
|
|
|
store2 := makeTestStore(10, now, 1)
|
|
if store2.trackedBytes <= 0 {
|
|
t.Fatal("expected positive trackedBytes after inserting 10 packets")
|
|
}
|
|
// Each packet has 2 observations; should be roughly 10*(384+5*48) + 20*(192+2*48) = 10*624 + 20*288 = 12000
|
|
expectedMin := int64(10*600 + 20*250) // rough lower bound
|
|
if store2.trackedBytes < expectedMin {
|
|
t.Fatalf("trackedBytes %d seems too low (expected > %d)", store2.trackedBytes, expectedMin)
|
|
}
|
|
}
|
|
|
|
func TestTrackedBytes_DecreasesOnEvict(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store := makeTestStore(100, now.Add(-48*time.Hour), 0)
|
|
store.retentionHours = 24
|
|
|
|
beforeBytes := store.trackedBytes
|
|
if beforeBytes <= 0 {
|
|
t.Fatal("expected positive trackedBytes before eviction")
|
|
}
|
|
|
|
evicted := store.EvictStale()
|
|
if evicted != 100 {
|
|
t.Fatalf("expected 100 evicted, got %d", evicted)
|
|
}
|
|
if store.trackedBytes != 0 {
|
|
t.Fatalf("expected 0 trackedBytes after evicting all, got %d", store.trackedBytes)
|
|
}
|
|
}
|
|
|
|
func TestTrackedBytes_MatchesExpectedAfterMixedInsertEvict(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
// Create 100 packets, 50 old + 50 recent
|
|
store := makeTestStore(100, now.Add(-48*time.Hour), 0)
|
|
for i := 50; i < 100; i++ {
|
|
store.packets[i].FirstSeen = now.Add(-1 * time.Hour).Format(time.RFC3339)
|
|
}
|
|
store.retentionHours = 24
|
|
|
|
totalBefore := store.trackedBytes
|
|
|
|
// Calculate expected bytes for first 50 packets (to be evicted)
|
|
var evictedBytes int64
|
|
for i := 0; i < 50; i++ {
|
|
tx := store.packets[i]
|
|
evictedBytes += estimateStoreTxBytes(tx)
|
|
for _, obs := range tx.Observations {
|
|
evictedBytes += estimateStoreObsBytes(obs)
|
|
}
|
|
}
|
|
|
|
store.EvictStale()
|
|
|
|
expectedAfter := totalBefore - evictedBytes
|
|
if store.trackedBytes != expectedAfter {
|
|
t.Fatalf("trackedBytes %d != expected %d (before=%d, evicted=%d)",
|
|
store.trackedBytes, expectedAfter, totalBefore, evictedBytes)
|
|
}
|
|
}
|
|
|
|
func TestWatermarkHysteresis(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
|
store.retentionHours = 0 // no time-based eviction
|
|
store.maxMemoryMB = 1 // 1MB budget
|
|
|
|
// Set trackedBytes to just above high watermark
|
|
highWatermark := int64(1 * 1048576)
|
|
lowWatermark := int64(float64(highWatermark) * 0.85)
|
|
store.trackedBytes = highWatermark + 1
|
|
|
|
evicted := store.EvictStale()
|
|
if evicted == 0 {
|
|
t.Fatal("expected eviction when above high watermark")
|
|
}
|
|
if store.trackedBytes > lowWatermark+1024 {
|
|
t.Fatalf("expected trackedBytes near low watermark after eviction, got %d (low=%d)",
|
|
store.trackedBytes, lowWatermark)
|
|
}
|
|
|
|
// Now set trackedBytes to just below high watermark — should NOT trigger
|
|
store.trackedBytes = highWatermark - 1
|
|
evicted2 := store.EvictStale()
|
|
if evicted2 != 0 {
|
|
t.Fatalf("expected no eviction below high watermark, got %d", evicted2)
|
|
}
|
|
}
|
|
|
|
func TestSafetyCap25Percent(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
|
store.retentionHours = 0
|
|
store.maxMemoryMB = 1
|
|
|
|
// Set trackedBytes way over limit to force maximum eviction
|
|
store.trackedBytes = 100 * 1048576 // 100MB vs 1MB limit
|
|
|
|
evicted := store.EvictStale()
|
|
// 25% of 1000 = 250
|
|
if evicted > 250 {
|
|
t.Fatalf("25%% safety cap violated: evicted %d of 1000 (max should be 250)", evicted)
|
|
}
|
|
if evicted != 250 {
|
|
t.Fatalf("expected exactly 250 evicted (25%% cap), got %d", evicted)
|
|
}
|
|
if len(store.packets) != 750 {
|
|
t.Fatalf("expected 750 remaining, got %d", len(store.packets))
|
|
}
|
|
}
|
|
|
|
func TestMultiplePassesConverge(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
|
store.retentionHours = 0
|
|
// Set budget to half the actual tracked bytes — requires ~2 passes
|
|
actualBytes := store.trackedBytes
|
|
store.maxMemoryMB = int(float64(actualBytes) / 1048576.0 / 2)
|
|
if store.maxMemoryMB < 1 {
|
|
store.maxMemoryMB = 1
|
|
}
|
|
|
|
totalEvicted := 0
|
|
for pass := 0; pass < 20; pass++ {
|
|
evicted := store.EvictStale()
|
|
if evicted == 0 {
|
|
break
|
|
}
|
|
totalEvicted += evicted
|
|
}
|
|
|
|
// After convergence, trackedBytes should be at or below high watermark
|
|
// (may be between low and high due to hysteresis — that's fine)
|
|
highWatermark := int64(store.maxMemoryMB) * 1048576
|
|
if store.trackedBytes > highWatermark {
|
|
t.Fatalf("did not converge: trackedBytes=%d (%.1fMB) > highWatermark=%d after multiple passes",
|
|
store.trackedBytes, float64(store.trackedBytes)/1048576.0, highWatermark)
|
|
}
|
|
if totalEvicted == 0 {
|
|
t.Fatal("expected some evictions across multiple passes")
|
|
}
|
|
}
|
|
|
|
func TestEstimateStoreTxBytes(t *testing.T) {
|
|
tx := &StoreTx{
|
|
RawHex: "aabbcc",
|
|
Hash: "hash1234",
|
|
DecodedJSON: `{"pubKey":"pk1"}`,
|
|
PathJSON: `["aa","bb"]`,
|
|
}
|
|
est := estimateStoreTxBytes(tx)
|
|
// Manual calculation: base + string lengths + index entries + perTxMaps + path hops + subpaths
|
|
hops := int64(len(txGetParsedPath(tx)))
|
|
manualCalc := int64(storeTxBaseBytes) + int64(len(tx.RawHex)+len(tx.Hash)+len(tx.DecodedJSON)+len(tx.PathJSON)) + int64(numIndexesPerTx*indexEntryBytes)
|
|
manualCalc += perTxMapsBytes
|
|
manualCalc += hops * perPathHopBytes
|
|
if hops > 1 {
|
|
manualCalc += (hops * (hops - 1) / 2) * perSubpathEntryBytes
|
|
}
|
|
if est != manualCalc {
|
|
t.Fatalf("estimateStoreTxBytes = %d, want %d (manual calc)", est, manualCalc)
|
|
}
|
|
if est < 600 || est > 1200 {
|
|
t.Fatalf("estimateStoreTxBytes = %d, expected in range [600, 1200]", est)
|
|
}
|
|
}
|
|
|
|
func TestEstimateStoreObsBytes(t *testing.T) {
|
|
obs := &StoreObs{
|
|
ObserverID: "obs123",
|
|
PathJSON: `["aa"]`,
|
|
}
|
|
est := estimateStoreObsBytes(obs)
|
|
// storeObsBaseBytes(192) + len(ObserverID=6) + len(PathJSON=6) + 2*48(96) = 300
|
|
expected := int64(192 + 6 + 6 + 2*48)
|
|
if est != expected {
|
|
t.Fatalf("estimateStoreObsBytes = %d, want %d", est, expected)
|
|
}
|
|
}
|
|
|
|
func BenchmarkEviction100K(b *testing.B) {
|
|
now := time.Now().UTC()
|
|
for i := 0; i < b.N; i++ {
|
|
b.StopTimer()
|
|
store := makeTestStore(100000, now.Add(-48*time.Hour), 0)
|
|
store.retentionHours = 24
|
|
b.StartTimer()
|
|
store.EvictStale()
|
|
}
|
|
}
|