Files
meshcore-analyzer/cmd/server/db_channels_singleflight_test.go
T
6d3da77b67 perf(channels): coalesce concurrent GetChannels/GetEncryptedChannels cache misses (#2059)
Fixes #2029.

## What was wrong

`GetChannels`/`GetEncryptedChannels` (`cmd/server/db.go`) cache their
region-scoped result for 60s but had no request coalescing on a cache
miss, so every request that arrived while the cache was cold or expired
ran the region-scoped `GROUP BY` scan itself. Measured on production: 5
concurrent requests for the same never-cached region each took ~8s, no
cheaper than 5 independent runs.

`statsSF`/`regionMembershipSF` already fix the identical bug class
elsewhere in this file (#1910), so this wraps both functions'
query-build/execute/cache-populate block in a `singleflight.Group` the
same way, keyed per region, double-checking the cache inside the flight
in case a previous winner already refreshed it.

## Tests

The review on #2029 pointed out that a timing-based check ("finish
within ~2ms of each other") doesn't actually prove coalescing happened —
it would pass on a fast machine even without singleflight.
`db_channels_singleflight_test.go` uses a call counter instead, same
pattern as `TestEnsureNeighborGraph_Singleflight` (#1203 Pair A):

- `TestGetChannels_SingleflightCoalescesQueries` /
`TestGetEncryptedChannels_SingleflightCoalescesQueries`: 10 concurrent
callers against a cold cache, asserting the real query runs exactly
once. A test-only hook (`channelsQueryHook`/`encChannelsQueryHook`, nil
in production, same contract as `bgLoaderEntryHook`) increments the
counter right where the query executes, since these functions hit
`db.conn.Query` directly rather than going through an injectable builder
function.
- `TestGetChannels_SingleflightPerRegion`: two regions queried
concurrently (5 callers each) assert 2 queries, not 1 — pins that the
flight is keyed per-region and a caller for one region can't receive
another region's coalesced result.

Anti-tautology: reverting `channelsSF.Do`/`encChannelsSF.Do` back to a
bare call makes the coalescing tests observe N instead of 1.

`go build ./...`, `go vet ./...`, `gofmt -l .` clean. Full `cmd/server`
suite (race-enabled for the new concurrency tests) run in a
`golang:1.22-alpine` container, mounted repo, workdir `cmd/server` so
the sibling `internal/*` replace directives resolve:

```
=== RUN   TestGetChannels_SingleflightCoalescesQueries
--- PASS: TestGetChannels_SingleflightCoalescesQueries (0.06s)
=== RUN   TestGetChannels_SingleflightPerRegion
--- PASS: TestGetChannels_SingleflightPerRegion (0.05s)
=== RUN   TestGetEncryptedChannels_SingleflightCoalescesQueries
--- PASS: TestGetEncryptedChannels_SingleflightCoalescesQueries (0.07s)
```
Full suite: `FAIL github.com/corescope/server 98.261s`, but the only
failures are `TestHandleNodePaths_PrefixCollision_1352`,
`TestHandleNodePaths_FallbackUniquePrefix_1352`, and
`TestHandleNodePaths_FallbackUnresolvableHop_1352`, all failing on a
`503 {"error":"index loading","retryAfter":5}` — an index-build race in
this container's timing, not this change. Confirmed by running the same
three against an unmodified, freshly-cloned `master` in the same
container: they fail there too (plus
`TestHandleNodePaths_PrefixCollision_1352_FallbackBranch`, which this
run happened not to hit). Nothing in this diff touches node-path
handling.

## Not done

The deeper query-plan issue flagged in #2029 (the outer scan is driven
by `payload_type`, not region, so a cold solo request still costs
several seconds regardless of concurrency) is filed separately as #2058,
with `EXPLAIN QUERY PLAN` output and row counts against production data.
Coalescing makes one slow query serve everybody; it doesn't make the
query itself fast.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: anieto <anieto@meshtexas.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:05:59 +02:00

130 lines
3.3 KiB
Go

package main
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// TestGetChannels_SingleflightCoalescesQueries (issue #2029) asserts that N
// concurrent cache-miss callers trigger at most ONE real query, using a call
// counter rather than timing. Timing alone ("finish within Nms of each
// other") passes on a fast machine even without coalescing, since it doesn't
// distinguish "one shared execution" from "N independent executions that all
// happened to be fast" — same rationale as TestEnsureNeighborGraph_Singleflight
// (#1203 Pair A) and statsSF (#1910).
//
// Anti-tautology: revert channelsSF.Do back to a bare call and this test
// fails (it observes N instead of 1).
func TestGetChannels_SingleflightCoalescesQueries(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
var calls int32
db.channelsQueryHook = func() {
atomic.AddInt32(&calls, 1)
time.Sleep(50 * time.Millisecond) // ensure callers actually overlap
}
var wg sync.WaitGroup
const N = 10
errs := make(chan error, N)
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
if _, err := db.GetChannels(); err != nil {
errs <- err
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Errorf("concurrent GetChannels: %v", err)
}
got := atomic.LoadInt32(&calls)
if got != 1 {
// got==0 would mean the hook never fired (a broken test, not a
// broken fix). got>1 means the query ran once per caller, i.e.
// singleflight is missing or keyed wrong. Both are caught by !=1.
t.Fatalf("expected exactly 1 real query under singleflight, got %d", got)
}
}
// TestGetEncryptedChannels_SingleflightCoalescesQueries mirrors the above for
// GetEncryptedChannels/encChannelsSF, which has the identical bug shape and
// fix (see #2029).
func TestGetEncryptedChannels_SingleflightCoalescesQueries(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
var calls int32
db.encChannelsQueryHook = func() {
atomic.AddInt32(&calls, 1)
time.Sleep(50 * time.Millisecond)
}
var wg sync.WaitGroup
const N = 10
errs := make(chan error, N)
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
if _, err := db.GetEncryptedChannels(); err != nil {
errs <- err
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Errorf("concurrent GetEncryptedChannels: %v", err)
}
got := atomic.LoadInt32(&calls)
if got != 1 {
t.Fatalf("expected exactly 1 real query under singleflight, got %d", got)
}
}
// TestGetChannels_SingleflightPerRegion asserts channelsSF is keyed per
// region, not a single shared slot — two different regions queried
// concurrently must each get their own query, or a caller for region A would
// wrongly receive region B's coalesced result.
func TestGetChannels_SingleflightPerRegion(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
var calls int32
db.channelsQueryHook = func() {
atomic.AddInt32(&calls, 1)
time.Sleep(30 * time.Millisecond)
}
var wg sync.WaitGroup
regions := []string{"SAT", "DFW"}
wg.Add(len(regions) * 5)
for _, region := range regions {
region := region
for i := 0; i < 5; i++ {
go func() {
defer wg.Done()
_, _ = db.GetChannels(region)
}()
}
}
wg.Wait()
got := atomic.LoadInt32(&calls)
if got != 2 {
t.Fatalf("expected exactly 1 real query per distinct region key (2 regions), got %d", got)
}
}