Repository navigation
perf(nodes): port upstream region membership cache (#2102) - #176
Conversation
Review follow-ups on the Kpa-clawbot#2102 port. 1. Keep #38's from_pubkey contract inside the cache. The port had reintroduced COALESCE(t.from_pubkey, JSON_EXTRACT(decoded_json, '$.pubKey')), which #38 measured and rejected: it rescues no rows, and one corrupt ADVERT fails the whole query. Behind a cache that is worse than before, because the failed refresh leaves the entry stale for every later request too, not just the one that triggered it. #38's comment block moves to the query it documents. 2. Bound the cache by evicting one entry, not by clearing the map. The old setNodeRegionEntry replaced the whole map when a 33rd region set arrived, so a client cycling through region sets — ?region= is a query parameter — emptied the cache on every request and every following request paid a full observation scan, serialised behind nodeRegionFullMu. Entries now evict least-recently-used, and a region set too long to key is stored under its SHA-256 digest (channelListMaxKeyBytes's rule). 3. Make the documented freshness the delivered freshness. An entry older than nodeRegionMaxStale is no longer served blind: the caller waits for its refresh, which at that age is a full rebuild. Without the wait a refresh that keeps failing serves membership of unbounded age with only a log line to show it. A failing refresh still falls back to the stale entry rather than 500-ing /api/nodes?region=. Tests: LRU eviction and the bound under 4x churn, the digest key, an addition visible within nodeRegionFreshTTL, removal by retention and by an observer IATA change gone within nodeRegionRebuildInterval and not by the delta scan, the over-stale wait and its fallback, and the cached result set against the uncached subquery for six region sets before and after new ADVERTs. The port's legacy-backfill test is replaced by one that locks #38's contract on the delta path. nodeRegionQueryHook now returns an error so a test can fail a scan. Perf (rule 0), nodes_region_cache_perf_176_test.go: 498MB / 1.5M observations / 749k transmissions, ANALYZE run; one Nodes page load with a region selected is 4 GetNodes pages of 500, 7 interleaved rounds of the same test file compiled against master and against this branch. Warm page load median 3729ms -> 31ms (121x; per GetNodes call 932ms -> 7.7ms, master's 932ms matching the 1-1.4s a region call costs on prod). Cold page load median 4292ms -> 705ms (6.1x): the one full membership scan replaces eight evaluations of the subquery.
Rapport — CS-Macmini PR#176 overtagelse — head a8541c0Review feedback addressed (commit PR #176 had never been reviewed, so the findings below are the ones from the 1. Merge
|
| before (master) | after (this branch) | ||
|---|---|---|---|
| warm page load (4 pages) | 3729 ms | 31 ms | 121x |
warm, per GetNodes call |
932 ms | 7.7 ms | |
| cold page load (4 pages) | 4292 ms | 705 ms | 6.1x |
cold, per GetNodes call |
1073 ms | 176 ms |
Master's 932 ms per region call sits inside the 1–1.4 s a region call costs on
prod, so the baseline is the right order of magnitude; the database is 498 MB
against prod's 1.4 GB, so the absolute numbers are conservative, not inflated.
All 7 rounds, page-load ms, sorted:
cold before [2421, 2651, 2896, 4292, 5543, 8645, 9000]
cold after [ 437, 619, 650, 705, 927, 943, 967]
warm before [3172, 3309, 3701, 3729, 4420, 4987, 5223]
warm after [29.4, 29.4, 30.3, 30.8, 46.2, 54.2, 75.2]
Cold is 6.1x rather than 121x because the one full membership scan is still
paid; it replaces the 8 subquery evaluations.
5. Correctness against the uncached query
[T] TestNodeRegionCacheMatchesUncachedForManyRegionSets_176 compares the
cached filter to an oracle built from the uncached subquery, for six region sets
(single, multi, mixed case with padding and duplicates, a region no observer
carries, and a mixed known/unknown set), each queried twice so the second answer
comes from the cache, then again after new ADVERTs arrive and every entry has
been taken past its TTL and refreshed. It fails if fewer than three sets match
any node, so it cannot pass vacuously.
[T] Master's TestGetNodesRegionFilter_FixtureMatchesJSONExtract_PR38 does the
same against the pre-#38 JSON_EXTRACT expression on the committed e2e fixture,
every region alone and combined — it now runs through the cache and passes.
[A] Against a local server on the prepared fixture, /api/nodes?region= matched
a sqlite3 oracle for all 8 sets tried (SJC, sjc, SJC,SJC,
MRY,OAK,SFO,SJC, sfo , SJC, ZZZ, ZZZ,SJC, All), totals and public
keys, on both passes; the server log shows 8 full builds for 8 distinct sets and
none for the repeats.
6. No other changes
Only cmd/server/db.go, cmd/server/nodes_region_cache.go and three test files.
No schema, ingestor, frontend, workflow, config or customizer change.
cmd/server stays read-only: the cache reads, and the only write SQL added is in
a _test.go file, which TestServerSourceHasNoNewWriteSQL does not scan.
Mutants
One per finding, each applied to nodes_region_cache.go, run, reverted.
| mutant | result | tests that went red |
|---|---|---|
M1 scan falls back to JSON_EXTRACT (the pre-merge branch query) |
KILLED | …MalformedAdvertJSONDoesNotFailQuery_PR38, …DeltaFollowsFromPubkeyContract_176 |
| M2 bound enforced by clearing the map instead of evicting one entry | KILLED | …EvictsOneLRUEntryAtCapacity_176 |
| M3 stored key not capped for a caller-supplied region set | KILLED | …LongRegionSetKeyIsBounded_176 |
| M4 stale entry returned without kicking a refresh | KILLED | …AdditionVisibleWithinFreshTTL_176, …RemovalGoneWithinRebuildInterval_176 (both subtests), …MatchesUncachedForManyRegionSets_176 |
| M5 full rebuild never becomes due, only delta scans run | KILLED | …RemovalGoneWithinRebuildInterval_176 (both subtests), …PortFullRebuildDropsPrunedRegion |
| M6 over-stale entry served while its refresh runs in the background | KILLED | …DoesNotServeBeyondMaxStale_176 |
Tests run locally
[T] cmd/server: go build, go vet, go test ./... — ok, 48.7s.
[T] cmd/server with -race (what CI runs): go test -race ./... — ok, 460s.
[T] cmd/ingestor: go vet ./..., go test ./... — ok, 113.8s.
[T] All 14 internal/* modules — ok (4 have no test files).
[T] sh test-all.sh — 228 passed, 0 failed (228 files).
[T] node test-frontend-helpers.js — 709 passed, 0 failed.
[A] E2E against a local Go server (port 13801) on e2e-fixture.db prepared as
in CI (freshen-fixture, Kpa-clawbot#1486 seed, corescope-migrate, Kpa-clawbot#2073/#199/#245 seeds,
instrumented frontend):
test-e2e-playwright.js— 132/135 passed, 3 skippedtest-issue-1136-live-region-e2e.js— 5 passed, 0 failedtest-map-nodes-pagination-e2e.js— 3 passed, 0 failedtest-area-nodes-map.js— passedtest-nodes-export-e2e.js— passed
[A] Browser validation (rule 2), Nodes page at 1440x900: All = 204 rows,
SJC = 20, SJC+SFO = 44, back to All = 204 — the same counts the API and the
sqlite oracle give. Requests issued:?limit=500&offset=0,
?limit=500®ion=SJC&offset=0,?limit=500®ion=SJC%2CSFO&offset=0. No
console errors. The local server was stopped afterwards. No staging or
production contact.
Not changed, on purpose
normalizeRegionCodesstill accepts an unbounded number of codes, so a
request with thousands of them still expands to thousands of SQL
placeholders. That is unchanged from master and lives outside this cache; the
cache's own key is now bounded regardless. Worth its own issue, not filed.- The cache is still per-process and per-region-set with no cross-set sharing: a
3-code set does not reuse the 3 single-code entries. Upstream's design; the
bound makes it safe, and the measured win does not depend on it.
CI per job — run 37616793970, head a8541c09
| job | result | time |
|---|---|---|
| Go Build & Test | pass | 19m46s |
| Playwright E2E Tests | pass | 26m16s |
| Build & Publish Docker Image | pass | — |
| Release Artifacts | skipped | — |
| Publish Badges & Summary | skipped | — |
| Deploy Staging | skipped | — |
No flakes, no reruns. The known-unstable #271 and #301 did not fire.
PR stays a draft. No merge, no ready-for-review, no issue closed.
Summary
Port upstream
Kpa-clawbot/CoreScope#2102to this fork: cache/api/nodes?region=node membership by normalized region set, reuse it across count/page requests, refresh additions incrementally after 30 seconds, and rebuild fully every 30 minutes for retention and IATA changes.Fork-specific changes
from_pubkeybackfill is incomplete: fall back todecoded_json.pubKeyfor rows whosefrom_pubkeyis NULL.Performance and behavior
TestNodeRegionCachePortMembershipAndReuseenforces one scan across three equivalent region requests.Verification
cmd/serverGo test suite andgo vet ./...passed locally.-race.cmd/ingestorGo test suite passed locally. The first sandboxed run could not bind the local MQTT test sockets; rerunning with local test sockets allowed passed.This is a port for review, not a deployment. No issue-closing keywords or auto-merge.
Related open PR #38 also changes the Nodes region query to use
from_pubkey; review its overlap before merging either PR.