mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-08 07:37:39 +00:00
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>
130 lines
3.3 KiB
Go
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)
|
|
}
|
|
}
|