mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-13 04:58:09 +00:00
Relates to #2108 ## Problem `traffic_share_score` grows with server uptime until it is far above reality, and many relays end up clamped at 1.0. The score is the number of non-advert entries in `byPathHop[pubkey]` divided by the number of non-advert transmissions. `indexResolvedPathHops` runs once per **observation**, on the live-ingest path (`IngestNewFromDB`) and on the late-observation path (`IngestNewObservations`). `addResolvedPubkeysToPathHopIndex` only de-duplicates within one call, so every further observation through the same relay appends the transmission to that relay's bucket again. The denominator counts each transmission once. After a restart the values look right, because `buildPathHopIndex` → `retainResolvedPathHops` de-duplicates by `*StoreTx`. They then drift upwards as live observations arrive. None of the affected functions has changed on `master` since the diagnosis against `415362c`. This branch is based on `000d9ab`. ## Fix 1. **Idempotent insert, per (transmission, relay key).** `addResolvedPubkeysToPathHopIndex` keeps a side map `pathHopResolved map[*StoreTx][]string`. It holds the resolved keys each transmission is already indexed under and skips those. - Keys are compared **exactly**, so no collision can ever drop an entry. - A later observation through a **new** relay still adds that relay, once. - `StoreTx` is unchanged. 2. **Interned keys.** `pathHopKeys map[string]string` keeps one shared copy of each resolved key. The record then holds a 16-byte string header per entry, and not the string each observation's resolve allocated: a fresh `json.Unmarshal` string per persisted observation on `Load`, and a `strings.ToLower` result on the live paths. The `byPathHop` key is the same shared copy. 3. **Eviction and rebuild.** - `evictStaleInternal` drops the record of evicted transmissions, plus any interned key whose bucket it deletes. - `retainResolvedPathHops` keeps the records of live transmissions only and drops interned keys whose bucket was not carried over. When a rebuild starts from an empty index, it clears both. 4. **Defence in depth.** The single (`GetRepeaterUsefulnessScore`), batch (`GetRepeaterNodeStatsBatch`) and bulk (`computeRepeaterUsefulnessScoreMap`) scores count **distinct** non-advert transmissions per bucket, via `countDistinctNonAdvert`. - IDs are collected in a reused slice. - A bucket in ascending ID order is already distinct; only out-of-order buckets are sorted. - The bulk pass does this for full-pubkey keys only. Raw-hop buckets get one entry per transmission from `addTxToPathHopIndex`. 5. **Cache side effect.** A repeated observation through known relays no longer mutates `byPathHop`, so it no longer drops the batch relay-stats cache, as the cache contract from `1164` intends. An observation through a new relay still drops it. The other `byPathHop` consumers already de-duplicate by `tx.ID` or read only raw prefix keys: `GetNodeHopAnalytics`, `computeMultiByteCapability`, `handleNodePaths`, `computeRepeaterRelayInfoMap` and the relay info in `GetRepeaterNodeStatsBatch`. Two tests pin that they ignore duplicate entries. ## Exact keys vs. a 64-bit fingerprint The first version of this fix stored a 64-bit FNV-1a hash per key. As requested, I measured both, plus exact keys without interning, on the real `addResolvedPubkeysToPathHopIndex`. **Keys per transmission.** In the e2e fixture, the union of resolved relay keys over all observations of one transmission has a mean of 5.4 and a maximum of 17. The protocol limit is 64 path bytes (`MAX_PATH_SIZE`), i.e. 64 / hash size hops. **Memory** retained by the record, per transmission. `BenchmarkPathHopRecordMemory_2108`: 100K transmissions, keys from 4,000 relays, each transmission heard twice with freshly allocated keys. The record map and the interned keys are included. | keys per tx | exact, interned (this PR) | 64-bit fingerprint | exact, not interned | |---|---|---|---| | 2 | 88 B | 68 B | 207 B | | 5 | 136 B | 101 B | 447 B | | 17 | 344 B | 197 B | 1,423 B | - At realistic key counts, exact keys cost 20–35 B per transmission more than the fingerprint. That is about 3.5 MB per 100K transmissions, and small next to what the store already charges per transmission (`storeTxBaseBytes` alone is 384 B). - Without interning, exact keys would cost 3–4× more, and most of that would be held during every `Load`. Interning is what makes exact keys affordable. **Time.** I ran the variants interleaved, 3 rounds each, median ns/op on an Apple M4. The record step is not measurable inside the full call. The isolated record step was faster with exact keys in a separate micro-benchmark, because comparing a handful of 64-char strings is cheaper than hashing each one. | benchmark | exact, interned | fingerprint | exact, not interned | |---|---|---|---| | late observation, 20K txs | 312 | 358 | 331 | | late observation, 100K txs | 760 | 836 | 758 | | `IngestNewObservations` (SQL + resolve + index) | 531 µs | 497 µs | 527 µs | **Collision probability of the fingerprint.** For k distinct keys in one transmission it is about k(k−1)/2 / 2^64: | k | per transmission | per 10^9 transmissions | |---|---|---| | 2 | 5.4e-20 | 5.4e-11 | | 5 | 5.4e-19 | 5.4e-10 | | 17 | 7.4e-18 | 7.4e-9 | | 64 | 1.1e-16 | 1.1e-7 | For random keys this is negligible. FNV-1a is unkeyed, though, so two relay keys that collide could be found deliberately (a birthday search over about 2^32 key pairs). A collision would leave a transmission out of one relay's bucket. **Decision.** Exact keys are cheap enough once interned: the same speed within noise, and a few tens of bytes per transmission. They remove the question of collision-driven omissions entirely, so this PR uses exact keys and drops the fingerprint. The two new maps (`pathHopResolved`, `pathHopKeys`) are not added to `trackedBytes`, so the `maxMemoryMB` trigger undercounts by roughly the per-transmission figures above. Both are bounded by live state (eviction and rebuild prune them), so this is a steady proportional undercount rather than a leak. ## Tests **New: `cmd/server/pathhop_dedupe_2108_test.go`.** Behaviour tests that use only existing API. Each one runs against the real SQLite schema through `Load`, `IngestNewFromDB` and `IngestNewObservations` where noted. | test | covers | on `master` | |---|---|---| | `TestPathHopIndexOncePerTx_LateObservations_2108` | 1 + 10 late observations through the same relays: one entry per relay | **fails** (11 entries) | | `TestPathHopIndexOncePerTx_LiveIngestBatch_2108` | 11 observations in one `IngestNewFromDB` batch | **fails** (11) | | `TestPathHopIndexOncePerTx_AfterLoad_2108` | `Load` + rebuild, then one live observation | **fails** (2) | | `TestPathHopIndexAddsNewRelayFromLaterObservation_2108` | a later observation through a **new** relay adds it exactly once, and its share becomes correct | **fails** (2) | | `TestTrafficShareStableAcrossLateObservations_2108` | single, batch and bulk scores agree, equal the definition, stay put over rounds of late observations, and equal a fresh `Load` of the same data | **fails** (all four relays at 1.0, want 0.4–0.5) | | `TestPathHopIndexSizeBoundedByTransmissions_2108` | the index size does not grow with observations | **fails** | | `TestRelayStatsCacheAcrossRepeatedObservations_2108` | a repeated observation keeps the relay-stats cache, and the kept cache equals a fresh compute; a new relay drops it, and the next read sees the relay | **fails** | | `TestAddResolvedPubkeysToPathHopIndex_PerRelayIdempotent_2108` | the helper's return value and cache invalidation per (tx, relay), in any key order | **fails** | | `TestTrafficShareCountsDistinctTransmissions_2108` | all three scores count distinct transmissions in a duplicated bucket | **fails** | | `TestPathHopConsumersIgnoreDuplicateEntries_2108` | pin of the consumer audit | passes (by design) | | `TestMultiByteCapabilityIgnoresDuplicateEntries_2108` | pin of the consumer audit | passes (by design) | **New: `cmd/server/pathhop_record_2108_test.go`.** These tests use the new symbols, so they do not compile against `master`. - `TestCountDistinctNonAdvert_2108`: ascending, descending, interleaved duplicates, adverts, nils, untyped. - `TestPathHopResolvedRecordBoundedAndEvicted_2108`: the record stays at 2 keys after 11 observations. Eviction removes the record and every entry. A survivor's next observation adds nothing. Evicting everything leaves no record, no interned key and no bucket. - `TestPathHopResolvedRecordAcrossRebuild_2108`: a rebuild keeps the records of live transmissions, so the next observation adds nothing. It drops the record of a removed transmission and the interned key only that transmission used. - `TestPathHopResolvedRecordClearedWithEmptyIndex_2108`: a rebuild from an empty index clears the record and the interned keys, and the next observation puts the transmission back. - `TestPathHopResolvedRecordInternsKeys_2108`: record entries and the `byPathHop` key share one copy, even though every observation passes freshly allocated keys. **Changed: `pathhop_eviction_1908_test.go`.** It built its duplicate entries by repeating `indexResolvedPathHops`, which no longer duplicates. It now seeds the duplicates directly, so the `1908` sweep is still tested against buckets that hold one transmission several times. The expected buckets are unchanged. **Changed: `db_test.go`.** `setupTestDB` takes `testing.TB`, so the SQL benchmark can use it. **Mutants.** I applied each mutant on its own to this branch. All 14 are killed: | mutant | killed by | |---|---| | record never consulted | the OncePerTx, stable-share, size, cache and record tests | | dedupe per transmission instead of per relay | `AddsNewRelayFromLaterObservation`, `RelayStatsCacheAcrossRepeatedObservations`, `PerRelayIdempotent` | | eviction keeps the record | `RecordBoundedAndEvicted` | | rebuild keeps records of removed transmissions | `RecordAcrossRebuild` | | rebuild from an empty index keeps the record | `RecordClearedWithEmptyIndex` | | cache dropped on every call | `RelayStatsCacheAcrossRepeatedObservations`, `PerRelayIdempotent`, existing `NoMutation_PreservesCache` | | cache kept although `byPathHop` changed | `RelayStatsCacheAcrossRepeatedObservations`, `PerRelayIdempotent`, existing `InvalidatesRelayStatsCache` | | single score counts entries | `TrafficShareCountsDistinctTransmissions` | | batch score counts entries | `TrafficShareCountsDistinctTransmissions` | | bulk score counts entries | `TrafficShareCountsDistinctTransmissions` | | distinct count trusts any bucket order | `TrafficShareCountsDistinctTransmissions`, `CountDistinctNonAdvert` | | keys not interned | `RecordInternsKeys` | | eviction keeps interned keys | `RecordBoundedAndEvicted` | | rebuild keeps interned keys of dropped buckets | `RecordAcrossRebuild` | **Commands run:** - `gofmt -l` on all tracked Go files: clean. - `go vet ./...` in all 14 modules: clean. - `cd cmd/server && go test -race ./...`: pass. - `cd cmd/ingestor && go test ./...`: pass. - `sh test-all.sh`: 184 of 186 suites pass locally. The other two, `test-issue-1956-release-routing.js` and `test-preflight-xss-gate.js`, shell out to scripts that need bash ≥ 4 (`mapfile`). They fail under macOS's bash 3.2 regardless of this change. This PR touches no frontend or script files. ## Benchmark: `master` vs. this branch I ran `master`'s sources (`000d9ab`) and this branch interleaved, 5 rounds, with the same benchmark files. Medians on an Apple M4. | benchmark | `master` | this PR | change | |---|---|---|---| | late observation through known relays, 20K txs | 446 ns, 38 B/op | 441 ns, 0 B/op | within noise | | late observation through known relays, 100K txs | 710 ns, 69 B/op | 755 ns, 0 B/op | within noise (runs overlap) | | … index size afterwards, entries/tx (20K / 100K) | 24.99 / 8.99, still growing | 4.99 / 4.99 | bounded | | `IngestNewObservations`, one observation for each of 20 txs | 532 µs | 513 µs | within noise | | bulk score pass, clean index, 20K, ingest order | 246 µs | 316 µs | +28 % | | bulk score pass, clean index, 100K, ingest order | 3.03 ms | 4.30 ms | +42 % | | bulk score pass, clean index, 20K, reversed buckets | 257 µs | 467 µs | +82 % | | bulk score pass, clean index, 100K, reversed buckets | 3.38 ms | 4.79 ms | +42 % | | bulk score pass after 11 observations per tx, 20K | 663 µs | 284 µs | −57 % | | bulk score pass after 11 observations per tx, 100K | 4.72 ms | 3.19 ms | −32 % | - On an index that is clean on both builds, the distinct count makes the bulk pass slower. That pass runs on the cache-miss path, which the background recomputer refreshes every 5 minutes by default. - On the index a live server actually holds without this fix, `master` walks every accumulated duplicate, so the real-world pass is faster after the fix. The gap grows with uptime. - The late-observation step no longer allocates. On `master` its index grows with every observation. Benchmarks: `BenchmarkLateObservationIndex_2108`, `BenchmarkIngestNewObservations_2108`, `BenchmarkTrafficShareScoreMap_2108` and `BenchmarkPathHopRecordMemory_2108`. ## Production motivation We have run this fix on two production instances. Before the fix, the summed `traffic_share_score` grew past 180 and many relays sat at the 1.0 clamp; with the fix no node reaches `≥ 0.999`. A controlled 12 h A/B run makes the drift explicit. Two servers read one identical database — one on this `master` base, one with the fix — alongside a reference server that freshly loads the same database (a fresh load is correct, because the rebuild dedupes). The unfixed server drifted to 9 relays at the 1.0 clamp and up to 0.95 absolute error per node against the reference; the fixed server stayed within 0.05 of the reference for every node, with no relay at the clamp. A smaller residual rise remains and is a separate cause (startup-vs-live hop resolution); it is deliberately left for a follow-up so its effect stays measurable. ## Out of scope That remaining slow rise comes from a separate cause: live ingest resolves some hops that the startup load does not. This PR deliberately leaves that drift alone, so its effect stays measurable. The fix will follow in a separate PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
6.4 KiB
Go
215 lines
6.4 KiB
Go
package main
|
||
|
||
import (
|
||
"slices"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// GetRepeaterUsefulnessScore returns a 0..1 score representing what
|
||
// fraction of non-advert traffic in the store passes through this
|
||
// repeater as a relay hop. Issue #672 (Traffic axis only — bridge,
|
||
// coverage, and redundancy axes are deferred to follow-up work).
|
||
//
|
||
// Numerator: the number of distinct non-advert transmissions indexed under
|
||
// pubkey in byPathHop (countDistinctNonAdvert, #2108). Denominator: total
|
||
// non-advert transmissions in the store (sum of byPayloadType for all keys
|
||
// != payloadTypeAdvert).
|
||
//
|
||
// Returns 0 when there is no non-advert traffic, the pubkey is empty,
|
||
// or the repeater never appears as a relay hop. Scores are clamped to
|
||
// [0,1] for defensive bounds.
|
||
//
|
||
// Cost: O(N) over byPayloadType keys (typically <20) plus the per-hop
|
||
// slice for pubkey. Cheap relative to the per-request enrichment loop
|
||
// in handleNodes; if it ever shows up in profiles, denominator can be
|
||
// memoized off store invalidation.
|
||
func (s *PacketStore) GetRepeaterUsefulnessScore(pubkey string) float64 {
|
||
if pubkey == "" {
|
||
return 0
|
||
}
|
||
key := strings.ToLower(pubkey)
|
||
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
// Denominator: total non-advert packets.
|
||
totalNonAdvert := 0
|
||
for pt, list := range s.byPayloadType {
|
||
if pt == payloadTypeAdvert {
|
||
continue
|
||
}
|
||
totalNonAdvert += len(list)
|
||
}
|
||
if totalNonAdvert == 0 {
|
||
return 0
|
||
}
|
||
|
||
// Numerator: the distinct non-advert transmissions this repeater relayed.
|
||
relayed, _ := countDistinctNonAdvert(s.byPathHop[key], nil)
|
||
|
||
score := float64(relayed) / float64(totalNonAdvert)
|
||
if score < 0 {
|
||
return 0
|
||
}
|
||
if score > 1 {
|
||
return 1
|
||
}
|
||
return score
|
||
}
|
||
|
||
// countDistinctNonAdvert returns the number of distinct non-advert
|
||
// transmissions in list, a byPathHop bucket. The index holds a transmission
|
||
// once per key (#2108); counting distinct transmissions keeps traffic share
|
||
// a fraction of transmissions even if a bucket ever held duplicates, since
|
||
// the denominator counts each transmission once. ids is caller-owned
|
||
// scratch (may be nil), returned for reuse.
|
||
//
|
||
// Cost: O(len(list)) with no allocation once ids has grown. Buckets are
|
||
// appended in ingest order, so their IDs are usually strictly increasing,
|
||
// which already proves them distinct; only otherwise (background chunks
|
||
// load older transmissions after newer ones) are the IDs sorted,
|
||
// O(n log n) on a flat slice. A map per bucket was measured ~10x slower
|
||
// on the bulk pass (BenchmarkTrafficShareScoreMap_2108).
|
||
func countDistinctNonAdvert(list []*StoreTx, ids []int) (int, []int) {
|
||
ids = ids[:0]
|
||
ascending := true
|
||
for _, tx := range list {
|
||
if tx == nil || (tx.PayloadType != nil && *tx.PayloadType == payloadTypeAdvert) {
|
||
continue
|
||
}
|
||
if n := len(ids); n > 0 && tx.ID <= ids[n-1] {
|
||
ascending = false
|
||
}
|
||
ids = append(ids, tx.ID)
|
||
}
|
||
if ascending || len(ids) < 2 {
|
||
return len(ids), ids
|
||
}
|
||
slices.Sort(ids)
|
||
n := 1
|
||
for i := 1; i < len(ids); i++ {
|
||
if ids[i] != ids[i-1] {
|
||
n++
|
||
}
|
||
}
|
||
return n, ids
|
||
}
|
||
|
||
// countNonAdvert returns the number of non-advert entries in list.
|
||
func countNonAdvert(list []*StoreTx) int {
|
||
n := 0
|
||
for _, tx := range list {
|
||
if tx != nil && (tx.PayloadType == nil || *tx.PayloadType != payloadTypeAdvert) {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// RepeaterNodeStats bundles relay-activity and usefulness data for a single node.
|
||
type RepeaterNodeStats struct {
|
||
Info RepeaterRelayInfo
|
||
Score float64
|
||
}
|
||
|
||
// GetRepeaterNodeStatsBatch computes relay info and usefulness scores for all given
|
||
// pubkeys in a single read-lock pass, sharing the non-advert denominator across all
|
||
// nodes. All StoreTx fields are read under the lock and copied into relayEntry
|
||
// snapshots before the lock is released; no StoreTx pointers escape the lock.
|
||
// Replaces the per-node loop in handleNodes that called GetRepeaterRelayInfo +
|
||
// GetRepeaterUsefulnessScore N times (O(N × byPayloadType) → O(byPayloadType + N)).
|
||
func (s *PacketStore) GetRepeaterNodeStatsBatch(pubkeys []string, windowHours float64) map[string]RepeaterNodeStats {
|
||
result := make(map[string]RepeaterNodeStats, len(pubkeys))
|
||
if len(pubkeys) == 0 {
|
||
return result
|
||
}
|
||
|
||
type nodeSnap struct {
|
||
entries []relayEntry
|
||
relayed int // distinct non-advert txs in full-key list only (for usefulness score)
|
||
}
|
||
|
||
s.mu.RLock()
|
||
|
||
totalNonAdvert := 0
|
||
for pt, list := range s.byPayloadType {
|
||
if pt != payloadTypeAdvert {
|
||
totalNonAdvert += len(list)
|
||
}
|
||
}
|
||
|
||
snaps := make(map[string]nodeSnap, len(pubkeys))
|
||
var ids []int
|
||
for _, pk := range pubkeys {
|
||
key := strings.ToLower(pk)
|
||
entries := s.collectRelayEntriesLocked(key)
|
||
var relayed int
|
||
relayed, ids = countDistinctNonAdvert(s.byPathHop[key], ids)
|
||
snaps[pk] = nodeSnap{entries: entries, relayed: relayed}
|
||
}
|
||
|
||
s.mu.RUnlock()
|
||
|
||
for _, pk := range pubkeys {
|
||
snap := snaps[pk]
|
||
info := computeRelayInfoFromEntries(snap.entries, windowHours)
|
||
|
||
var score float64
|
||
if totalNonAdvert > 0 && snap.relayed > 0 {
|
||
score = float64(snap.relayed) / float64(totalNonAdvert)
|
||
if score > 1 {
|
||
score = 1
|
||
}
|
||
}
|
||
|
||
result[pk] = RepeaterNodeStats{Info: info, Score: score}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// GetRepeaterNodeStatsBatchCached wraps GetRepeaterNodeStatsBatch with a 5min
|
||
// TTL cache keyed on (pubkeys, windowHours). handleNodes calls this for every
|
||
// map/live/node request; without caching the full batch over ~1900 repeaters
|
||
// takes 20-30s on large datasets.
|
||
// 300s TTL: cold compute (~25s) runs at most once per 5min (~8% duty cycle)
|
||
// vs the previous 30s TTL (~82% duty cycle).
|
||
func (s *PacketStore) GetRepeaterNodeStatsBatchCached(pubkeys []string, windowHours float64) map[string]RepeaterNodeStats {
|
||
sig := pubkeySig(pubkeys)
|
||
|
||
s.relayStatsCacheMu.Lock()
|
||
if s.relayStatsCache != nil &&
|
||
s.relayStatsCacheSig == sig &&
|
||
s.relayStatsCacheWindow == windowHours &&
|
||
time.Since(s.relayStatsCacheAt) < 300*time.Second {
|
||
cached := s.relayStatsCache
|
||
s.relayStatsCacheMu.Unlock()
|
||
return cached
|
||
}
|
||
s.relayStatsCacheMu.Unlock()
|
||
|
||
result := s.GetRepeaterNodeStatsBatch(pubkeys, windowHours)
|
||
|
||
s.relayStatsCacheMu.Lock()
|
||
s.relayStatsCache = result
|
||
s.relayStatsCacheAt = time.Now()
|
||
s.relayStatsCacheWindow = windowHours
|
||
s.relayStatsCacheSig = sig
|
||
s.relayStatsCacheMu.Unlock()
|
||
|
||
return result
|
||
}
|
||
|
||
// pubkeySig returns a stable, order-independent string key for a pubkey set.
|
||
func pubkeySig(pubkeys []string) string {
|
||
if len(pubkeys) == 0 {
|
||
return ""
|
||
}
|
||
sorted := make([]string, len(pubkeys))
|
||
copy(sorted, pubkeys)
|
||
sort.Strings(sorted)
|
||
return strings.Join(sorted, ",")
|
||
}
|