perf(channels): coalesce concurrent GetChannels/GetEncryptedChannels cache misses per region - #138
Conversation
…s misses (#109) Adds two test seams to DB, nil in production: channelsMissHook (after a cache miss) and channelsQueryHook (right before each real query), and channels_singleflight_109_test.go, which counts real queries on a seeded fixture (pinTestDB/pinTestSeed, the #107 fixture). On master all 6 fail: 16 concurrent cold GetChannels("AAR") run 16 queries, normalized region forms are not shared, GetEncryptedChannels has no coalescing and no cache, a failing flight runs 8 queries for 8 callers, and a caller held past its miss queries again after another caller has filled the cache. Results per region are already correct; the tests pin that too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
…normalized region (#109) GetChannels had a single-slot 60s cache and no request coalescing; GetEncryptedChannels had neither. Every request that missed ran the GROUP BY scan itself. Both now go through channelListCache (channels_list_cache.go): - keyed by the normalized region (sorted, de-duplicated codes), so " aar " and "AAR,aar" share one entry and one flight, and different regions never share a result; - per-key singleflight with a second cache check inside the flight; - only successful results are stored: an error goes to that flight's callers and the next call queries again; - bounded to 64 keys (the region is a query parameter): expired entries go first, then the one closest to expiry. The query bodies are unchanged: #107's pinned-then-unpinned fallback is the same code, now in queryChannels(key); #98's full-slice append in handleChannels is untouched and the cached slices are never modified. No new map[string]interface{} (the cached slice moved from a DB field to channelListEntry in db.go; count unchanged at 66). Benchmark, 16 concurrent cold callers (8 GetChannels, 8 GetEncryptedChannels, region AAR, 20k-row fixture): master 16 queries and about 440 ms per round, this branch 2 queries and about 54 ms. TestDifferentRegionsAreNeverMixed_109 now spreads both kinds over every region (the first version paired each region with one kind only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
Independent review of
|
| Criterion | Result |
|---|---|
Concurrent misses for the same normalized region → at most one real GetChannels query |
Met [F]. 16 callers → 1 query; 4 spellings (AAR, aar, aar,AAR, Aar) → 1 query. M1 (no singleflight) and M5 (raw, un-normalized key) are both caught. |
Same for GetEncryptedChannels |
Met [F]. 16 → 1, then served from the cache within the TTL. M1 is caught. |
| Different region keys never coalesced or mixed | Met [F]. 4 regions × 2 kinds are compared with uncoalesced results. M4 (one global flight key) and M8 (encrypted uses the channels cache) are caught. Keys are separate per kind because each cache has its own singleflight.Group (db.go:101-102). |
| Errors shared only within the flight, not cached | Met at the cache layer [F]. M3 (cache the error as empty success) is caught. See finding 3 for iteration errors that never reach this layer. |
| Second cache check inside the singleflight winner | Met [F]. channels_list_cache.go:105-108. M2 (remove it) is caught by TestSecondCacheCheckInsideTheFlight_109, both kinds. |
| #107 index pinning and fallback intact | Met [F]. The diff only moves the query body into queryChannels(key) and adds the hook; the pinned-then-unpinned SQL is unchanged. TestGetChannels_PinnedMatchesUnpinned, TestGetChannels_MissingIndexFallsBack and TestChannelsSQL_* pass. |
| #98 cached-slice protection intact | Met [F]. routes.go:3419 and :3431 are unchanged. M10 (plain append(channels, encrypted...)) is caught by TestChannelsListDoesNotMutateCaches and TestChannelsCacheAppend_DBPath. The encrypted slice is now shared too; its only caller (routes.go:3412-3419) reads it as the append source and never writes into it [F]. |
| Deterministic tests count real executions | Met [F]. channelsQueryHook runs once per real query. On the head, the counts do not depend on timing (the 300 ms grace only makes callers overlap on commit A). |
-race plus cold concurrent measurement before/after |
Met [F]. See the next sections. |
| Kpa-clawbot#1936 comment: keyed caches plus per-key singleflight for both lists | Met [F]. The cache is keyed by normalized region and bounded at 64 keys (M6, unbounded, is caught). Bytes are not bounded; see finding 2. |
| Kpa-clawbot#1936 comment: do not port the message-page cache or the composite index | Met [F]. There is no message cache and no schema or index change. The only db.go changes are in the channel list functions and the DB struct. |
Test-first and mutants
Commit A (tests plus hooks only): all 6 tests present are red, for the intended reason [F]:
ran 16 queries, want 1ran 12 queries, want 1channels=16 encrypted=16, want 4 eachfailing flight ran 8 queries2 queries: the held caller queried again
On the head, all 8 _109 tests are green [F]. Between A and the head, TestDifferentRegionsAreNeverMixed_109 changed its region index from i%len to (i/2)%len. The change is justified: with the old index, only 2 regions were exercised per kind, so the "one per region" count could not hold. Two tests were also added: bound and key normalization. A also adds the two hook fields to db.go. They are test seams, nil in production.
The mutant runs used -run '_109|ChannelsCacheAppend|ChannelsListDoesNotMutateCaches|GetChannels_|ChannelsSQL|ChannelsListIncludesApproved'. All files were restored afterwards, and shasum matches git show 048db3c9:<path> for channels_list_cache.go, db.go and routes.go [F].
| # | Mutant | Result |
|---|---|---|
| M1 | No singleflight (query right after the miss, still cache) | caught (5 tests) |
| M2 | Remove the second cache check inside the flight | caught (SecondCacheCheck, both kinds) |
| M3 | Cache an error as an empty success | caught (ErrorsAreSharedButNotCached, both kinds) |
| M4 | One constant flight key for all regions | caught (DifferentRegionsAreNeverMixed) |
| M5 | Raw region as the key (no normalization) | caught (CoalescesOnTheNormalizedRegion) |
| M6 | No size bound | caught (ChannelListCacheIsBounded) |
| M7 | No expired-first eviction pass | survived: equivalent mutant (finding 4) |
| M8 | Encrypted list uses the channels cache | caught (DifferentRegionsAreNeverMixed) |
| M9 | TTL = 0 (never cached) | caught (EncryptedCoalesces, SecondCacheCheck, ChannelsCacheAppend_DBPath) |
| M10 | #98 regression: append into the cached slice | caught (ChannelsListDoesNotMutateCaches, ChannelsCacheAppend_DBPath) |
| M11 | Key not sorted | caught (ChannelsRegionKey) |
| M12 | get ignores expiry |
survived: test gap (finding 1). Killed by the reviewer probe. |
Suites run locally
cmd/serveron the head,go test -race -count=1 -timeout 60m ./...:ok github.com/corescope/server 910.562s, exit 0 [F]. This ran on a loaded machine alongside other reviewers' suites. The two flaky tests named in the PR (TestPollerBroadcastsNewData,TestIssue1008_...) did not fail in this run, so their flakiness is [T] and I did not check it.- Test and benchmark counts from
go test -list[F]:- master
ad011021: 2041 - head: 2039 (older base)
- merged tree: 2050, which is master + 8 tests + 1 benchmark.
- master
- Targeted run on the head:
go test -race -count=5 -run '_109|ChannelsCacheAppend|ChannelsListDoesNotMutateCaches|GetChannels_':ok(431.8 s) [F]. readonly_invariant_test.go(all 4 tests) andTestServerDBConnIsReadOnly: PASS [F].go vet: clean on the head and on the merged tree [F].cmd/ingestor: not touched and not run.
Performance and security
- Perf proof reproduced [F].
BenchmarkColdConcurrentGetChannels_109(16 cold callers, 8 + 8, region AAR, 20k-row fixture,-benchtime=20x -count=3). For commit A, I used a reviewer copy of the benchmark, adapted to A's cache fields.- Commit A: 480 / 437 / 430 ms per round, 16.00 queries/round.
- Head: 56 / 58 / 69 ms per round, 2.000 queries/round.
- This matches the PR's claim (≈440 ms → ≈54 ms).
- Warm path [A]. It adds
channelsRegionKey(split, sort and join over a handful of codes) and a mutex per call. That is negligible next to JSON encoding of the response. I did not benchmark it. - Cancellation. DB calls take no
context, andDo(notDoChan) is used. So no caller's cancellation can abort other callers' results, and a slow query blocks its waiters exactly as long as it would have blocked each of them separately [F]. - Panics.
x/sync v0.10.0singleflight re-panics in all waiters, andnet/httprecovers per request [A]. - Boundedness.
- Entries are capped at 64 per kind, with eviction in O(64) under the mutex [F]. Bytes are not bounded (finding 2).
- The
singleflightmap holds only in-flight keys and deletes each on completion [F]. - No goroutines or timers are added [F].
- Aliasing. Stored entries are never modified after
put.e.expiresis set on a fresh entry before it is stored, and callers only read [F]. - New
map[string]interface{}. None added:db.gohas 66 before and 66 after, total non-testcmd/serveris 710 before and 710 after, and the new file has 0 [F]. - Writes. No DB writes; the read-only invariant tests pass [F].
- Behaviour change [K].
includeEncrypted=truenow shows the encrypted list up to 60 s stale, as the issue comment asks. The two lists expire independently, so for up to 60 s a channel can appear in neither or both lists after it changes state [A]. A similar window already existed on master, because only the decrypted list was cached.
Not verified
- The PR's two flaky full-suite tests (
TestPollerBroadcastsNewData,TestIssue1008_...): they did not fail in my run [T]. - The upstream perf(channels): coalesce concurrent GetChannels/GetEncryptedChannels cache misses Kpa-clawbot/CoreScope#2059/perf: index, cache, and deflake /api/channels queries (rebase of #1887) Kpa-clawbot/CoreScope#1936 comparison (for example "upstream's map is unbounded"): no upstream access [T].
- An actual mid-iteration
rowserror (finding 3): I read it from the code; it is not reproduced. - Production traffic, region mix and hit rate: the PR lists these honestly under "Not verified". It does not mention the missing TTL-expiry test or the key-size exposure.
- No browser check: this is a backend-only change with no UI.
…109) queryChannels and queryEncryptedChannels did not check rows.Err() after the row loop. A SQLite step that fails part-way ends rows.Next() like the last row does, so the truncated list was returned as success and, since #109, cached for the TTL and shared with every waiter of the flight. Both now return the error, which the cache already never stores. Test: TestChannelRowsErrorIsNotCachedAsSuccess_109 fails the iteration after one row (both kinds) through a new test seam, channelsRowsHook, and asserts the error, a re-query on the next call and the full list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FcyXW5RdFzLZhuL1ntAsY
The cache key is the normalized region query parameter, whose length is caller-controlled (up to the 1 MiB header limit). The 64-entry bound alone let 64 such keys per list stay in memory for the TTL. A key over 256 bytes is now stored, and used as the flight key, as "sha256:<hex>" (71 bytes). Long regions are still cached, coalesced and kept apart; the 64-entry bound is unchanged. Test: TestChannelListCacheKeyLengthIsBounded_109 (300 codes, two 1 MiB codes differing in the last byte, and a short code; both lists). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FcyXW5RdFzLZhuL1ntAsY
|
Review feedback addressed (commit
Test counts:
Not addressed in this round: the TTL-expiry test gap and the expired-first eviction nit. Generated by Claude Code |
Only the cache and the flight use the bounded (sha256) key; the query must still get the full normalized region. Without this, passing the digest to the query would look up the region "sha256:..." and cache an empty list for 60 s, and no test caught it (review mutant G3). Also correct the comment on why a digest never equals a short key: region codes are upper-case, so none starts with the lower-case "sha256:". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review feedback addressed (commit
|
Relates to #109
Plan and design
The user asked for autonomous work, so the plan is written here instead of waiting for sign-off (AGENTS.md rule 5). Scope: the issue plus the extension in its comment (keyed caches + per-key singleflight). As instructed, this PR does not add upstream's message-page cache or its composite index.
Commits:
4abd9caf: tests plus two test hooks indb.go(channelsMissHook,channelsQueryHook), which count real query executions. Red on master.048db3c9: the change.a39b2fd5(review round): a channel list whose rows stop on an error is returned as an error, not as a truncated success.7f32845b(review round): stored cache keys are bounded in size.Claims in the issue, verified against master
GetChannelshas a single-slot 60 s cache and no coalescing.GetEncryptedChannelshas no cache at all.Change: new
cmd/server/channels_list_cache.gochannelListCachebacks both calls.channelsRegionKey: upper-cased, trimmed, de-duplicated, sorted codes;""/all→ all regions). So" aar "and"AAR,aar"share one entry and one flight, and different regions never share a result.singleflight(golang.org/x/sync, already a dependency), with a second cache check inside the flight. A caller that missed before another flight stored its result finds that result instead of querying again.a39b2fd5, bothqueryChannelsandqueryEncryptedChannelscheckrows.Err()after the row loop, so a SQLite step that fails part-way is an error too; a truncated list is never returned or cached as success.7f32845b, a key longer than 256 bytes is stored, and used as the flight key, assha256:<hex>(71 bytes), so a longregionparameter (up to the 1 MiB header limit) is no longer kept byte for byte. Long regions are still cached, coalesced and kept apart. The 64-key bound is unchanged.Behaviour change: the encrypted list is now cached
GetEncryptedChannelshad no cache before. It now has the same 60 s TTL asGetChannels, as the issue comment asks./api/channels?includeEncrypted=truecan be up to 60 s old.Preserved fork behaviour
queryChannels(key).handleChannelsis untouched, and cached slices are never modified.map[string]interface{}: the cached slice moved from aDBfield tochannelListEntry. The count indb.gois unchanged at 66.How this differs from upstream
Kpa-clawbot/CoreScope#2059/Kpa-clawbot/CoreScope#1936Upstream is read as a reference only; nothing was cherry-picked.
annotateMessageAreas,annotateBotReplyTouchedAreas), so that would need a copy-on-read contract first (separate decision per the issue comment).Acceptance criteria
GetChannelsqueryTestGetChannelsCoalescesConcurrentMisses_109(16 callers → 1),TestGetChannelsCoalescesOnTheNormalizedRegion_109(4 spellings → 1)GetEncryptedChannelsqueryTestGetEncryptedChannelsCoalescesConcurrentMisses_109(16 → 1, then cached within the TTL)TestDifferentRegionsAreNeverMixed_109,TestChannelListCacheKeyLengthIsBounded_109(long keys too)TestChannelErrorsAreSharedButNotCached_109,TestChannelRowsErrorIsNotCachedAsSuccess_109(both kinds)TestSecondCacheCheckInsideTheFlight_109(both kinds)channel_proposals_test.go(adapted to the keyed cache)channels_cache_append_test.go(adapted to the keyed cache) passeschannelsQueryHookcounts each real query; callers are held on a barrier so they really overlap-raceand cold concurrent measurement before/afterTests
048db3c9(_109tests, including the cache-bound and key-normalization tests)TestChannelRowsErrorIsNotCachedAsSuccess_109both subtests;TestChannelListCacheKeyLengthIsBounded_109)7f32845bReview-round details:
TestChannelRowsErrorIsNotCachedAsSuccess_109fails the iteration after 1 row through a new test seam,channelsRowsHook(nil in production, like the existing hooks). It asserts the error, a re-query on the next call and the full list.rows.Err()check fails the matching subtest. Truncating long keys instead of hashing them mixes two 1 MiB regions and is caught. Skipping the store key is caught.cd cmd/server && go test -count=1 ./...on7f32845b:ok(60.9 s).go test -race -run "_109|Channel|channel"on048db3c9:ok(387.7 s on the loaded local machine).BenchmarkColdConcurrentGetChannels_109: 16 concurrent cold callers (8GetChannels+ 8GetEncryptedChannels, region AAR, 20k-row fixture).Full server
-racerun on048db3c9It reported
FAILin two tests, neither in code this PR touches:TestPollerBroadcastsNewData(WebSocket poller:expected data.packet.timestamp to exist). In isolation under-raceit failed 1 of 40 runs on origin/masterd264716cand 0 of 40 on this branch. That is a pre-existing intermittent failure, shown on master.TestIssue1008_HandlerReturns503WhileSubpathIndexLoading(status 200 vs 503, timing-dependent). It passes 30/30 in isolation on both master and this branch. Reproduced on origin/masterd264716c:go test -race -count=200 -cpu 1,2,8 -run '^TestIssue1008_HandlerReturns503WhileSubpathIndexLoading$'failed 2 of 600 runs, twice, with the identical message (status = 200, want 503). It is a scheduling race in the test: the background subpath build on a tiny DB can finish before the handler call. This PR does not touch that build or the handler. The master full-suite run that passed (ok, 1307 s) simply did not hit it.Not verified
Overlap with other open PRs
cmd/server/db.gois changed only by this PR.85bfee49and merges cleanly withorigin/master. A merge simulation of all 13 batch branches in issue order merges this one without conflicts.🤖 Generated with Claude Code
https://claude.ai/code/session_011FcyXW5RdFzLZhuL1ntAsY