Repository navigation
port(upstream#1882): nodes region filter reads the indexed from_pubkey column - #38
Conversation
The region subquery in GetNodes was pulling the advert pubkey out of decoded_json with JSON_EXTRACT for every row the join touched, instead of reading the from_pubkey column that Kpa-clawbot#1143 already added and indexed It looks like buildPacketWhere, GetRecentTransmissionsForNode, QueryMultiNodePackets etc. moved to from_pubkey already, but not this. (cherry picked from commit eb8f376) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings the branch up to master 834c8da so CI runs on current master. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parked: correct only while
|
from_pubkey NULL |
dense region | sparse region |
|---|---|---|
| 25% | 7830 → 7552 (−3.5%) | 5588 → 4707 (−15.8%) |
| 100% | 7830 → 0 | 5588 → 0 |
The loss is superlinear in region sparsity: a node survives only if at least one of its ADVERTs in that region is already backfilled. The backfill can also abandon rows permanently — cmd/ingestor/maintenance.go:181,205,221 bare-return on select/begin/commit errors.
3. The one operator signal for that window is hardcoded green. cmd/server/from_pubkey_migration.go:18 sets fromPubkeyBackfillDone = true and nothing ever writes it; the ingestor passes nil for the progress callback. /api/healthz therefore always reports done: true, and public/warmup-banner.js:79-85 auto-dismisses on that — so the banner built to warn "attribution is incomplete right now" never shows. Pre-existing, but this change is what makes a user-visible query depend on that dead signal.
The existing tests cannot catch any of it
TestGetNodesFiltering/region_filter_* and TestGetNodesRegionFilterV2 do cover the changed line, and they pass. But all three helpers (db_test.go:142, db_test.go:1951, coverage_test.go:54) install trigger test_from_pubkey_advert, which auto-populates from_pubkey on every ADVERT insert — so in the test world the two expressions are equal by construction and the suite passes identically with either.
A test that would earn its keep: add a second node + ADVERT observed by the SJC observer, then UPDATE transmissions SET from_pubkey = NULL for it (the trigger is INSERT-only, so it sticks), and assert ?region=SJC returns both nodes. Passes on master, fails on this branch.
The perf case is weaker than stated, and the real argument is elsewhere
I measured 2.5x on the 500-row fixture; that does not survive scale. At 120k rows it is 1.17–1.31x (75–90 ms absolute either way, dominated by the transmissions→observations→observers fan-out). And idx_transmissions_from_pubkey is not used by this query — dropping it gives byte-identical EXPLAIN QUERY PLAN and timings within noise. The query drives off idx_transmissions_payload_type; from_pubkey is only projected, never searched. So the comment's "dedicated, indexed column" misattributes the win, which is really ~110k avoided JSON_EXTRACT calls.
The stronger argument the PR does not make: the old expression is a latent 500. SELECT JSON_EXTRACT('NOT JSON','$.pubKey') raises malformed JSON, so a single ADVERT with corrupt decoded_json in the queried region makes the whole subquery error and /api/nodes?region= return 500. The new expression is immune. This also removes the last $.pubKey SQL extraction in non-test code repo-wide.
Suggested resolution
COALESCE(t.from_pubkey, JSON_EXTRACT(t.decoded_json, '$.pubKey')) would keep nearly all of the win (JSON parsed only for the NULL minority) while being correct in every case above, including during backfill. Alternatively: populate from_pubkey in the POST /api/packets insert, and revive the backfill progress signal so the window is at least visible. Either way this wants the NULL-row test above.
Branch is synced with current master; go test -race ./... is clean (268s, 0 failures, 0 races).
The SQL is unchanged. Only the comment above it is, because it asserted two things that do not hold. 1. It called this "the indexed from_pubkey column" and implied the win comes from the index. EXPLAIN QUERY PLAN is byte-identical before and after the change — both drive off idx_transmissions_payload_type, and idx_transmissions_from_pubkey does not participate at all. The win is entirely the avoided per-row JSON parse. 2. It gave no number. Measured against a live 4.9GB database with 9 interleaved rounds (so concurrent ingest cannot bias one variant): 934ms -> 835ms median, 1.12x, identical result set both ways. The comment now also names the trap this change introduces, which nothing in the code or tests would otherwise reveal: from_pubkey is only written for ADVERTs that carry a pubkey, and a NULL drops out of IN (...) in silence. That is safe today because it was measured — 25 of 62104 ADVERTs have NULL from_pubkey, and all 25 have no pubKey in decoded_json either, so there is nothing to lose. It would stop being safe the moment a write path fills decoded_json without filling from_pubkey. A COALESCE(from_pubkey, JSON_EXTRACT(...)) fallback was written, measured and rejected: it rescued 0 rows on real data and cost half the win (1.12x -> 1.05x). The comment records that so the next reader does not repeat the experiment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Korrektion: min tidligere blocker holdt ikke — målt mod jeres rigtige databaseJeg parkerede tidligere denne PR som en bevist blocker med påstanden om "15,8 % nodetab". Det var forkert, og jeg trækker det tilbage. Tallet stammede fra et syntetisk backfill-scenarie med 25 % NULL, ikke fra denne installation. Jeg burde have målt mod den rigtige database, før jeg kaldte den en blocker. Hvad de rigtige data sigerMålt read-only mod stagings 4,9 GB
Formatet er også identisk — begge giver lowercase hex, der matcher Og de 25 NULL-rækker er ikke tabt data: ingen af dem har en Performance — målt ordentligtFørste måling svingede for meget til en konklusion (boksen ingester samtidig). Gentaget med 9 interleaved runder, så samtidig trafik ikke kan favorisere én variant:
Alle tre giver 877. Så gevinsten er reel, men mindre end en størrelsesorden. To ting rettet i
|
…one (#38) The db_test.go helpers install a trigger that copies decoded_json.pubKey into from_pubkey on every ADVERT insert, so the existing region tests pass identically with either expression and cannot tell them apart. The new tests drop that trigger and compare the real GetNodes against the pre-#38 JSON_EXTRACT expression as an oracle: - on the committed e2e fixture, every region alone, all combined, a lower-case padded pair and an unknown code; - on the row shapes the write paths produce: ingest-time from_pubkey, a pubkey-less ADVERT left NULL, a backfilled "" sentinel, and a non-ADVERT. A third test pins the deliberate behaviour change: one ADVERT with corrupt decoded_json no longer fails the region query ("malformed JSON"), which it does under the old expression and under a COALESCE fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The /api/nodes region filter now matches on from_pubkey alone, so the backfill must write exactly decoded_json.pubKey for legacy ADVERTs, "" for ADVERTs without one (including corrupt JSON), and leave non-ADVERTs NULL, across batch boundaries. No test covered its output: replacing the extracted pubkey with "" left the whole ingestor suite green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a 4.3GB synthetic database COALESCE(from_pubkey, JSON_EXTRACT(...)) times within noise of from_pubkey (11 interleaved rounds), so "it cost half the win" does not hold. The reason that does hold: every NULL row is parsed again, so a corrupt ADVERT the backfill has not reached fails the whole region query, as the old expression did. Also point at the tests that now guard the from_pubkey contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-pve-agent2 PR#38 runde 2 — head fd0b8a9Review feedback addressed (commit Evidence labels: [T] tested or measured in this round · [K] verified by reading code · [A] assumption or inference, not verified. Commits this round (no rebase, amend or force-push):
The production SQL is unchanged from Findings, blocking first
Equivalence (task item 2)
The synthetic DB has 1,600,065 transmissions, 6,400,111 observations, 2,300 nodes (300 never advertised) and 60 observers over 10 IATA codes, skewed from AAR (16 observers) to BIL (2). It was built from the migrated fixture schema with no Perf (task item 3, AGENTS.md rule 0)Method:
The ~630 ms floor that is the same for all variants (BIL) is the plan walking all ~61k ADVERTs and their observations for every region. That is the region-cache work in #176, which this PR does not touch. Tests first, then mutantsThe red evidence is the new tests run against master's
Local runs (head
|
| job | result |
|---|---|
| ✅ Go Build & Test | pass (15m48s) |
| 🎭 Playwright E2E Tests | pass (25m56s) |
| 🏗️ Build & Publish Docker Image | pass (45s) |
| 📦 Release Artifacts | skipped (version tags on upstream only) |
| 🚀 Deploy Staging | skipped (does not run for PRs) |
| 📝 Publish Badges & Summary | skipped (upstream push only) |
Neither of the known flakes (#271, #301) appeared.
Notes
- The PR is currently not a draft (
isDraft: false). The brief said it stays a draft. I did not change its state either way. - Out of scope and left alone: the healthz signal (finding 3) and perf(nodes): port upstream region membership cache (#2102) #176.
🤖 Generated with Claude Code
Review — CS-MacBook PR#38 — head fd0b8a9Dom: APPROVE with nits Independent, read-only review. Evidence labels: [T] tested/measured by me this round · [K] verified by reading code · [A] inferred or relied on other evidence, not run here. Findings
No blocking findings. The one-line swap is correct and safe on the data as it exists, in scope, and well guarded by the new tests. Answers to the review points1. Identical result (on a large DB). [T]
2. Perf proof. [T] 11 interleaved rounds (order alternated each round), read-only, warmed, median of each series. C-sqlite 3.54:
3. Test — does it pin the result set and kill the NULL-drop mutant? [T]
4. Scope. [K][T] Diff is exactly 3 files: Tests (merged tree:
|
| Suite | Result |
|---|---|
cmd/server full (go test .) |
ok, 46 s [T] |
cmd/server nodes/region -race |
ok, 65 s [T] |
cmd/ingestor full |
ok, 108 s [T] |
PR38 targeted (server + ingestor) |
pass [T] |
node test-frontend-helpers.js |
709 / 0 [T] |
sh test-all.sh |
227 / 0 [T] |
| GitHub CI on head fd0b8a9 | Go Build & Test pass, Playwright E2E pass (25m56s), Docker build pass [A] |
Mutants
| # | Mutant | Expected guard | Result |
|---|---|---|---|
| M0 | Revert production SQL to JSON_EXTRACT (= master) |
malformed-JSON test | RED malformed JSON (1); identity tests GREEN (variant-agnostic, as designed) [T] |
| mine#1 | Data: NULL from_pubkey on 25% of pubkey-bearing ADVERTs (110 MB DB) |
— (scale demo) | new query loses up to 400 of 800 nodes per region vs old; the loss is real and large, proving the swap's dependence on the write-path contract [T] |
| mine#2 | Data shape: one pubkey-bearing ADVERT left NULL in WritePathShapes |
…WritePathShapesMatchJSONExtract_PR38 |
RED missing [aa00000000000001] in SJC and SJC+SFO [T] |
| M6 | Backfill writes "" for every row |
…BackfillFromPubkey_CopiesDecodedPubKey_PR38 |
RED legacy-adv-1/2: from_pubkey = "" [T] |
mine#1 and mine#2 together confirm the task's concern: a forgotten from_pubkey on a pubkey-bearing ADVERT silently drops nodes, and a committed test now catches that shape.
What I did not verify
- HTTP / Playwright E2E locally. I exercised equivalence through the real
db.GetNodescode path (theFixtureMatchestest on committed staging data) rather than over HTTP; the HTTP layer adds nothing to the region-filter logic. For the full E2E I relied on the green CI run on this exact head. [A] - Real staging / production DB (no access). The "25 of 62104 ADVERTs NULL, all without
pubKey" figure is the author's round-1 staging measurement, not re-run here. [A] - modernc timing. My perf numbers are C-sqlite; a proxy for the JSON-parse cost, not the server's driver. [A]
- My synthetic DB is 110 MB with uniform per-region node counts — smaller than the author's 4.3 GB and with less region-size skew, so my speed-ups are a lower bound.
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.
Split out of #25 (commit
9334d1a7there). This branch holds exactly one upstream change so it can be reviewed, tested and reverted on its own.Upstream
eb8f376c6c29c8e703deebc1eb2a3dd8467a7276git cherry-pick -xonto masterfda24ca5; upstream authorship kept, and the commit message carries the(cherry picked from commit …)line.cmd/server/db.go(GetNodesregion filter).Problem
The region subquery in
GetNodesextracted the advert pubkey withJSON_EXTRACT(t.decoded_json, '$.pubKey')for every joined row, although Kpa-clawbot#1143 added the dedicated, indexedtransmissions.from_pubkeycolumn that the other node queries already use.Change
Uses
t.from_pubkeyin the subquery.Adaptation to this fork
None in content. The changed lines are identical to upstream; only the surrounding diff context differs because this fork's files have diverged around them.
Notes for review
AGENTS.md rule 0 (perf proof): the upstream PR has no measurement. Locally checked on the E2E fixture DB (no production data was used):
EXPLAIN QUERY PLANis identical before and after (idx_transmissions_payload_type→idx_observations_tx_ts→ observers rowid). The change only removes the per-rowJSON_EXTRACT/JSON parse.from_pubkeydiffers fromdecoded_json.pubKey.from_pubkeybeing populated for all adverts, which this fork'sfrom_pubkey_v1migration backfills. That was not checked against production data.Dependencies and merge order
fda24ca5and needs no other PR from this split.TestPruneOldNeighborMetricsdeterministic). If test(ingestor): make neighbor metrics pruning deterministic #33 lands first, the expected CI failure named below disappears; nothing in this PR depends on it.Verification
Local run of the same commands as CI's “Go Build & Test” job (server tests with
-race), on this branch and on masterfda24ca5under the same conditions (same machine, run one after another):fda24ca5go-server-build-vetgo-server-test-racechannel-lib-testdecrypt-cli-build-testdockerfile-copy-invariantsdeclare -A), macOS has 3.2; identical on masterstaging-disk-monitorcss-vars-lintBaseline failures (fail identically on master; not introduced or changed here): see rows marked baseline failure, unchanged.
Browser validation (local, fixture DB, no staging/production): Not applicable (no frontend change).
Not run:
eslint(not installed locally; CI installs it on the fly).Expected GitHub CI: “Go Build & Test” is expected to fail on
TestPruneOldNeighborMetrics, which already fails on master (see #25's run). Downstream jobs (Playwright, image build) are therefore skipped. “Deploy Staging” and all GHCR publish steps only run onpushtomasterand cannot run for this PR.Two further ingestor tests have failed intermittently in this split's CI on branches whose
cmd/ingestortree is byte-identical to master (#27, #28), so they can also appear here without being caused by this change:TestBackfillTxLastSeen_ResolvesFromMaxObservationTimestamp: also reproduced locally on unmodified master.TestMQTTStallWatchdog_DisconnectedEscalationThrottled_1749: the suite flake that upstream test(ingestor): join the watchdog loop goroutine instead of only asking it to stop Kpa-clawbot/CoreScope#2003 (also split out of port(upstream): 26 clean upstream fixes — prune batching, /ws limits, observer liveness, watchdog race #25) addresses.GitHub CI result: run 34751044665 on
5afba186. Go Build & Test: failure; all downstream jobs incl. Deploy Staging skipped. Failed tests:TestPruneOldNeighborMetrics: fails on master, documented baseline🤖 Generated with Claude Code