mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 07:50:04 +00:00
## Summary
The subpath index keeps every contiguous run of 2-8 hops from every
stored transmission's path, in two maps:
- `spIndex`: key → count
- `spTxIndex`: key → transmissions containing that subpath, one entry
per occurrence
Both were always updated together (the only production callers were
`addTxToSubpathIndexFull` / `removeTxFromSubpathIndexFull`, which touch
both), so every count equalled `len(spTxIndex[key])`. The existing
`TestSubpathTxIndexPopulated` asserted exactly that. The index therefore
held its 1M+ keys twice.
This PR removes `spIndex`. The two analytics consumers read `len(txs)`,
and the add/remove helpers take only the transaction map. No API
response changes.
No existing upstream issue covers this; it came out of profiling a
production instance where garbage collection was 87% of CPU (30 s CPU
profile: `gcDrain` 39.9 s of 45.9 s sampled) with an 805 MB live heap
against a 1 GiB `GOMEMLIMIT`. That heap profile put 186 MB in this
index; on that instance's 7-day window there are 1.2M distinct subpath
keys (79% seen once) and 3.07M entries.
## Performance
### Go benchmark
`BenchmarkBuildSubpathIndex` builds the index for a synthetic,
production-shaped window: 41k transmissions with path lengths drawn from
the production hop-count histogram, and hops from random walks over a
400-node graph with 3-byte prefixes. That gives 1.48M keys, 85% seen
once (production: 1.2M, 79%). No real data is used. 10 runs each:
```
cpu: AMD Ryzen 5 3600XT 6-Core Processor
│ master │ this PR │
│ sec/op │ sec/op vs base │
BuildSubpathIndex-12 2.157 ± 1% 1.610 ± 5% -25.33% (p=0.000 n=10)
│ retained-B │ retained-B vs base │
BuildSubpathIndex-12 238.5Mi ± 0% 187.7Mi ± 0% -21.31% (p=0.000 n=10)
│ B/op │ B/op vs base │
BuildSubpathIndex-12 572.0Mi ± 0% 465.6Mi ± 0% -18.61% (p=0.000 n=10)
```
(`retained-B` is the live heap the index keeps after a forced GC: 250.1
MB → 196.8 MB.)
### Staging heap
Live heap after a forced GC (`/debug/pprof/heap?gc=1`), on a staging
host running a copy of a production database and its live MQTT feed,
attributed to the subpath index builder:
| | master | this PR |
|---|---|---|
| subpath index (`buildSubpathIndex`, cum) | 172.4 MB | 116.1 MB |
| packets in memory at the time | 27,521 | 24,086 |
| per packet | 6.27 KB | 4.82 KB (**-23%**) |
The two snapshots were taken minutes apart with different numbers of
packets loaded, hence the per-packet figure; it agrees with the
benchmark's -21%.
The same A/B replay used for the other PRs (30 minutes of real
production API traffic) showed no latency change (p50 10.3 ms vs 10.7
ms, p95 59.5 ms vs 52.9 ms, 0 errors). GC count and CPU were lower in
this run (44 → 28 collections, 13.0% → 9.6% CPU), but runs on this
shared host vary by that much on their own, so no claim is made from
them.
## Tests
- Red commit `ded359ff`: the fixture, `TestSubpathFixtureShape` (keeps
the fixture production-like: 60-95% singleton keys),
`BenchmarkBuildSubpathIndex`, and `TestSubpathIndexRetainedHeap`, which
allows at most 225 MB for the 41k fixture. It fails on `master` with
250.1 MB.
- Green commit `e399c561`: the change. The heap test passes at about 197
MB.
- `make test`: 16 of 16 modules pass; `go test -race` over the subpath,
eviction, path-hop and resolved-index tests passes.
Test changes in the green commit are mechanical, as required for a
structural change:
- struct literals stop setting `spIndex`; where a literal did not set
`spTxIndex` it now does, because appending to a nil map panics where the
old code skipped the transaction map when passed `nil`;
- count assertions read `len(spTxIndex[key])`;
- the old "spIndex and spTxIndex agree" check becomes "no key is kept
with an empty list", which is what keeps `len()` a correct count.
## Review notes
- Removal still takes out one occurrence per subpath occurrence and
deletes a key whose list becomes empty, so `len()` stays equal to the
old count through eviction.
- The store's memory estimate (`perSubpathEntryBytes`) is unchanged
here; accounting accuracy is a separate item.
- No new `map[string]interface{}`.
## SonarLint
SonarQube Community rules (the SonarLint engine) were run on the files
this PR touches, and only issues on lines it adds are listed.
- **Fixed** in `8af9582f`: S3776 (cognitive complexity 16 > 15) in the
test fixture. Path generation is split into graph and hop-count helpers;
the random draws stay in the same order, so the fixture is unchanged.
## Checked against the repo rules
- Rebased onto `master` (`673442cc`). Each red commit was re-run and
still fails on its assertions; each green commit passes (TDD.md).
- `make test`: all 16 modules pass. Frontend suites: all pass except
`test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME`
and fails the same way on `master` locally (#2160 addresses it).
- No private data: test fixtures and examples use the codes the existing
tests use (SJC/SFO/LAX, `#be`-style scopes).
- No new `map[string]interface{}`, no hard-coded colours. `git add` was
explicit, and there is one logical change per commit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
8.9 KiB
Go
218 lines
8.9 KiB
Go
// Issue #1008: background-deferred subpath + pathHop index builds.
|
||
//
|
||
// Pattern mirrors the distance index (#1011) — but where distance is
|
||
// fully lazy (built on first request), these two indexes are kicked off
|
||
// eagerly by Load() in a background goroutine so HTTP becomes ready
|
||
// immediately while the indexes finish populating.
|
||
//
|
||
// Concurrency model:
|
||
//
|
||
// - subpathReady / pathHopReady are atomic.Bool flags written exactly
|
||
// once by the background builder (false → true) and never reset
|
||
// thereafter. Handlers read them via SubpathIndexReady() /
|
||
// PathHopIndexReady() before touching s.spTxIndex /
|
||
// s.byPathHop. While a flag is false, the handler responds 503 +
|
||
// Retry-After: 5.
|
||
//
|
||
// - The builder itself acquires s.mu.Lock() and calls the existing
|
||
// buildSubpathIndex() / buildPathHopIndex() methods. Those methods
|
||
// replace s.spTxIndex / s.byPathHop with freshly-
|
||
// allocated maps under the write lock. Visibility of the populated
|
||
// maps to handlers that see Ready()==true is guaranteed by Go's
|
||
// sync/atomic acquire-release semantics (formalized in Go 1.19):
|
||
// the atomic.Store(true) happens-after the s.mu.Unlock() that
|
||
// completes the build, and the handler's atomic.Load()==true
|
||
// synchronizes-with that store. The handler's subsequent s.mu.RLock
|
||
// is not what establishes visibility — it only serializes against
|
||
// concurrent ingest writers — so dropping the RLock would still be
|
||
// safe for the build's "populated map" snapshot (we keep it for
|
||
// ingest serialization).
|
||
//
|
||
// - Ingest-side incremental updates in StoreNewTransmissions /
|
||
// pruning / hash-collision paths continue to write
|
||
// s.spTxIndex / s.byPathHop directly under s.mu.Lock(). Because
|
||
// the builder also runs under s.mu.Lock() and the builder
|
||
// overwrites whatever is there, the brief window between Load()
|
||
// returning and the goroutine acquiring s.mu means any
|
||
// concurrent ingest writes will be overwritten by the build —
|
||
// this matches the prior behavior where ingest could not start
|
||
// until Load() released s.mu, so in practice ingest does not
|
||
// run during the build window. Documenting this rather than
|
||
// adding a separate gate: the existing main.go boot sequence
|
||
// does not start ingest goroutines until after store.Load()
|
||
// and graph init complete.
|
||
//
|
||
// Handler scope of the ready gate (issue #1008 review M2):
|
||
//
|
||
// - HARD-GATED with 503 + Retry-After: 5 — analytics endpoints whose
|
||
// entire response is the index aggregate. Empty data would be
|
||
// visibly broken (charts, top-N tables). See routes.go:
|
||
// /api/analytics/subpaths, /api/analytics/subpaths-bulk,
|
||
// /api/analytics/subpath-detail, /api/nodes/{pubkey}/paths.
|
||
//
|
||
// - BEST-EFFORT (not gated) — endpoints where the index drives
|
||
// enrichment fields that callers already treat as optional. During
|
||
// the not-ready window these report zero counts / nil scores
|
||
// rather than 503-ing the whole list. Acceptable because:
|
||
//
|
||
// - /api/nodes and /api/nodes/{pubkey} have many other fields
|
||
// (last-seen, position, advert metadata) that callers depend
|
||
// on at startup. 503-ing the SPA bootstrap to wait for an
|
||
// index that exclusively affects "relay activity" badges
|
||
// would be a worse UX than a 30–60s window of "—" badges.
|
||
//
|
||
// - GetRepeaterRelayInfoMap / GetRepeaterUsefulnessScoreMap /
|
||
// GetBridgeScore / repeater_liveness / repeater_usefulness
|
||
// all walk s.byPathHop. During the build window they return
|
||
// empty maps or zero scores; the steady-state recomputer
|
||
// (#1262) refreshes them every 5min once indexes flip ready
|
||
// (prewarm guarded by WaitIndexesReady — see review M1).
|
||
//
|
||
// This is documented rather than gated so operators do not see
|
||
// /api/nodes 503 during routine restarts on Cascadia-scale data.
|
||
package main
|
||
|
||
import (
|
||
"log"
|
||
"net/http"
|
||
"time"
|
||
)
|
||
|
||
// writeIndexLoading503 emits the standard 503 response used by handlers
|
||
// that depend on a not-yet-built index (#1008). Body shape matches the
|
||
// triage spec: {"error":"index loading","retryAfter":5}. The Retry-After
|
||
// header is also set so well-behaved clients back off automatically.
|
||
func writeIndexLoading503(w http.ResponseWriter) {
|
||
w.Header().Set("Retry-After", "5")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
_, _ = w.Write([]byte(`{"error":"index loading","retryAfter":5}`))
|
||
}
|
||
|
||
// SubpathIndexReady reports whether the subpath index build kicked off
|
||
// by Load() has completed (#1008). Until this returns true, callers
|
||
// must NOT read s.spTxIndex.
|
||
func (s *PacketStore) SubpathIndexReady() bool {
|
||
return s.subpathReady.Load()
|
||
}
|
||
|
||
// PathHopIndexReady reports whether the path-hop index build kicked
|
||
// off by Load() has completed (#1008). Until this returns true,
|
||
// callers must NOT read s.byPathHop.
|
||
func (s *PacketStore) PathHopIndexReady() bool {
|
||
return s.pathHopReady.Load()
|
||
}
|
||
|
||
// indexReadyCh returns the channel that is closed when BOTH indexes
|
||
// have flipped ready. Lazily created on first access. Safe to call
|
||
// concurrently. Used by WaitIndexesReady and any future waiters that
|
||
// want event-driven semantics instead of polling.
|
||
func (s *PacketStore) indexReadyCh() <-chan struct{} {
|
||
s.indexReadyChMu.Lock()
|
||
defer s.indexReadyChMu.Unlock()
|
||
if s.indexReadyChan == nil {
|
||
s.indexReadyChan = make(chan struct{})
|
||
// If both are already ready (e.g. background chunk loader
|
||
// flipped them synchronously before any waiter showed up),
|
||
// close immediately so the channel is usable as a one-shot.
|
||
if s.subpathReady.Load() && s.pathHopReady.Load() {
|
||
close(s.indexReadyChan)
|
||
}
|
||
}
|
||
return s.indexReadyChan
|
||
}
|
||
|
||
// maybeCloseIndexReadyCh closes the ready channel iff both flags are
|
||
// set. Idempotent (a sync.Once on the channel) and safe to call from
|
||
// either builder goroutine on the green-path transitions, as well as
|
||
// from markIndexesReadySync.
|
||
func (s *PacketStore) maybeCloseIndexReadyCh() {
|
||
if !(s.subpathReady.Load() && s.pathHopReady.Load()) {
|
||
return
|
||
}
|
||
s.indexReadyChMu.Lock()
|
||
defer s.indexReadyChMu.Unlock()
|
||
if s.indexReadyChan == nil {
|
||
// Lazily allocate AND close it in one step so any future
|
||
// indexReadyCh() caller gets a pre-closed channel.
|
||
s.indexReadyChan = make(chan struct{})
|
||
close(s.indexReadyChan)
|
||
return
|
||
}
|
||
select {
|
||
case <-s.indexReadyChan:
|
||
// Already closed.
|
||
default:
|
||
close(s.indexReadyChan)
|
||
}
|
||
}
|
||
|
||
// startBackgroundIndexBuilds is called from Load() after s.loaded=true
|
||
// to populate the subpath + path-hop indexes off the critical path
|
||
// (#1008). It returns immediately; the work runs in two background
|
||
// goroutines (one per index — see review m7) that each acquire
|
||
// s.mu.Lock() independently, install their map, then set the
|
||
// corresponding atomic ready flag.
|
||
//
|
||
// At Cascadia scale (~5M observations) this previously blocked HTTP
|
||
// readiness ~60s inside Load() under s.mu. Running the two builds in
|
||
// parallel halves the pathHop-not-ready window since the two builders
|
||
// are independent of each other.
|
||
func (s *PacketStore) startBackgroundIndexBuilds() {
|
||
go func() {
|
||
t0 := time.Now()
|
||
s.mu.Lock()
|
||
s.buildSubpathIndex()
|
||
s.mu.Unlock()
|
||
// Atomic.Store happens-after s.mu.Unlock; handlers that
|
||
// observe Ready()==true synchronize-with this store.
|
||
s.subpathReady.Store(true)
|
||
s.maybeCloseIndexReadyCh()
|
||
log.Printf("[startup] index build complete: subpath (%s)",
|
||
time.Since(t0).Round(time.Millisecond))
|
||
}()
|
||
go func() {
|
||
t1 := time.Now()
|
||
s.mu.Lock()
|
||
s.buildPathHopIndex()
|
||
s.mu.Unlock()
|
||
s.pathHopReady.Store(true)
|
||
s.maybeCloseIndexReadyCh()
|
||
log.Printf("[startup] index build complete: pathHop (%s)",
|
||
time.Since(t1).Round(time.Millisecond))
|
||
}()
|
||
}
|
||
|
||
// markIndexesReadySync is the synchronous-build entry point used by
|
||
// the background chunk loader in store.go (and by tests). The chunk
|
||
// loader rebuilds both indexes under s.mu.Lock(); after the Unlock it
|
||
// calls this to flip the ready flags and close the broadcast channel
|
||
// in one shot, preserving symmetry with the goroutine path above.
|
||
func (s *PacketStore) markIndexesReadySync() {
|
||
s.subpathReady.Store(true)
|
||
s.pathHopReady.Store(true)
|
||
s.maybeCloseIndexReadyCh()
|
||
}
|
||
|
||
// WaitIndexesReady blocks until both background indexes built by
|
||
// startBackgroundIndexBuilds() report ready, or the deadline expires.
|
||
// Returns true if both flipped in time. Intended for tests that read
|
||
// s.spTxIndex / s.byPathHop directly after Load(); production
|
||
// code paths gate via SubpathIndexReady() / PathHopIndexReady() and
|
||
// respond 503 + Retry-After to clients instead of blocking.
|
||
//
|
||
// Uses the indexReadyCh broadcast channel rather than polling
|
||
// (see review m6) so wake-up is immediate with no poll-interval jitter.
|
||
func (s *PacketStore) WaitIndexesReady(timeout time.Duration) bool {
|
||
if s.SubpathIndexReady() && s.PathHopIndexReady() {
|
||
return true
|
||
}
|
||
ch := s.indexReadyCh()
|
||
select {
|
||
case <-ch:
|
||
return true
|
||
case <-time.After(timeout):
|
||
return s.SubpathIndexReady() && s.PathHopIndexReady()
|
||
}
|
||
}
|