Review: COALESCE selectivity estimation (commitfest-6960)

code-review · generated from canonical Markdown

Type
code-review
Source
postgresql-master
Slug
commitfest-6960-review
Tags
coalescecode-reviewcoverageselfuncs
Updated
2026-09-01T06:10:29.342754+00:00
Verdict / Summaryneeds-review
Front matter
schema: review-report-v2
source: postgresql-master
slug: commitfest-6960-review
target: commitfest-6960 COALESCE selectivity estimation in src/backend/utils/adt/selfuncs.c (uncommitted staged change vs HEAD a20d3e35)
base: a20d3e35f2c00938c5329cbf570160184f4f0a69
head: a20d3e35f2c00938c5329cbf570160184f4f0a69
diff_sha: 9e1c7ac320c0085792d033848aefaaf1a62451bf
verdict: needs-review
blocker_counts:
  verified: 0
  unverified: 0

Review Report — CommitFest 6960: COALESCE Selectivity Estimation

Patch: COALESCE selectivity estimation in src/backend/utils/adt/selfuncs.c (+434 / -30 lines) Author: Egor Savelev (Tantor Labs) — pgsql-hackers thread 650175 Verdict: needs-review Blockers: 0 verified, 0 unverified

Executive Summary

The patch adds COALESCE selectivity estimation to selfuncs.c, decomposing COALESCE(...) = ... predicates and hash keys into reach-probability-weighted sums of per-branch selectivities. The approach is architecturally sound and the common cases (COALESCE of Vars, COALESCE with a trailing const) are estimated well. The build is clean with --enable-cassert.

Two MAJOR correctness defects are verified and must be fixed before commit:

  1. PR-1 (neqsel <> bypass): COALESCE(a,1) <> 5 returns the un-negated eq estimate (~10187 rows) instead of the negated estimate (~90000 rows) — off by ~9x. This is corroborated live in pgsql-hackers thread 650175 (Ilya Evdokimov, 2026-08-17). Repair validated: COALESCE(a,1) <> 5 → 89703 rows after fix.
  1. PR-3 (is_eqjoin dispatch broken under DirectFunctionCall): try_coalesce_eq detects eqjoin via fcinfo->flinfo->fn_oid, but DirectFunctionCall*Coll sets flinfo=NULL, so nested-COALESCE join sub-pairs are dispatched to eqsel instead of eqjoinsel. Edge case reproduced (5000 → 1000 rows after fix). Common cases bail harmlessly.

One MAJOR process gap (PR-4): no regression tests added. A proposal-only test suite (13 scenarios) was produced and validated.

Version discrepancy: the frozen worktree is v3 (2026-08-07), not the latest v4 (2026-08-17). v4 moved the leading-Const check before the loop and changed the early-stop threshold to a product — neither change is in the frozen diff.

Findings Summary

IDSeverityClassVerificationLocationMechanism
PR-1MAJORcorrectnessverifiedselfuncs.c:595neqsel <> bypasses negation
PR-2MAJORmaintainabilitycannot-verifyselfuncs.c:4737order-dependent nd_mix (Max vs +=)
PR-3MAJORcorrectnessverifiedselfuncs.c:442is_eqjoin detection broken under DirectFunctionCall
PR-4MAJORtestsverifiedselfuncs.c:595no regression tests added
PR-5MINORcorrectnesscannot-verifyselfuncs.c:2699SEMI/ANTI bypass eqjoinsel_semi (not demonstrably wrong)
PR-6MINORcorrectnesscannot-verifyselfuncs.c:342leading NULL Const rejected (unreachable via const-folding)
PR-7MINORcorrectnesscannot-verifyselfuncs.c:366strict exprType false-negatives for binary-compatible types
PR-8MINORmaintainabilitycannot-verifyselfuncs.c:4762rows/tuples proxy only for i==0
SEC-2MINORmaintainabilityverifiedselfuncs.c:413stanullfrac read lacks policy comment
PR-9NITmaintainabilitycannot-verifyselfuncs.c:537copyObject leak (PG filter: not real)
PR-10NITmaintainabilitycannot-verifyselfuncs.c:436no check_stack_depth (bounded by parser)
COV-1MINORtestsverifiedselfuncs.c:487right-COALESCE-only path untested
COV-2NITtestsverifiedselfuncs.c:4787nd_mix>nbuckets branch untested (needs injection point)

