Repository navigation
fix(packets): preserve area filtering in SQL history fallback - #579
Conversation
The GPS area filter was applied only in memory. buildTransmissionWhere ignored Area, so the store's since/until fallback to SQL history (ordinary and grouped) and the store-less DB path returned packets outside the area with a wrong total. PacketQuery now carries AreaNodes, the area resolved to member pubkeys by the same resolveAreaNodes/areaMemberSet set the in-memory filter uses, and the SQL predicate matches t.from_pubkey against it via one json_each bind (idx_transmissions_from_pubkey) before COUNT and LIMIT/OFFSET. A nil set applies no filter, as in memory. Relates to #569 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-Minimax PR#579 #569 — head e4f2841Status: Implemented. Draft PR, all CI jobs green on the first run, no reruns. Not merged or marked ready. Evidence tags: [T] test run by me (local or CI), [A] analysis or reading of code/plans, [K] known or inherited fact, not re-verified here. Requirement 1 — Area applied in the SQL predicate with in-memory semantics (ordinary and grouped)
Requirement 2 — Fallback across the cache boundary
Requirement 3 — Performance and bounds
Invariants
Local validation
CI per job (head e4f2841)
No reruns were needed (#271 did not occur). [T] Residuals
|
Review — CS-Macmini PR#579 — head e4f2841Dom: APPROVE med nits Independent read-only review. Evidence tags: [T] test/measurement I ran myself, [A] my own code analysis, [K] inherited fact I did not re-verify. Base: the branch sits directly on Findings
No blocking finding. No behaviour change beyond the stated purpose: the diff adds one Answers to the review pointsSQL predicate has the same semantics as the in-memory path. Yes, and the membership source is literally the same function. Both paths resolve through Ordinary and grouped, and I reproduced the equivalence test myself. All four tests green on the merged tree; the three behavioural ones are red on unchanged master with exactly the reported shape — 32 contract mismatches, in-memory control green, and the predicate test does not compile on master ( Fallback across the cache boundary — reproduced independently at the HTTP layer. Two servers on the same migrated fixture,
19 equals a direct SQLite count of in-area ADVERTs in that window. Same result ordinary and grouped. Paging with Real Packets UI. Same two builds, area selected through the real Perf — my own EXPLAIN QUERY PLAN. On the migrated fixture and on a 300 K-row / 4 K-pubkey database I built, with and without
No
Call-site coverage is complete. The only production callers of Other invariants. Each acceptance criterion in #569 maps to a test that is red before and green after (see below). No new Tests I ranOn the merged tree against
Red-before on unchanged master: My own extension of the equivalence matrix. I added ~1 360 further comparisons pairing CI per job, head My own mutants
What I did not verify
|
Relates to #569
Problem
The GPS
areafilter (originator membership) was applied only by the in-memory packet path.buildTransmissionWherehandledregionbut notarea, so whenever/api/packets(ordinary orgroupByHash=true) fell back to SQL for history older thanoldestLoaded— or ran store-less against the DB — it returned packets from outside the area, with atotalthat counted them too.On a CI-prepared fixture with an
SFBOXarea and a 1.5 h in-memory window,since=now-6h&area=SFBOXreturnstotal=512with GRP_TXT/TRACE rows on master, andtotal=18(ADVERTs only, equal to a direct SQLite count) with this change.Plan / what changed
cmd/server/db.go).PacketQuerygainsAreaNodes map[string]bool, which isArearesolved to its member pubkeys. The DB layer has noconfig.Areas, so the caller resolves it.buildTransmissionWhereaddst.from_pubkey IN (SELECT value FROM json_each(?)). BothQueryPacketsandQueryGroupedPacketsuse it beforeCOUNT(*)andLIMIT/OFFSET, so totals and pages are filtered, not post-filtered.cmd/server/store.go). The store's ordinary and grouped SQL fallbacks setq.AreaNodes = s.resolveAreaNodes(q.Area). That is the same cached set the in-memory filter reads. The DB-to-set conversion is extracted intoareaMemberSet, whichresolveAreaNodesnow calls.cmd/server/routes.go). With no store,handlePacketsresolves the area through the sameareaMemberSetandcfg.Areas.Semantics, matched to
filterPacketsPreparedpublic_key/pubKeyt.from_pubkey, which the ingestor writes from the decoded ADVERTpubKey(Kpa-clawbot#1143)NULL/""never match;""is dropped from the bound set)resolveAreaNodes→ nil → no filterlen(AreaNodes)==0→ no predicateGetNodePubkeysInArea)geofilter.PassesFilterAND)Known residual (documented, not changed): SQL reads the
from_pubkeycolumn, notdecoded_json. An ADVERT row whosefrom_pubkeyhas not been backfilled yet, or a legacy row that carries onlypublic_key, can match in memory but not in SQL. This is the same Kpa-clawbot#1143 contract that the SQLnodeand region membership filters already rely on: the ingestor writesfrom_pubkeyat insert time and backfills it. A JSON fallback was measured and rejected (see Performance).Performance
json_each). There is noIN (?,?,…)growth, no per-row Go loop, and no per-rowdecoded_jsonparse.EXPLAIN QUERY PLANfor the area predicate:SEARCH t USING COVERING INDEX idx_transmissions_from_pubkey (from_pubkey=?)plus a bloom-filteredjson_eachlist. Together withsince/until, the planner drives off the observation-timestamp subquery and checksfrom_pubkeyper candidate rowid.TestAreaFilterSQLPredicateBounded569asserts the index and noSCAN t.OR (from_pubkey IS NULL AND payload_type=4 AND json_extract(...))) turned the plan into a MULTI-INDEX OR that walks every ADVERT throughidx_transmissions_payload_type. It was rejected for that reason.BenchmarkAreaSQL569: fullCOUNT+ page, 30K ADVERTs from 2K nodes, 1K-member area,-benchtime 20xon a local dev machine:since(the shape of every history fallback)since+ areaEvery store fallback carries
sinceoruntil, so the realistic cost is thesince+ area row. The area-alone row is the worst case for the store-less path.Tests (written first, red on master)
TestAreaFilterSQLMatchesMemory569is an equivalence matrix. It compares the in-memory path against the store SQL fallback and the store-less DB path, forsince,until, andsince+untilwindows, ordinary and grouped. Areas: none, polygon, bbox, empty, unknown. Extra filters: none, two regions,type,observer. Pages:limit=50,limit=2,offset=2, and an offset past the end. It asserts identical hashes, order andtotal.TestAreaFilterSQLFallbackContract569pins absolute results for all three paths. It covers polygon, bbox, area+region, unknown key, empty area and an empty result, so the matrix cannot pass by both sides drifting together.TestAreaFilterAcrossCacheBoundary569adds SQL-only rows older than the realoldestLoaded(inside and outside the area). It checkssince,since+untilanduntilwindows that cross the boundary, and pages through them withlimit=3. It checks totals and the absence of duplicates or outside rows.TestAreaFilterSQLPredicateBounded569checks one predicate and one argument for 5,000 members, nodecoded_json, theidx_transmissions_from_pubkeyplan, that""is dropped, and that nil membership adds no predicate.Red on unchanged master: the first three fail (32 SQL mismatches in the contract test; the in-memory control passes). The predicate test does not compile on master because
AreaNodesdoes not exist.Eight mutants were applied one at a time, and each is killed (details in the report comment).
Validation
cd cmd/server && go test ./...passes, and so doescd cmd/ingestor && go test ./.... The new tests also pass under-race.sh test-all.sh,node test-frontend-helpers.jsandtest-e2e-playwright.jswere run against a local Go server on a CI-preparede2e-fixture.db(freshened, migrated, seeded).Area: SF boxselected and Last 6 hours chosen (beyond the 1.5 h in-memory window, so the request takes the SQL fallback), the page showsLatest Packets (18)with only ADVERTs. The request is/api/packets?since=…&limit=50000&area=SFBOX&groupByHash=true→total=18.Scope
cmd/serverstays read-only: SELECTs only, no schema change, and no new index (idx_transmissions_from_pubkeyalready exists).map[string]interface{}, no frontend change, no new requests.🤖 Generated with Claude Code