Repository navigation
Observer comparison: read each observer's receptions on their own - #260
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Observer comparison: read each observer's receptions on their own
Problem
Since the API pool started making a fresh plan on every call (
plan_cache_mode = force_custom_plan, no parallel workers),/stats/observer-comparisongot slow on the dev box. 24 h windows took 6.6 to 13 s. 7 d windows always hit the handler's 15 s limit and returned 503. The SQL did not change.Before the upgrade the server used automatic plan caching. After 5 calls on a connection it switched to a cached generic plan, and that plan read each observer's rows through
idx_observations_observer. With the observer ids as literal values, the planner now does one of two things:idx_observations_heardand filters out the other observers. On the report's page (busy and mid observer, 22 h) that read 351K buffers, where the old generic plan read 78K.idx_packets_type_last_heard. On a small box whose visibility map is not current, that turns into a heap visit for every packet.Dev data makes both choices likely: 189 observers, and the busiest ones hear about 4 % of all receptions.
Change
The statement now reads each observer's receptions in its own scan, keyed by
(observer_id, heard_at). The two sets are joined with a full join onpacket_hash. Because(packet_hash, observer_id)is unique, each side holds a packet at most once. The route type is then checked once per heard packet, where before it was checked once per reception.The observer ids are passed as scalar subqueries. The planner then costs every observer the same way, so a busy observer's statistics can no longer push it toward the window walk or the full packet scan. In both plan modes, on every input tried, the plan is:
idx_observations_observerrange scans;packets_pkeyprobe per packet.The result is unchanged: same counts and same
knownflags. The query does not useidx_observations_observer_id, which was dropped.Results
Measured on a Mac, warm cache, best of 3. Settings were as the API pool runs them: JIT off, no parallel workers for the new build.
Buffers are shared hits.
Dev data (5.9M observations, window ending 2026-10-20 11:00). Busy pair: the two busiest observers, at the same site. Quiet pair: two observers with about 200 receptions a week each. Region: four sites, none of them the busy pair's. IATA: the busy pair's site.
* The current build's 7 d plans that show fewer buffers are the hash joins over every route 0/1 packet. They count each index page once, but they scan the whole packet index whatever the observers heard. That is the plan that times out on the dev box. With warm caches on the Mac, the current build is 10 % faster on "7 d all busy"; the new plan reads only the packets the two observers heard.
Local production copy (29M observations, 3,558 observers). Plans ran on
t_end, the read-only overlay thatdb/planguard/overlay/end_state.sqlbuilds with the migrated index set, and the window ends at 2026-10-18 17:00. The two busiest observers at one site make the busy pair. The quiet pair is two observers with about 100 receptions a week each.The plan corpus entries pick their own pairs through resolvers, and those pairs differ from the ones in this table. Their busy pair is the two busiest observers overall, which sit at different sites. Their budgets come from the local tier on the same
t_end. On those pairs the new statement reads the same buffers as the current build, within 0.5 %. For example, 24 h busy reads 130,894 B under both builds, and 7 d busy reads 372,197 B against 370,407 B. On the production copy the current build's plans mostly stay sane; they go wrong on dev-sized data and on the 7 d busy spill shown above.Generic-mode plans of the new statement read the same or fewer buffers than the old build's generic plans in every row.
Overall:
Tests
TestObserverComparisonMatchesTodayPostgres(new, added to the CI list) runs both the new statement and the old text over a skewed fixture: 2,000 packets of every route type and nine observers. It covers:{""}, lower case.It compares 2,112 parameter sets in custom and generic plan modes, with and without
enable_seqscan = off, with JIT off. All rows match. The fixture dropsidx_observations_observer_id. The test also checks that, in both modes, every observation scan usesidx_observations_observer, one per observer. It also checks that packets is read only by onepackets_pkeyprobe per heard packet, under a nested loop, and never scanned whole. The fixture adds 100,000 unheard packets, so a whole-table scan of packets costs what it does at full size. With the old text the observation check fails, because the old text uses a single scan for both observers.The plan corpus has eight new entries for the statement: busy, quiet, mixed and unknown pairs; 24 h and 7 d windows; all sites, one site, a seven-site region and an empty region.
idx_observations_observer, capped at 1,000 rows. The unknown entry uses a fixed non-zero id, derived from a hash, that a resolver confirms matches no observer.idx_observations_observerwith anobserver_id =condition on observations, andpackets_pkeywith apacket_hash = COALESCE(...)condition on packets.t_end. Fixture and local baselines are included, the local ones also fromt_end.idx_packets_route_type). Ont_endit fails every entry on the packets condition.go build,go vet,gofmt,go test ./..., the race step, every Postgres step inci.yml, the fixture tier (684 runs, every planted regression detected), the planguard tests, gen-policy (no change: the statement stays custom-plan) and the swagger diff all pass.