Verification tally: verified = 6 (PR-1, PR-3, PR-4, SEC-2, COV-1, COV-2); cannot-verify = 7; refuted = 0.

Verification Summary

PR-1

— neqsel (<>) bypasses negation — VERIFIED (repro)

Runtime evidence (live cluster, coverage-instrumented build, port 55555):

  • a <> 5 → rows=90067 (correct, ~90%)
  • COALESCE(a, 1) <> 5 → rows=10187 (WRONG — returns the eq estimate, not the negated estimate)
  • a = 5 → rows=9933; COALESCE(a, 1) = 5 → rows=9933 (correct)

The <> path returns the eq selectivity because try_coalesce_eq runs before eqsel_internal's negate handling (1.0 - eq_sel - nullfrac). Corroborates pgsql-hackers thread 650175 msg #9 (Ilya Evdokimov, 2026-08-17): 10260 vs 90057.

Repair validated (port 55556, repaired cluster): COALESCE(a,1) <> 5 → rows=89703 (was 10187). The fix threads a bool negate flag into try_coalesce_eq and applies 1.0 - acc_selec - coal_nullfrac for the restriction negation case.

PR-3

— is_eqjoin detection broken under DirectFunctionCall — VERIFIED (repro)

DirectFunctionCall4Coll/5Coll set fcinfo->flinfo = NULL (fmgr.c:867,894). The inner recursive try_coalesce_eq sees is_eqjoin=false and dispatches nested-COALESCE join sub-pairs via eqsel instead of eqjoinsel.

  • Common case bails harmlessly: for COALESCE(Var,Var)=COALESCE(Var,Var), the inner match_coalesce_join_side returns false (plain Var is not CoalesceExpr), so normal eqjoinsel runs. Live: rows=1250 (correct).
  • Edge case reproduced: with expressional indexes on nested COALESCE, COALESCE(COALESCE(a,b),c)=COALESCE(COALESCE(x,y),z) → rows=5000 (default, wrong dispatch) vs rows=1000 (repaired, correct join estimate).

Repair: replace flinfo->fn_oid check with is_eqjoin = (fcinfo->nargs == 5). nargs is preserved under DirectFunctionCall. Validated: common join unchanged, nested-COALESCE-join fixed.

PR-4

— No tests added — VERIFIED (diff-check)

git diff HEAD --name-only shows only selfuncs.c; no test files. A proposal-only test suite (selectivity_coalesce.sql, 13 scenarios) was produced by pg-test-author, apply-checked clean, and loaded against the running cluster with all scenarios producing deterministic output.

SEC-2

— stanullfrac policy comment — VERIFIED (static-check)

The ungated stanullfrac read at line 413 is consistent with established PG policy (var_eq_const:661-671: "note we allow use of nullfrac regardless of security check"). The comment is missing; comment-only fix applied in the combined repair patch.

COV-1

— right-COALESCE-only path untested — VERIFIED (verified-by-coverage)

selfuncs.c.gcov lines 487-488 show ##### (never executed); the symmetric arm 492-493 is hit 6×. After adding two test scenarios (b = COALESCE(a,b) restriction, t1.b = COALESCE(t2.c,t2.d) join), line 487 went from ##### to 25 hits. Determinism confirmed across two runs. Expected output correction: eq_const_right_coalesce should assert 8 (not the proposed 2).

COV-2

— nd_mix > nbuckets branch untested — VERIFIED (verified-by-coverage)

Line 4787 remains ##### even after the proposed small-work_mem scenario. The branch is not reachable by plain SQL (default branch stats abort at line 4754; analyzed stats yield nbuckets >= nd_mix). Confirms requires-special-harness-or-injection-point classification. A test-only injection point or inflated-ndistinct fixture is required.

Repair Validation Summary

Combined repair patch (PR-1, PR-2, PR-3, PR-8, PR-10, SEC-2)

A single HEAD-bound unified diff covering all verified findings plus cosmetic hardening for cannot-verify findings:

COV-1

repair (test-patch)

  • Two SQL scenarios added; validated on port 55555.
  • eq_const_right_coalesce = 8 (deterministic); join_left_var_right_coalesce = 1000 (deterministic).
  • gcov delta: line 487 ##### → 25 hits. Passed.
COV-2

