Summary
PR #260 added a 3-column MCV so Postgres estimates token selectivity correctly, which fixed the high-match shapes dramatically (Encounter?status=finished p95 27s → 54ms). But accurate statistics also make the planner commit harder to the ordered-index early-stop plan (idx_resources_search, walk resources in last_updated DESC and stop at LIMIT 21). That plan is optimal when many rows match and pathological when almost none do — it then walks the entire resource type before it can return an empty page.
Net effect in the benchmark is positive (p95 1.7x better, throughput +23%), but two shapes regressed and this is the next thing to attack.
Spun off from #224 / PR #260.
Evidence (real benchmark dataset, 656,737 Observations)
| shape |
p95 before this PR |
p95 after |
Observation?code |
5,747 ms |
30,018 ms |
Encounter?date |
2,582 ms |
17,757 ms |
Both are shapes where a randomly-chosen value from the benchmark's pool matches few or no rows, so the ordered idx_resources_search scan walks the whole Observation / Encounter partition looking for 21 hits that are sparse or absent. With the old (wrong) estimate the planner instead built the match set first and probed by PK, which is faster in the sparse case.
This is the genuine two-sided nature of the ordered index: it is the 149x median win for the common case and a cliff for the sparse case. The fix is to get the planner to switch strategies based on actual selectivity of the specific value, not just the parameter.
Directions to investigate
idx_search_* covering/INCLUDE resource_id so the sparse pre-filter path (materialize matching ids, probe resources by PK) is cheap enough that the planner prefers it when selectivity is high — removing the incentive to gamble on the ordered scan.
- Per-value statistics: the regression is value-dependent (
code=8302-2 matches many, code=non-existent matches none). A wider MCV on value_token_code / value_date may already let the planner tell these apart — measure whether bumping SET STATISTICS further separates them.
- A
LIMIT-aware cost tweak or a planner hint is a last resort; prefer fixing the estimate.
- Confirm the same effect on
value_quantity_value range shapes.
Method note (important)
These plans cannot be A/B'd on a laptop-sized replica: the dominant cost is random heap I/O on a ~10M-row search_index, which does not exist when the table fits in cache. A small replica ranked PR #260's winning fixes backwards. Use the EXPLAIN (ANALYZE, BUFFERS) capture step added to .github/workflows/fhir-benchmark.yml (.github/scripts/pg-search-plans.sql) and read Buffers: shared read=, not Execution Time.
Acceptance
Summary
PR #260 added a 3-column MCV so Postgres estimates token selectivity correctly, which fixed the high-match shapes dramatically (
Encounter?status=finishedp95 27s → 54ms). But accurate statistics also make the planner commit harder to the ordered-index early-stop plan (idx_resources_search, walkresourcesinlast_updated DESCand stop at LIMIT 21). That plan is optimal when many rows match and pathological when almost none do — it then walks the entire resource type before it can return an empty page.Net effect in the benchmark is positive (p95 1.7x better, throughput +23%), but two shapes regressed and this is the next thing to attack.
Spun off from #224 / PR #260.
Evidence (real benchmark dataset, 656,737 Observations)
Observation?codeEncounter?dateBoth are shapes where a randomly-chosen value from the benchmark's pool matches few or no rows, so the ordered
idx_resources_searchscan walks the wholeObservation/Encounterpartition looking for 21 hits that are sparse or absent. With the old (wrong) estimate the planner instead built the match set first and probed by PK, which is faster in the sparse case.This is the genuine two-sided nature of the ordered index: it is the 149x median win for the common case and a cliff for the sparse case. The fix is to get the planner to switch strategies based on actual selectivity of the specific value, not just the parameter.
Directions to investigate
idx_search_*covering/INCLUDE resource_idso the sparse pre-filter path (materialize matching ids, proberesourcesby PK) is cheap enough that the planner prefers it when selectivity is high — removing the incentive to gamble on the ordered scan.code=8302-2matches many,code=non-existentmatches none). A wider MCV onvalue_token_code/value_datemay already let the planner tell these apart — measure whether bumpingSET STATISTICSfurther separates them.LIMIT-aware cost tweak or a planner hint is a last resort; prefer fixing the estimate.value_quantity_valuerange shapes.Method note (important)
These plans cannot be A/B'd on a laptop-sized replica: the dominant cost is random heap I/O on a ~10M-row
search_index, which does not exist when the table fits in cache. A small replica ranked PR #260's winning fixes backwards. Use theEXPLAIN (ANALYZE, BUFFERS)capture step added to.github/workflows/fhir-benchmark.yml(.github/scripts/pg-search-plans.sql) and readBuffers: shared read=, notExecution Time.Acceptance
Observation?codeandEncounter?datep95 back under a few seconds at 30 VUs, without regressing the high-match shapes PR perf(postgres): fix search collapse at volume (#224) #260 fixed (status,class,category, quantity).