mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 10:57:17 +00:00
Refs #2146 (first item: "Stop issuing SQL while holding `store.mu`").
## Situation
Several store functions ran SQLite queries while holding
`PacketStore.mu`. The server's read pool has 4 connections
(`db.go:153`), so a store-lock holder could wait for a pool connection.
`mu` is a `sync.RWMutex`: once the ingest poller asks for the write
lock, every new reader queues behind it, including `/api/healthz`. A
test that scans the package (below) finds 16 such call sites on `master`
at 9dbc2875.
## My changes in this PR
One commit per part:
1. **Caches serve stale while one refresh runs** (`4d9a818e`).
`getCachedNodesAndPM`, `resolveRegionObservers` and `resolveAreaNodes`
ran their SQL inline on a 30 s TTL miss, also from ingest under the
write lock. Past the TTL they now return the cached value and start one
background refresh per key (`cache_refresh.go`).
- A cache that was never built, or was invalidated with
`InvalidateNodeCache`, still loads inline. A generation counter stops a
refresh that started before an invalidation from marking the cache
fresh.
- The region cache no longer holds `regionObsMu` across its query. Its
refresh re-queries only the regions looked up since the previous
refresh. That keeps it bounded, because the region comes from the query
string.
2. **resolved_path after the lock** (`750dd951`). `QueryPackets`,
`QueryMultiNodePackets`, `GetTransmissionByID`, `GetPacketByHash`,
`GetPacketByID`, `GetObservationsForHash` and `GetNodeHealth` record the
lookups under the lock (`rpJob`, with a snapshot of observation IDs and
path JSON) and run them after releasing it. The number of queries per
row is unchanged.
3. **Single queries before the lock** (`bf85e63e`): the `node=` filter
(`resolveQueryNode`), multi-node pubkey resolution, the can_relay sets
in node health, the observers read for hash-size home regions
(`observerIATAs`), and the stats read in topology.
4. **Bulk health** (`93b7e8ec`): the nodes-table read runs before the
lock. The region filter, which needs the in-memory indexes, is applied
to those rows under the lock.
5. **`/paths` observations race** (`c30e9b43`, `fbf0d7ef`).
`handleNodePaths` read each candidate's canonical resolved_path after
releasing the lock, and that lookup walked `tx.Observations`, which
ingest appends to under the write lock. `-race` reports it in the new
test. The handler now copies the survivors' observation IDs and path
JSON in a short read lock, and parses after releasing it.
`fetchResolvedPathForTxBest` had no caller left and is replaced by
`bestResolvedPath`.
6. **Topology node count** (`4094d215`). Topology used `db.GetStats()`
for one field, the 7-day active node count. `GetStats` also runs
`COUNT(*)` over transmissions and observations. That count is now
`CountActiveNodes`, one query on `nodes`, and `GetStats` uses it too.
## Tests
- `TestNoSQLUnderStoreLock` parses the package and fails on any call
inside a `store.mu` section that reaches SQL, directly or through other
package functions. It allows the three caches above as callees (they
only query when cold) and the startup loaders as holders.
`TestNoSQLUnderStoreLockDetects` runs the scan on a small synthetic
source, so a scan that stops matching cannot pass silently. On master:
16 call sites. On this branch: 0.
- `TestSlowResolvedPathSQLDoesNotStallReaders` reproduces the convoy. A
test hook makes the resolved_path query take 300 ms; `/api/packets` sits
in it, the ingest writer queues, then a reader takes `RLock`. With the
lookup back under the lock (as on master) the reader waited 590 ms. On
this branch it did not wait measurably.
- `TestResolvedPathSQLRunsWithoutStoreLock` checks all seven functions
from part 2. `cache_swr_test.go` covers stale serving, one refresh per
key, inline rebuild after invalidation, and a cold region not blocking a
cached one. `TestNodePathsDoesNotReadObservationsUnlocked` fails under
`-race` with the old read. `TestCountActiveNodes` pins the 7-day count
against `GetStats`.
## Verification
- `cmd/server`: `go test ./...` green, and `go test -race` on the full
package green with 0 races, both on the final commit.
- E2E against a local server on the CI fixture: `test-e2e-playwright.js`
148/151 (3 skipped by the suite itself), plus
`test-issue-1486-collapse-reopens-detail-e2e.js`,
`test-issue-1692-packets-init-parallel-e2e.js` and
`test-channel-issue-1087-e2e.js`, all passing. The other E2E suites were
not run locally.
- Browser: packets, node detail, analytics hash sizes and topology
render. In the API responses, resolved_path is present on 44 of 50
packets, on packet detail, and on health `recentPackets`. No console
errors.
### Staging measurement
A staging instance with 129k transmissions in memory. Load ran on the
same host: 4 threads looping `/paths`,
`/api/packets?limit=200&expand=observations`, node health,
`hash-sizes?region=`, `topology?region=` and `/api/packets?node=`, while
one thread sampled `/api/healthz` every 200 ms. Each build had one
cycle: 360 s during the ingestor's startup `ANALYZE` (heavy disk reads
for 7 to 9 minutes after every restart), then 2 runs of 120 s after it
finished. Values in ms.
| | without this PR | with this PR |
|---|---|---|
| **During ANALYZE** | | |
| `healthz` p95 / p99 / max | 156 / 1105 / 5681 | 42 / 905 / 4280 |
| `topology?region` p99 / max | 2537 / 3061 | 1942 / 4159 |
| `hash-sizes?region` max | 5809 | 2866 |
| node health max | 5719 | 1110 |
| `packets?expand` max | 3912 | 2570 |
| calls over 5 s, excluding `/paths` | 2 | 0 |
| **After ANALYZE** (two runs) | | |
| `healthz` p95 / max | 444-667 / 2139-4356 | 189-335 / 1377-3238 |
| `topology?region` max | 3401-5363 | 1440-1927 |
| `/paths` p50 | 1466-4736 | 1125-2648 |
| calls over 5 s | 3 | 0 |
The two builds were measured about 50 minutes apart, so the background
traffic was not identical.
An earlier version of this branch, without `fbf0d7ef`, raised `healthz`
p95 to 2.7-2.9 s in the same setup, because it parsed every `/paths`
candidate's path JSON under the read lock. Without `4094d215`,
`topology?region` reached 8-17 s during `ANALYZE` in three cycles. The
table is for the final branch.
## Not in this PR
- The query count of `/paths` (1 to 3 per candidate,
`resolved_index.go:160`). It still dominates that endpoint, at p95 8 to
15 s on staging. It is its own item in #2146.
- Batching the resolved_path queries.
- The other per-endpoint items in #2146 (reach, channels, perf,
observers, config/regions, battery, neighbors).
- A cache's first lookup still queries inline, under the lock where the
caller holds it. That happens once per key per process, by design, and
the invariant test allows it explicitly.
- `cmd/ingestor` is unchanged. No measurement on an instance the size of
the ones in #2146.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
7.1 KiB
Go
241 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// #2146: the node, region-observer and area-node caches are read by callers
|
|
// that hold s.mu. A cache past its TTL must be served as-is while a single
|
|
// background refresh runs the SQL, so no caller holding s.mu waits on the
|
|
// 4-connection read pool.
|
|
|
|
// blockingLoadHook returns a cacheLoadHook that counts calls per kind and
|
|
// blocks every call for kind until release is closed.
|
|
func blockingLoadHook(kind string) (hook func(string), calls *int64, entered chan struct{}, release chan struct{}) {
|
|
calls = new(int64)
|
|
entered = make(chan struct{}, 64)
|
|
release = make(chan struct{})
|
|
hook = func(k string) {
|
|
if k != kind {
|
|
return
|
|
}
|
|
atomic.AddInt64(calls, 1)
|
|
entered <- struct{}{}
|
|
<-release
|
|
}
|
|
return hook, calls, entered, release
|
|
}
|
|
|
|
// returnsWithin runs fn and fails the test if it does not return within d.
|
|
func returnsWithin(t *testing.T, d time.Duration, what string, fn func()) {
|
|
t.Helper()
|
|
done := make(chan struct{})
|
|
go func() { fn(); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(d):
|
|
t.Fatalf("%s blocked for more than %v", what, d)
|
|
}
|
|
}
|
|
|
|
func waitUntil(t *testing.T, what string, cond func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for !cond() {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("timed out waiting for %s", what)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestNodeCacheServesStaleWhileRefreshing(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
s := &PacketStore{db: db}
|
|
|
|
stale := []nodeInfo{{PublicKey: "stale0001", Name: "Stale"}}
|
|
s.nodeCache, s.nodePM = stale, buildPrefixMap(stale)
|
|
s.nodeCacheTime = time.Now().Add(-time.Hour)
|
|
|
|
hook, calls, entered, release := blockingLoadHook("nodes")
|
|
s.cacheLoadHook = hook
|
|
|
|
var got []nodeInfo
|
|
returnsWithin(t, time.Second, "stale getCachedNodesAndPM", func() {
|
|
got, _ = s.getCachedNodesAndPM()
|
|
})
|
|
if len(got) != 1 || got[0].PublicKey != "stale0001" {
|
|
t.Fatalf("want the stale cache served, got %v", got)
|
|
}
|
|
<-entered // the background refresh reached the SQL
|
|
|
|
// Further stale callers while the refresh is in flight: served from the
|
|
// stale cache, and they do not start a second refresh.
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); s.getCachedNodesAndPM() }()
|
|
}
|
|
returnsWithin(t, time.Second, "concurrent stale callers", wg.Wait)
|
|
if n := atomic.LoadInt64(calls); n != 1 {
|
|
t.Fatalf("want exactly 1 background refresh, got %d", n)
|
|
}
|
|
|
|
close(release)
|
|
waitUntil(t, "refreshed node cache", func() bool {
|
|
nodes, _ := s.getCachedNodesAndPM()
|
|
return len(nodes) > 0 && nodes[0].PublicKey != "stale0001"
|
|
})
|
|
}
|
|
|
|
func TestNodeCacheInvalidatedBuildsInline(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
s := &PacketStore{db: db}
|
|
|
|
stale := []nodeInfo{{PublicKey: "stale0001", Name: "Stale"}}
|
|
s.nodeCache, s.nodePM = stale, buildPrefixMap(stale)
|
|
s.nodeCacheTime = time.Now()
|
|
s.InvalidateNodeCache()
|
|
|
|
var calls int64
|
|
s.cacheLoadHook = func(k string) {
|
|
if k == "nodes" {
|
|
atomic.AddInt64(&calls, 1)
|
|
}
|
|
}
|
|
nodes, _ := s.getCachedNodesAndPM()
|
|
if atomic.LoadInt64(&calls) != 1 {
|
|
t.Fatalf("an invalidated cache must rebuild inline, hook calls = %d", calls)
|
|
}
|
|
for _, n := range nodes {
|
|
if n.PublicKey == "stale0001" {
|
|
t.Fatal("an invalidated cache must not serve the old node list")
|
|
}
|
|
}
|
|
if len(nodes) == 0 {
|
|
t.Fatal("want the seeded nodes after an inline rebuild")
|
|
}
|
|
}
|
|
|
|
// A refresh that started before InvalidateNodeCache read the DB before the
|
|
// change the invalidation announces, so it must not mark the cache fresh.
|
|
func TestNodeCacheRefreshDoesNotOverrideInvalidation(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
s := &PacketStore{db: db}
|
|
|
|
stale := []nodeInfo{{PublicKey: "stale0001", Name: "Stale"}}
|
|
s.nodeCache, s.nodePM = stale, buildPrefixMap(stale)
|
|
s.nodeCacheTime = time.Now().Add(-time.Hour)
|
|
|
|
hook, _, entered, release := blockingLoadHook("nodes")
|
|
s.cacheLoadHook = hook
|
|
s.getCachedNodesAndPM()
|
|
<-entered
|
|
|
|
s.InvalidateNodeCache()
|
|
close(release)
|
|
waitUntil(t, "background refresh to finish", func() bool { return !s.cacheRefresh.running("nodes") })
|
|
|
|
s.cacheMu.Lock()
|
|
builtAt := s.nodeCacheTime
|
|
s.cacheMu.Unlock()
|
|
if !builtAt.IsZero() {
|
|
t.Fatal("a refresh that began before InvalidateNodeCache marked the cache fresh")
|
|
}
|
|
}
|
|
|
|
func TestRegionObsServesStaleWhileRefreshing(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
s := &PacketStore{db: db}
|
|
|
|
s.regionObsCache = map[string]map[string]bool{"SJC": {"stale-obs": true}}
|
|
s.regionObsCacheTime = time.Now().Add(-time.Hour)
|
|
|
|
hook, calls, entered, release := blockingLoadHook("regionObs")
|
|
s.cacheLoadHook = hook
|
|
|
|
var got map[string]bool
|
|
returnsWithin(t, time.Second, "stale resolveRegionObservers", func() {
|
|
got = s.resolveRegionObservers("SJC")
|
|
})
|
|
if !got["stale-obs"] {
|
|
t.Fatalf("want the stale region set served, got %v", got)
|
|
}
|
|
<-entered
|
|
returnsWithin(t, time.Second, "second stale caller", func() { s.resolveRegionObservers("SJC") })
|
|
if n := atomic.LoadInt64(calls); n != 1 {
|
|
t.Fatalf("want exactly 1 background refresh, got %d", n)
|
|
}
|
|
|
|
close(release)
|
|
waitUntil(t, "refreshed region cache", func() bool {
|
|
m := s.resolveRegionObservers("SJC")
|
|
return len(m) > 0 && !m["stale-obs"]
|
|
})
|
|
}
|
|
|
|
// A cold region still queries inline, but it must not hold regionObsMu while
|
|
// it does: other regions are served from the cache meanwhile.
|
|
func TestRegionObsColdMissDoesNotBlockCachedRegion(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
s := &PacketStore{db: db}
|
|
|
|
s.regionObsCache = map[string]map[string]bool{"SJC": {"obs-sjc": true}}
|
|
s.regionObsCacheTime = time.Now()
|
|
|
|
hook, _, entered, release := blockingLoadHook("regionObs")
|
|
s.cacheLoadHook = hook
|
|
go s.resolveRegionObservers("LAX")
|
|
<-entered
|
|
|
|
returnsWithin(t, time.Second, "cached region during a cold miss", func() {
|
|
if !s.resolveRegionObservers("SJC")["obs-sjc"] {
|
|
t.Error("want the cached SJC set")
|
|
}
|
|
})
|
|
close(release)
|
|
}
|
|
|
|
func TestAreaNodesServesStaleWhileRefreshing(t *testing.T) {
|
|
db := setupTestDBv2(t)
|
|
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('pk-fresh', 50.85, 4.35)`)
|
|
cfg := &Config{Areas: map[string]AreaEntry{
|
|
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
|
|
}}
|
|
s := newTestStoreWithDB(t, db, cfg)
|
|
s.areaNodeCache["BEL"] = map[string]bool{"pk-stale": true}
|
|
s.areaNodeCacheTimes["BEL"] = time.Now().Add(-time.Hour)
|
|
|
|
hook, calls, entered, release := blockingLoadHook("areaNodes")
|
|
s.cacheLoadHook = hook
|
|
|
|
var got map[string]bool
|
|
returnsWithin(t, time.Second, "stale resolveAreaNodes", func() {
|
|
got = s.resolveAreaNodes("BEL")
|
|
})
|
|
if !got["pk-stale"] {
|
|
t.Fatalf("want the stale area set served, got %v", got)
|
|
}
|
|
<-entered
|
|
returnsWithin(t, time.Second, "second stale caller", func() { s.resolveAreaNodes("BEL") })
|
|
if n := atomic.LoadInt64(calls); n != 1 {
|
|
t.Fatalf("want exactly 1 background refresh, got %d", n)
|
|
}
|
|
|
|
close(release)
|
|
waitUntil(t, "refreshed area cache", func() bool { return s.resolveAreaNodes("BEL")["pk-fresh"] })
|
|
}
|