repair (test-patch) — partial

  • Small-work_mem scenario runs cleanly (1000 rows, deterministic) but does NOT cover line 4787.
  • The branch is not reachable by plain SQL. Partial — requires injection point.

Changed-Code Coverage

Status: measured (fresh disposable counter epoch, coverage-instrumented build) Execution lane: host-disposable (coverage-instrumented PostgreSQL 20devel on port 55555)

Test commands

  1. psql -p 55555 -d postgres -v ON_ERROR_STOP=1 -f test-proposal/selectivity_coalesce.sql (13-scenario proposal suite)
  2. psql -p 55555 -d postgres -v ON_ERROR_STOP=0 -f src/test/regress/sql/stats.sql (existing stats regress test)

Metrics (changed implementation lines only)

MetricValue
Changed executable lines199
Covered changed lines171 (86%)
Uncovered changed lines28 (14%)
Changed branches82
Covered changed branches59 (72%)
Uncovered changed branches23 (28%)
Changed functions8
Covered functions8 (100%)

All 8 changed/added functions are entered at least once (no wholly-dead function).

Uncovered changed lines (28 total)

Coverable-now (4 regions, 9 lines):

Requires-special-harness-or-injection-point (2 regions, 2 lines):

Infeasible-with-reason (11 regions, 17 lines): defensive guards unreachable via normal SQL due to eval_const_expressions folding, parser type resolution, CLAMP_PROBABILITY clamps, or operator arity guarantees. These are design-choice waivers, not findings.

Touched functions diagnostics

FunctionLines (cov/total)Branches (cov/total)Entered
match_coalesce_join_side28/3811/18✅
get_coalesce_prefix_probs14/155/8✅
try_coalesce_eq52/6020/30✅
eqsel_internal (hook)2/21/2✅
eqjoinsel (hook)2/21/2✅
get_variable_mcv_freq9/94/6✅
hash_bucket_stats_coalesce_dispatch42/5212/22✅
estimate_hash_bucket_stats (refactor)22/245/8✅

Non-instrumentable changes

None — the patch is entirely C implementation in src/backend/utils/adt/selfuncs.c.

Residual limitations

pgsql-hackers Thread Ledger

Thread: 650175 ("COALESCE patch") — reconciled Ledger report: commitfest-6960-review-thread-ledger Messages: 9 (2026-06-30 to 2026-08-17)

Key thread events

Finding dispositions

Aggregated Test Plan

  1. selectivity_coalesce.sql (proposal-only, 13 scenarios): controlled-distribution EXPLAIN row-count assertions. Apply-checked clean; loaded against cluster with deterministic output. Expected output requires correction for SEMI/ANTI scenarios (1000/1, not 1/999) and the COV-1 scenario (8, not 2).
  2. COV-1 supplement: b = COALESCE(a,b) restriction + t1.b = COALESCE(t2.c,t2.d) join (validated, rows=8 and 1000).
  3. COV-2 supplement: small-work_mem hash-join scenario (runs cleanly but does not cover line 4787; requires injection point).
  4. PR-1 regression oracle: pr1_neq_estimate ~= 1000 - eq_rows - coalesce_nullfrac (after fix: ~998, was 2).
  5. PR-3 regression oracle: nested-COALESCE-join with expr-index stats → ~1000 (was 5000 default).

Verified-Clean (explicitly cleared)

Provenance / Model Routes

SeatAgentModelHandoff slug
Patch reviewpg-patch-reviewer(routed)commitfest-6960-review-handoff-patch-reviewer
Security reviewpg-security-reviewer(routed)commitfest-6960-review-handoff-security-reviewer
Test authoringpg-test-author(routed)commitfest-6960-review-handoff-test-author
Thread ledgercoordinator—commitfest-6960-review-thread-ledger
Finding verificationpg-finding-verifier(routed)commitfest-6960-review-handoff-verifier
COV repair validationpg-finding-verifier(routed)commitfest-6960-review-handoff-verifier-cov
Changed-code coveragepg-change-coverage-reviewer(routed)commitfest-6960-review-handoff-coverage

Residual Risk

Deterministic Verdict

needs-review — verified correctness MAJORs (PR-1, PR-3) require author action. No BLOCKERs. The test MAJOR (PR-4) is addressed by the proposal-only test suite. The combined repair patch is validated but is a proposal (not applied to the read-only worktree).