Files
meshcore-analyzer/cmd/server/pathhop_eviction_1908_test.go
T
n30nex cf67a5e5ec fix: remove evicted resolved path hops and preserve relay snapshots (#1966)
Eviction removes raw wire hops but leaves resolved full-key entries in
`byPathHop`, retaining expired transmissions and stale relay
counts/scopes. Filter every hop bucket once per eviction batch using the
existing evicted-ID set, remove duplicate references and empty buckets,
and clear discarded pointer slots.

Bulk relay aggregation now owns its bucket snapshots before releasing
the read lock, so eviction and raw-path updates cannot mutate an
in-flight reader. Three existing handler test fixtures also wait for
index readiness or explicitly simulate not-ready state, preserving their
original 200/503 assertions.

Fixes #1908.

Validation:

- Regression commits fail before their corresponding fixes: resolved
keys/counts/scopes remain after eviction, and saved relay snapshots
change during eviction.
- Targeted eviction, relay, scope, cache and concurrent-reader checks
pass under `-race`; coverage includes time/cap eviction, missing
resolved-path prefetch, disabled membership indexing, duplicate
references, retained backing arrays and surviving entries.
- Local browser smoke: nodes, node details/path attribution and
analytics render using the fixture-backed Go server.
- The last full Windows server race run, before the final snapshot-copy
correction, had one remaining DB-only timing failure
(`TestGetChannelMessagesPerfLargeChannel`: 2.198s against a 1.5s
budget). The final correction was checked with focused race tests. The
unchanged ingestor suite also cannot create one symlink without Windows
privileges. These thresholds/assertions were preserved; full Linux
Go/E2E results still require upstream CI approval.

Performance tradeoff: cleanup is O(total indexed pointers) per nonempty
eviction batch, under the existing write lock. The minute-based ticker
pays for one sweep instead of repeated scans of shared raw buckets. No
per-transmission string index or dependency is added. Synthetic
benchmark medians (three single-iteration runs, shared Windows host):

| Transmissions | Evicted | Before | After |
|---:|---:|---:|---:|
| 30,000 | 1 | 1.07 ms | 14.19 ms |
| 30,000 | 3,000 | 56.27 ms | 61.68 ms |
| 30,000 | 7,500 | 83.58 ms | 65.54 ms |
| 100,000 | 1 | 0.30 ms | 56.95 ms |
| 100,000 | 10,000 | 949.93 ms | 190.71 ms |
| 100,000 | 25,000 | 1,834.48 ms | 320.38 ms |

Fixture: eight raw plus eight resolved hops per transmission, two
observations, 2,048 relays; 480,000/1,600,000 hop entries. Timing
includes acquiring the store lock and omits unrelated secondary indexes.
Small batches now pay for the complete sweep; shared-host timing is
noisy.

Owning the bulk reader's arrays also has a measured cost on cold/bulk
recomputation, rather than cached hits. Snapshot medians from three
samples of ten iterations:

| Transmissions / relay nodes | Before time / bytes per operation |
After time / bytes per operation |
|---|---:|---:|
| 30,000 / 50 | 0.0068 ms / 5,416 B | 23.65 ms / 4,101,435 B |
| 30,000 / 2,000 | 0.1374 ms / 196,768 B | 26.77 ms / 4,274,336 B |
| 100,000 / 2,000 | 0.1376 ms / 196,768 B | 27.89 ms / 13,959,337 B |

These are total snapshot costs, comparing the unsafe header-only
snapshot with owned pointer arrays. Cleanup guarantees here apply to
`byPathHop`; other indexes and existing periodic bulk-cache freshness
are outside this change.

Following #1922, this runtime fix is separate from the release-routing
and frontend-runner PRs. Current Go and E2E job results should be
assessed separately from workflow-approval or staging-runner state.
2026-09-06 21:00:44 +02:00

186 lines
6.4 KiB
Go

package main
import (
"fmt"
"reflect"
"strings"
"testing"
"time"
)
func TestPathHopEviction1908(t *testing.T) {
for _, mode := range []string{"time-direct", "time-run", "memory-empty-batch", "memory-index-disabled"} {
t.Run(mode, func(t *testing.T) {
now := time.Now().UTC()
store := makeTestStore(8, now.Add(-30*time.Minute), 0)
store.byPayloadType[5] = store.byPayloadType[4]
delete(store.byPayloadType, 4)
store.byPathHop = make(map[string][]*StoreTx)
store.useResolvedPathIndex = mode != "memory-index-disabled"
store.initResolvedPathIndex()
store.retentionHours = 1
if strings.HasPrefix(mode, "memory-") {
store.maxMemoryMB = 1
store.trackedBytes = 8 * 1048576 // The 25% cap evicts the first two.
} else {
for _, tx := range store.packets[:2] {
tx.FirstSeen = now.Add(-2 * time.Hour).Format(time.RFC3339)
}
}
shared := strings.Repeat("a", 64)
expiredOnly := strings.Repeat("b", 64)
retainedOnly := strings.Repeat("c", 64)
expiredScope, retainedScope := "expired-scope", "retained-scope"
hopsSeen := make(map[string]bool)
for i, tx := range store.packets {
*tx.PayloadType = 5 // Non-advert traffic contributes to relay stats.
pk := retainedOnly
tx.ScopeName = &retainedScope
if i < 2 {
pk = expiredOnly
tx.ScopeName = &expiredScope
}
addTxToPathHopIndex(store.byPathHop, tx)
store.indexResolvedPathHops(tx, []string{shared, pk}, hopsSeen)
}
// Repeated observations can append the same resolved association again.
store.indexResolvedPathHops(store.packets[0], []string{shared, expiredOnly}, hopsSeen)
store.indexResolvedPathHops(store.packets[7], []string{shared}, hopsSeen)
addTxToPathHopIndex(store.byPathHop, store.packets[0])
// Keep the original backing arrays observable: a shortened slice must
// not hide pointers that keep evicted transmissions alive for the GC.
backingArrays := make(map[string][]*StoreTx, len(store.byPathHop))
for key, list := range store.byPathHop {
backingArrays[key] = list[:cap(list)]
}
before := store.GetRepeaterNodeStatsBatchCached([]string{shared}, 24)[shared].Info
if before.RelayCount24h != 8 || len(before.TransportedScopes) != 2 {
t.Fatalf("fixture must index all transmissions and scopes: %+v", before)
}
var evicted int
if mode == "time-run" {
evicted = store.RunEviction() // No DB: cleanup cannot depend on SQL.
} else {
store.mu.Lock()
if strings.HasPrefix(mode, "memory-") {
evicted = store.EvictStaleWithRP(map[int][]string{})
} else {
evicted = store.EvictStale()
}
store.mu.Unlock()
}
if evicted != 2 {
t.Fatalf("evicted %d transmissions, want 2", evicted)
}
expected := map[string][]int{
"aa": {3, 4, 5, 6, 7, 8},
"bb": {3, 4, 5, 6, 7, 8},
"cc": {3, 4, 5, 6, 7, 8},
shared: {3, 4, 5, 6, 7, 8, 8},
retainedOnly: {3, 4, 5, 6, 7, 8},
}
if len(store.byPathHop) != len(expected) {
t.Errorf("path-hop index has %d keys, want %d (expired-only keys must disappear)", len(store.byPathHop), len(expected))
}
for key, want := range expected {
var ids []int
for _, tx := range store.byPathHop[key] {
ids = append(ids, tx.ID)
}
if !reflect.DeepEqual(ids, want) {
t.Errorf("path-hop bucket %q = %v, want %v", key, ids, want)
}
}
for key, list := range backingArrays {
for _, tx := range list {
if tx != nil && tx.ID <= 2 {
t.Errorf("backing array for %q still retains evicted tx %d", key, tx.ID)
break
}
}
}
if store.relayStatsCache != nil {
t.Error("eviction must invalidate the relay-stats cache")
}
after := store.GetRepeaterNodeStatsBatchCached([]string{shared}, 24)[shared].Info
if after.RelayCount24h != 6 {
t.Errorf("relay count after eviction = %d, want 6", after.RelayCount24h)
}
if !reflect.DeepEqual(after.TransportedScopes, []string{retainedScope}) {
t.Errorf("transported scopes after eviction = %v, want only retained scope", after.TransportedScopes)
}
// A later sweep that removes every survivor must also remove every key.
for _, tx := range store.packets {
tx.FirstSeen = now.Add(-2 * time.Hour).Format(time.RFC3339)
}
if got := store.RunEviction(); got != 6 {
t.Fatalf("final eviction removed %d transmissions, want 6", got)
}
if len(store.byPathHop) != 0 {
t.Errorf("empty store retains %d path-hop keys", len(store.byPathHop))
}
})
}
}
// Benchmark the eviction path with 8 raw and 8 resolved hops per transmission,
// 2 observations each, and 2,048 resolved relays sharing 256 raw-prefix buckets.
// Other secondary indexes are omitted to isolate path-hop cleanup cost. Setup
// is outside the timer; the timed operation includes acquiring the store lock.
func BenchmarkPathHopEviction1908(b *testing.B) {
const hops = 8
pubkeys := make([]string, 2048)
for i := range pubkeys {
pubkeys[i] = fmt.Sprintf("%02x%062x", i%256, i)
}
for _, count := range []int{30000, 100000} {
for _, evictCount := range []int{1, count / 10, count / 4} {
b.Run(fmt.Sprintf("tx=%d/evict=%d", count, evictCount), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
b.StopTimer()
now := time.Now().UTC()
old := now.Add(-2 * time.Hour).Format(time.RFC3339)
recent := now.Add(-30 * time.Minute).Format(time.RFC3339)
store := &PacketStore{
packets: make([]*StoreTx, count),
byPathHop: make(map[string][]*StoreTx, len(pubkeys)+256),
spIndex: make(map[string]int),
retentionHours: 1,
}
hopsSeen := make(map[string]bool, hops*2)
for j := range store.packets {
tx := &StoreTx{ID: j + 1, FirstSeen: recent, parsedPath: make([]string, hops), pathParsed: true}
if j < evictCount {
tx.FirstSeen = old
}
tx.Observations = []*StoreObs{{ID: j * 2}, {ID: j*2 + 1}}
resolved := make([]string, hops)
for k := range resolved {
resolved[k] = pubkeys[(j*17+k*13)%len(pubkeys)]
tx.parsedPath[k] = resolved[k][:2]
}
store.packets[j] = tx
addTxToPathHopIndex(store.byPathHop, tx)
store.addResolvedPubkeysToPathHopIndex(tx, resolved, hopsSeen)
}
b.StartTimer()
store.mu.Lock()
got := store.EvictStale()
store.mu.Unlock()
b.StopTimer()
if got != evictCount {
b.Fatalf("evicted %d transmissions, want %d", got, evictCount)
}
}
b.ReportMetric(float64(count*hops*2), "entries/batch")
})
}
}
}