Skip to content

Let an ordering index scan hand its ORDER BY value to the target list [cf-7392] - #52

Draft
gburd wants to merge 9 commits into
masterfrom
ntsv
Draft

gburd wants to merge 9 commits into
masterfrom
ntsv

Conversation

@gburd

@gburd gburd commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Purpose (gburd fork): exercise CI + OCR AI review on the CF 7392 series before pgsql-hackers. Targets the fork's master; opened draft.
cf-7392: https://commitfest.postgresql.org/patch/7392/
Thread: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me


An ordering index scan (ORDER BY col <-> const) computes each row's ORDER BY value in the index AM. When the target list needs the same expression, the executor evaluates it again. This series lets the scan hand the value up instead, and teaches the planner to cost and choose plans accordingly.

  • 0001 Let an ordering index scan hand its ORDER BY values to the target list (new optional amcanreturnorderby(opfamily, opcintype, orderbyop); GiST point_ops, SP-GiST quad/kd point ops)
  • 0002 Don't charge an ordering index scan for ORDER BY values it returns
  • 0003 Let an ordering index scan below a join emit its ORDER BY values
  • 0004 Don't charge an index-only scan for index expressions it returns
  • 0005 Let an index-only scan win on the index expressions it returns
  • 0006 Carry index-computed values up through joins and Appends
  • 0007 Carry index-computed values through outer joins, Gather and ordered Appends

The two dev: commits under the series are local tooling and are not part of the patch set.

gburd added 4 commits October 22, 2026 08:38
This isn't a commit that will be submitting for review, it is purely for
local developer tooling while developing this patch set.  Ignore it.
gcc16 (nixpkgs-unstable) links glibc 2.42 but the stable patchedGlibc
in buildInputs put a 2.40 libc.so.6 first in RUNPATH, so every meson
sanity-check binary failed with a GLIBC_PRIVATE symbol lookup error.
The patched glibc only existed to silence the fortify-without-O
warning; hardeningDisable does that without a second glibc.

pg-test-one: set LD_LIBRARY_PATH to the tmp_install lib dir, as the
meson test runner does, so initdb finds libpq.
pg-format: put the in-tree pg_bsd_indent build on PATH.
An Index Scan that orders by an operator (ORDER BY col <-> const)
computes each returned row's ORDER BY value inside the index AM and
reports it in xs_orderbyvals.  If the same expression also appears in
the target list, the executor then evaluates it a second time in the
scan's projection (a third time if the AM asked for a recheck).  For an
expensive distance operator that is wasted work.

The AM contract only requires a non-rechecked distance to sort the same
way as the operator's result, not to equal it, so the executor cannot
substitute it blindly.  Add an optional IndexAmRoutine callback,
amcanreturnorderby(opfamily, opcintype, orderbyop): does the opclass
report, for a non-rechecked leaf entry and that ordering operator,
exactly the value the operator would return?  It takes the operator
because one opfamily can hold several ordering operators.  The planner
keeps the callback in IndexOptInfo, like amcostestimate, and
create_indexscan_plan() asks it for each ORDER BY key, carrying one
bool per key into the IndexScan.

For each qualifying key, setrefs.c replaces a top-level target list
expression that is equal() to that indexorderbyorig expression with
Var(INNER_VAR, i), and nodeIndexscan.c stores the returned row's ORDER
BY values in a virtual slot it installs as the projection's inner tuple
(a scan node has no inner plan, so INNER_VAR is otherwise unused
there).  The values stored are the AM's for a row it reported exact,
the recomputed ones for a row it asked to recheck, a reorder queue
entry's copy for a queued row, and freshly recomputed ones for an
EvalPlanQual test tuple.  Only float4/float8 ORDER BY expressions
qualify, as those are the only types
index_store_float8_orderby_distances() can deliver.

GiST answers yes for point_ops and SP-GiST for
quad_point_ops and kd_point_ops, whose leaf distances call
point_distance(), the function behind <->(point, point).  box_ops
doesn't qualify: its leaf distance is exact arithmetic on the box,
while <->(box, point) treats a point within EPSILON of an edge's line
as on it, so the two can differ in the last digits.  The polygon and
circle opclasses report lower bounds and are not affected, nor is any
other AM or any out-of-core opclass, which all default to no.

EXPLAIN VERBOSE deparses the INNER_VAR back to the ORDER BY expression
and reports "Order By Values Used: N".  Above such a scan, an upper
node's reference to the value now prints with one more pair of
parentheses, the same way any reference to a computed scan column does.

Author: Greg Burd <greg@burd.me>
Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me
The previous commit lets a plain Index Scan return its ORDER BY values
to a target list entry that matches the ORDER BY expression, instead of
evaluating that expression again.  The planner didn't know that.  An
ORDER BY expression is always part of the scan/join target (as a sort
column, if nothing else), and apply_scanjoin_target_to_paths() charges
every path rows * cost(expression) when it applies that target.  For
the Index Scan that cost is now never paid, while Seq Scan + Sort and
Bitmap Heap Scan + Sort really do pay it, once per row.  With a costly
ORDER BY expression the planner therefore overestimated the kNN scan by
up to ~32x and picked a Sort plan that was up to 13x slower.

Teach create_projection_path() and apply_projection_to_path() to cost
the target a path pays for with a new helper, path_target_cost(), which
for a plain IndexScan leaves out top-level target expressions the scan
will take from its ORDER BY values.  The test for "will take" is
index_orderby_returnable() plus orderby_tlist_match(); setrefs.c's
rewrite and create_indexscan_plan() use the same two, so the cost model
and the plan cannot drift apart.  orderby_tlist_match() accepts the
expression equal() to the ORDER BY key, and also its commuted form:
match_clause_to_ordering_op() turns "const <-> col" into "col <-> const"
for the index, so a target entry written the original way is the same
value but not equal() to the key.  An expression that only contains the
ORDER BY expression, e.g. (expr) * 2, isn't rewritten and is still
charged.

Because this makes an IndexPath cheaper relative to its siblings,
apply_scanjoin_target_to_paths() can no longer assume that applying the
target preserves the cost ordering of rel->pathlist and
rel->partial_pathlist, which add_path_precheck() and the
linitial(partial_pathlist) callers rely on.  Re-sort them with a new
sort_pathlist_by_cost(), a stable insertion sort that is a single pass
in the usual already-sorted case.

Nothing changes for an index whose opclass does not pass
amcanreturnorderby, for IndexOnlyScan, for bitmap scans, or for any
query without a returnable ORDER BY expression in the target.

Author: Greg Burd <greg@burd.me>
Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

📜 Change history & discussion (Agora / pg.ddx.io)

All prior-art links are verified. I have what I need.

🧵 Related discussion

🔗 Related commits / prior art

The patch frames itself explicitly against earlier threads (all verified):

  • Direct predecessor request — Why is FOR ORDER BY function getting called when the index is handling ordering? (Chris Cleveland, 2024-05-02). Chris's follow-up is the "expose orderbyval in the projection / report score()" ask this patch implements.
  • Tom Lane's objection in that same thread — reply: an index ordering operator is an optional planner choice, so making the operator's own semantics index-dependent breaks any non-trivial plan. The patch is designed to not trip this (it rewrites a target-list expr only into a reference to the already-computed scan value, like IndexOnlyScan's INDEX_VAR, rather than redefining the operator).
  • Tom's earlier caution on subexpression matching cost — 2023-12-22. Patch addresses it by matching only top-level target-list entries (list length ~1).
  • Matthias's related observation — 2024-05-02.
  • Revived old thread — Comments for lossy ORDER BY are lacking (Greg Burd reply 2026-10-07 to Andres Freund's 2019 thread), hit from the AM-author/recheck angle.
  • Foundational mechanism this builds on: Heikki Linnakangas's Allow GiST distance function to return merely a lower-bound (2015) — introduced xs_recheckorderby, which gates whether substitution is safe (confidence: high; this is the exact-value flag the patch keys on).
  • Older KNN prior art, context only: Alexander Korotkov's KNN-GiST with recheck, Teodor Sigaev's KNNGiST (WIP).

📋 Commitfest

  • CF entry 7392 — "Let an ordering index scan hand its ORDER BY value to the target list", CF 62, status needs-review. Author Greg Burd. (Matches the cf-7392 tag in the PR title.)

🧭 Context for reviewers

  • Core idea: when an Index Scan returns exact ORDER BY values (xs_recheckorderby = false), let the executor substitute those values for a matching top-level target-list expression instead of re-evaluating the operator — modeled on how IndexOnlyScan rewrites indexed columns to INDEX_VAR in setrefs. This is the mechanism behind the PR's Order By Values Used: N EXPLAIN line and the deparse-via-indexorderbyorig ruleutils change.
  • The design is motivated by BM25/corpus-ranked FTS AMs (Greg's out-of-tree pg_fts/pg_weave) where recomputing the per-row score is both expensive and wrong (score is corpus-relative). For GiST-on-points/pg_trgm it's a pure cost win; for AMs whose index value differs from the operator result (pgvector squared-L2, BM25) the substitution is user-observable — a semantics point reviewers should scrutinize against Tom's 2024 objection.
  • Scope is much larger than the title implies: the later commits (6a8705d16bc, 931496fe8a2, 4d6f08bea6a) propagate index-computed values up through joins, outer joins, Gather, and ordered Appends, and 93d3d814c62/5bc31a0bb1a extend the same cost/benefit treatment to index-only scans. Heavy churn in pathnode.c (+980), allpaths.c, joinpath.c, createplan.c, setrefs.c. Reviewers should check costing changes in costsize.c (not charging for returned expressions) and the new amapi.h/gist/spgist AM-interface additions.
  • Verify independently: the GiST regression tests were adjusted to run under extra_float_digits = 0 because recomputed-vs-returned distance printing differs — a tell that the substituted value is not bit-identical to the recomputed one in some AMs.
  • The 5 non-PostgreSQL files (.clangd, .envrc, .gdbinit, flake.*, shell.nix, pg-aliases.sh, glibc patch) and commits dc2505c5ff7/cd316026a74 are the author's local dev scaffolding and must not be part of any upstream submission.

Generated by pg-history via the Agora MCP server (pg.ddx.io).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 OCR found 22 issue(s).

  • 22 inline, 0 in summary

Comment thread .gdbinit
Comment on lines +19 to +20
# Main entry: heap_update
break heapam.c:3210

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breakpoints pinned to absolute source line numbers (heapam.c:3210, 4019, 4024, 4033, 4101, 4147; heapam_indexscan.c:182, 250, 297; indexam.c:299; execIndexing.c:370; pruneheap.c:1287, 1802, 1836, 1863, 2936) silently drift the moment any of those files change. I verified heapam_indexscan.c:182 in this tree is lp = PageGetItemId(page, offnum);, which does not match the comment's claim of "initialize bitmap accumulator from collapsed redirect" -- the line-number breakpoints are already stale. More fundamentally, this entire file is personal developer tooling that should not be committed to a PostgreSQL patch destined for pgsql-hackers/commitfest; it violates the minimal-diff/one-logical-change discipline. Move it to a local (git-ignored) location. If kept, prefer symbol-based breakpoints (e.g. break heap_prune_chain) which survive edits.

Comment thread shell.nix
define print_relcache
set $rel = (Relation)$arg0
if $rel
printf "Relation: %s.%s (OID: %u)\n", $rel->rd_rel->relnamespace, $rel->rd_rel->relname.data, $rel->rd_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This GDB pretty-printer is stale against the current backend. relnamespace is an Oid (confirmed in src/include/catalog/pg_class.h), so the %s format with $rel->rd_rel->relnamespace is wrong and will print garbage / error at runtime -- it should be %u. Also note rd_rel->relname.data is a NameData; the surrounding usage should be checked too. Committing a misleading debugging helper is worse than omitting it.

Comment thread shell.nix
Comment on lines +146 to +148
set $cell = $list->head
set $i = 0
while $cell && $i < $list->length

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

print_list walks $list->head and $cell->next, which is the pre-PG13 linked-list List layout. The modern PostgreSQL List (src/include/nodes/pg_list.h) is array-backed: it has length, max_length, and ListCell *elements with no head/next fields. This helper will fail at runtime against current backend structs. Traverse via $list->elements[$i] instead, or drop the helper.

Comment thread pg-aliases.sh
--dlpath="$bdir/src/test/regress" \
--outputdir="$outdir" \
--temp-instance="$outdir/tmp" \
--port=40099 \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pg-test-one hardcodes --port=40099. A fixed port collides with any concurrently running regress instance or another dev using the same port, causing intermittent test-setup failures. pg_regress normally picks a free port; consider omitting --port or deriving it from the worktree so parallel runs don't clash.

Comment on lines +1104 to +1110
bool
spgcanreturnorderby(Oid opfamily, Oid opcintype, Oid orderbyop)
{
return (get_opfamily_proc(opfamily, opcintype, opcintype,
SPGIST_LEAF_CONSISTENT_PROC) ==
F_SPG_QUAD_LEAF_CONSISTENT &&
get_opcode(orderbyop) == F_POINT_DISTANCE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new spgcanreturnorderby path (ordered index-only scan returning exact <-> distances from quad_point_ops/kd_point_ops leaves) ships with no regression test. The parallel GiST change added extensive coverage in src/test/regress/sql/gist.sql (notably the counting-function test that proves the ORDER BY value is served from the index rather than re-evaluated), but no SP-GiST equivalent was added in this change. Per project policy a user-visible behavior change requires tests; please add SP-GiST coverage exercising this path. Confidence: high.

@@ -1,4 +1,4 @@
#!/usr/bin/perl
#!/usr/bin/env perl

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated, non-minimal change that also breaks a tree-wide convention. Every other Perl script in the PostgreSQL source uses #!/usr/bin/perl (genbki.pl, gen_node_support.pl, copyright.pl, mark_pgdllimport.pl, etc.); switching only pgindent to #!/usr/bin/env perl introduces an inconsistency and is unrelated to the KNN/EvalPlanQual feature this patch implements. This looks like a local (Nix) environment accommodation and should be dropped to keep the diff minimal. Revert to #!/usr/bin/perl. (high confidence)

Suggested change
#!/usr/bin/env perl
#!/usr/bin/perl

Comment on lines +1521 to +1525
if (best == NULL)
return;
accumulate_append_subpath(best, &input.subpaths, NULL,
&input.child_append_relid_sets);
foreach(lp, childrel->pathlist)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The early return here (when a child lacks a qualifying non-partial path) bails out of the entire function, which also skips the parallel Append construction below. The parallel block searches childrel->partial_pathlist -- a different list -- so a child that can emit the extras via a partial path but not a non-partial one will silently lose a valid parallel Append. If the unordered Append can't be built, consider only skipping the unordered/ordered variants (e.g. break out of this block) rather than returning, so the parallel Append is still attempted. (Missed optimization, not a correctness bug; confidence: moderate.)

Comment thread src/backend/optimizer/util/pathnode.c Outdated
Comment on lines +1354 to +1356
if (target == NULL)
target = copy_pathtarget(rel->reltarget);
add_column_to_pathtarget(target, orderby, 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_column_to_pathtarget does not deduplicate, unlike add_new_column_to_pathtarget used in indexonly_path_target(). If two ORDER BY keys yield the same returnable expression value (distinct OpExpr nodes that are equal()), the expression is appended twice, widening the tuple and causing path_target_cost() to credit it twice. Using add_new_column_to_pathtarget() here would keep the two returnable-value paths consistent and avoid the duplicate. (Edge case; confidence: low.)

Comment on lines +1994 to +1999
if ((extra->pgs_mask &
(PGS_NESTLOOP_MATERIALIZE | PGS_CONSIDER_NONPARTIAL)) ==
(PGS_NESTLOOP_MATERIALIZE | PGS_CONSIDER_NONPARTIAL) &&
inner_cheapest_total != NULL)
{
Path *extras_inner = extra->extras_inner;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two guard conditions are identical except for the !ExecMaterializesOutput(inner_cheapest_total->pathtype) sub-check, which is just the existing matpath guard above. The whole block can be folded into the preceding if that computes matpath, reusing the single condition evaluation and avoiding the copy-paste of the (extra->pgs_mask & (...)) == (...) && inner_cheapest_total != NULL test. Keeping two near-identical blocks invites them to drift out of sync.

Comment thread src/backend/optimizer/util/pathnode.c Outdated
Comment on lines +3465 to +3469
ojrelids = bms_difference(path->parent->relids,
bms_union(jpath->outerjoinpath->parent->relids,
jpath->innerjoinpath->parent->relids));
return free_exprs_cost(root, target, join_free_exprs(jpath),
!bms_is_empty(ojrelids));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The join branch allocates two throwaway Bitmapsets (bms_union then bms_difference) on every call solely to test whether any outer-join relids are formed here. path_target_cost() is invoked from cost_index(), create_projection_path() and apply_projection_to_path(), i.e. once per candidate path during projection costing, so this is needless palloc churn on a planner hot path. You can avoid both allocations: the join forms outer joins iff path->parent->relids is a strict superset of the union of the two inputs' relids, which bms_is_subset/membership checks can answer without materializing the difference.

gburd added 5 commits October 22, 2026 09:18
With the previous two commits, an ordering Index Scan returns its ORDER
BY values to a target list entry that matches the ORDER BY expression,
and isn't charged for it.  But that only happens when the scan is the
top of the scan/join tree.  When the scan is below a join, its target
is rel->reltarget, which holds only the Vars the rest of the query
needs; the join computes the ORDER BY expression from those Vars, once
per joined row, though the scan already had the value.  Tom Lane
pointed at this in the 2023 junk-column thread: a fix that only munges
the final targetlist "leaves everything on the table as soon as there's
more than one level of plan involved".

Give such an IndexPath its own PathTarget: a copy of rel->reltarget
with each returnable ORDER BY expression appended, but only if the
query needs that expression above the scan (a top-level entry of the
final targetlist, which includes sort columns).  Most index paths keep
sharing rel->reltarget as before.  The appended expressions cost the
scan nothing (cost_index() now uses path_target_cost()).  Above the
scan, setrefs.c's fix_join_expr() already matches whole non-Var
expressions against the input targetlists before descending into them,
so the join's copy becomes a reference to the scan's output with no
setrefs change; and the join isn't charged for it either, since
path_target_cost() leaves out of a join path's target the entries an
input already emits (looking through Material and Memoize).
use_physical_tlist() must not replace such a scan's targetlist with a
physical one, which has only Vars; index_path_emits_orderby() tells it.

Scope: plain IndexScan paths on plain base relations only.  Not
IndexOnlyScan (no ORDER BY value plumbing), not appendrel children
(Append/MergeAppend build their own child targetlists, and the
MergeAppend-over-kNN case already works through the scan/join target),
and only ORDER BY expressions that pass index_orderby_returnable().
Because the join reads the value from the scan's output, an outer join
nulls it correctly with the rest of that side's columns.

Author: Greg Burd <greg@burd.me>
Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me
Discussion: https://postgr.es/m/2935085.1703806977@sss.pgh.pa.us
An index-only scan on an index with an expression column reads that
expression's value from the index: set_indexonlyscan_references()
replaces a target list entry equal() to a returnable index expression
with a reference to that column.  But the planner still charges the
scan the full cost of evaluating the expression for every row, as if it
computed it.  Heikki Linnakangas showed this in the 2023 junk-column
thread with an index on (i, expensive(i)) and a function of COST
1000000: the index-only scan was estimated at 25 million, like the
Seq Scan that really does evaluate expensive(i).

path_target_cost() now also handles index-only scan paths: a top-level
target expression equal() to a returnable index expression costs
nothing.  cost_index() already goes through path_target_cost(), so the
index-only scan's estimate drops to what it actually does.

This corrects the estimate of an index-only scan path that is already
in the running, e.g. one kept for its pathkeys.  It does not make such a
path win the base relation's add_path() comparison when its only merit
is the cheaper expression: at that point every path of the rel carries
rel->reltarget, which holds only Vars, and the expression is charged to
all of them alike afterwards.  Doing that would mean giving index-only
paths their own targets, as the previous commit does for ordering
Index Scans; that's left for later.

Author: Greg Burd <greg@burd.me>
Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me
Discussion: https://postgr.es/m/356d5811-d8f0-4c29-a601-b61334337b11@iki.fi
The previous commit stopped charging an index-only scan for target
expressions it reads from the index, but that only changes the plan
when the index-only scan path is already in the running for some other
reason, such as useful pathkeys.  Heikki Linnakangas's example, an index
on (i, expensive(i)) and

    SELECT expensive(i) FROM atab ORDER BY expensive(i);

still got a Seq Scan and evaluated expensive() for every row.  When the
rel's paths are compared, they all carry rel->reltarget, which holds
only Vars.  The index-only scan costs more than the seq scan, and the
expression it would have read for free is charged to the winner later.

Give an index-only scan path its own PathTarget: rel->reltarget plus
each returnable index expression that is a top-level entry of the
query's targetlist.  This is the same thing the earlier commit does for
an ordering Index Scan's ORDER BY values.  Teach add_path() and
add_partial_path() to treat base-rel index paths with different targets
as incomparable, the way they treat different pathkeys, so neither
dominates the other on cost alone.  When the scan/join target is
applied, the seq scan pays for evaluating the expression and the
index-only scan doesn't, so the index-only scan wins on cost when it
should.  A path with its own target also needs the projection even
when the scan/join target's exprs equal the reltarget's, since its
target has extra columns.

Heikki's query now plans as Sort over an Index Only Scan: estimated
930 instead of 25000809.  With a function that really costs something,
it ran about 20 times faster.

This is limited to the query's sole base relation, where the value goes
straight to the scan/join target.  Below a join, the join would have to
credit the input for the value and the joinrel's add_path() would have
to keep it; that isn't attempted here.

Author: Greg Burd <greg@burd.me>
Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me
Discussion: https://postgr.es/m/356d5811-d8f0-4c29-a601-b61334337b11@iki.fi
The previous commit lets an index-only scan beat a seq scan when it
reads an expression the query's targetlist needs, but only when the
scan's rel is the only one in the query and the targetlist entry is
that exact expression.  Below a join the scan isn't chosen, and the
expression is computed above it as before.  Nor is it chosen when the
expression is part of a larger one, such as expensive(i) + 1, or when
the table is partitioned.

Generalize the extra values an index path may emit, which 0003 added
for an ordering IndexScan's ORDER BY values and the previous commit for
an index-only scan's expression columns:

* A join emits what its inputs emit, so the value reaches the scan/join
  target from any depth.  fix_join_expr() already matches whole
  expressions in an input's targetlist, and path_target_cost() now
  credits them wherever they appear in an expression, as setrefs.c
  replaces them.  Material and Memoize share their input's target.

* An index-only scan emits a returnable index expression wherever it
  appears in the targetlist, including ORDER BY columns.  Its qpquals
  aren't charged for one either, since set_indexonlyscan_references()
  rewrites them too.

* For a partitioned table whose partitions all have the expression
  index, an unordered Append over child paths that all emit the value
  emits it too.  create_append_child_plan() puts each child's values in
  the Append's order, translating them to the child, and drops any
  extra values a child emits for an Append that doesn't.

* Hash and merge joins look only at each input's cheapest path, which
  rarely emits the value.  Also try each input's cheapest path that
  does, and skip add_path_precheck() for paths over such an input, as
  the precheck can't see the advantage.

add_path() and add_partial_path() compare the extra values like
pathkeys: a path that emits a superset of another's is better, so it
can dominate it, but not the other way round.  The previous commit's
rule kept both whenever the targets differed, which kept, for example,
a seq scan that a cheaper, better-ordered kNN path dominates.

The cost is planning time: every join path over an input with an extra
value is kept alongside the one it would otherwise have replaced.  So a
value is emitted only if it costs more than 10 times cpu_operator_cost
per evaluation, the same test make_sort_input_target() uses to decide
an expression is worth postponing past a sort.  That applies to an
ordering IndexScan's ORDER BY values too, so a plain point distance is
computed again above a join.  Queries without such an expression plan
as before.

A value from the nullable side of an outer join is never used above
it: the query's Vars carry varnullingrels the index's don't, so they
don't match.  Partial paths emit only parallel-safe values.  An ordered
or parallel Append still computes the expression above it.

Author: Greg Burd <greg@burd.me>
Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me
Discussion: https://postgr.es/m/356d5811-d8f0-4c29-a601-b61334337b11@iki.fi
…ppends

The previous commit passes an index's extra values (see
path_extra_exprs()) up through inner joins and an unordered Append, but
stops at three places, where the expression is then computed above them:

* The nullable side of an outer join.  Above the join, the query's copy
  of the expression has the join's bit in its Vars' nullingrels, and the
  scan's copy doesn't.  The join null-extends the scan's value along
  with the Vars, so the two agree on every row exactly when the
  expression yields NULL for NULL inputs.  So for a strict expression,
  join_path_target() now relabels the value with the joinrel's Vars
  (their nullingrels) as it passes it up, and setrefs.c's
  fix_join_expr() matches it against the input's copy at an outer join
  the way it already matches Vars there: equal but for nullingrels, with
  the upper copy's a superset of the input's
  (search_indexed_tlist_for_nulled_expr()); path_target_cost() credits
  it the same way.  A non-strict expression is still computed above
  the join.  An index-only scan matches the query's targetlist without
  nullingrels, so it emits the value in the first place; an ordering
  IndexScan's ORDER BY values still aren't emitted on the nullable side.

* Partial paths, which emitted only parallel-safe values.  A worker
  reads the stored value; nothing evaluates the expression, so a
  parallel-restricted one is fine.  Gather and Gather Merge now pass a
  partial path's values up (gather_target()), and a Gather over the
  cheapest partial path that emits values is considered as well as one
  over the cheapest partial path.

* An ordered Append or MergeAppend, and a parallel Append.
  add_extras_append_path() now also builds those, over child paths that
  emit the values and have the ordering, and over the children's
  partial paths, collecting the children as
  generate_orderedappend_paths() does.

The expression also has to be compared without nullingrels when a scan
decides what to emit.  The new strip_nullingrels() in clauses.c does
that; remove_nulling_relids() can't, as it errors out on the negative
special varnos (ROWID_VAR in a MERGE).

Author: Greg Burd <greg@burd.me>
Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me
@gburd

gburd commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the review. Disposition of the 22 inline comments, as of 931496f:

Fixed

  • pathnode.c:1356 (index_path_orderby_target): uses add_new_column_to_pathtarget() now, so two equal ORDER BY keys don't add the value twice. (0003)
  • joinpath.c:1999: folded the extras Material into the existing matpath guard. (0006)
  • pathnode.c:3469: whether a join forms outer joins is now a bms_num_members() comparison, with no bms_union/bms_difference allocations. (0007)
  • allpaths.c:1525: a child without a qualifying non-partial path now skips only the unordered and ordered Appends, not the parallel one. (0007)
  • gist.sql:537, gist.sql:548: the three parallel EXPLAINs on non-temp tables now all run with enable_seqscan/enable_bitmapscan off. (0007) I'd also checked that the plans hold with relallvisible = 0 on both tables. The Parallel Append child order was the one real flake (cfbot 32-bit, running-server pass), and 0007 already fixes that with different partition sizes.

Already covered

  • spgscan.c:1110 (SP-GiST tests): gist.sql has an SP-GiST block (spg_orderby). It checks Order By Values Used: 1 and value equality for quad_point_ops and kd_point_ops, and that box_ops does not qualify.

Out of scope: dev tooling, not in the patch set
The remaining 16 comments are on .gdbinit, shell.nix, .clangd, .envrc, pg-aliases.sh, the pg_regress.c execlp change and the pgindent shebang. All of these live in the two dev: commits under the series (cd31602, dc2505c). The patches posted to pgsql-hackers are cut from above them (git format-patch dc2505c5ff7..ntsv), so none of these files go to the list.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 OCR found 29 issue(s).

  • 25 inline, 4 in summary (inline capped at 25)

📄 src/test/regress/pg_regress.c (L1246-L1246)

This execl -> execlp change is unrelated to the index/KNN feature this patch implements, and it changes behavior in a way that should not be included. shellprog is set from SHELLPROG, which is $(SHELL) from the Makefile (normally an absolute path such as /bin/sh) or the hardcoded /bin/sh in meson.build. Since the value already contains a slash, execlp buys nothing for the shipped build; its only effect is to make the test harness search PATH for the shell when a bare name is supplied, which is a footgun (PATH-dependent shell resolution in the test runner) and appears to be a local Nix-environment convenience (cf. the added .envrc/flake.nix/shell.nix). Revert this to execl to keep the patch minimal and avoid changing process-spawn semantics. (high confidence)

💡 Suggested change

Before:

		execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

After:

		execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

📄 src/tools/pgindent/pgindent (L1-L1)

This shebang change (#!/usr/bin/perl -> #!/usr/bin/env perl) is unrelated to the feature in this patch and is inconsistent with the rest of the tree: every other Perl script uses #!/usr/bin/perl (e.g. src/tools/copyright.pl, src/tools/git_changelog, src/tools/mark_pgdllimport.pl, src/test/recovery/t/*). Unrelated convenience churn like this is a common rejection reason on -hackers; revert to keep the diff minimal and consistent with project convention. (high confidence)

💡 Suggested change

Before:

#!/usr/bin/env perl

After:

#!/usr/bin/perl

📄 src/backend/optimizer/plan/createplan.c (L1273-L1273)

Internal elog() messages in this file (and the backend generally) start with a lowercase letter; e.g. "failed to assign all NestLoopParams...", "index key does not match...". This new message starts with an uppercase "Append", which is inconsistent with the convention. Suggest lowering the first letter.

💡 Suggested change

Before:

			elog(ERROR, "Append child doesn't emit an Append's extra value");

After:

			elog(ERROR, "Append child doesn't emit an Append's extra value");

📄 src/backend/optimizer/plan/setrefs.c (L3260-L3262)

The intended caching here does not work when itlist->tlist contains no non-Var/non-PHV entries. In that case the build loop leaves itlist->nulled_tles == NIL, so the function returns NULL -- but every subsequent call for the same itlist re-enters the if (itlist->nulled_tles == NIL) block and rescans the entire tlist, calling strip_nullingrels()/expr_nullingrels() again, defeating the comment's promise that "later calls don't strip them again". During a single fix_join_expr() pass over a large targetlist this repeats O(tlist) work per non-Var lookup. Consider a separate "already built" flag (or an empty sentinel) so the scan runs at most once per itlist.

Comment thread .clangd
ParameterNames: true
DeducedTypes: true
CompileFlags:
CompilationDatabase: build/ # Search build/ directory for compile_commands.json

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CompilationDatabase: build/ and the trailing -I../../../../src/include hardcode one developer's directory layout (a build/ dir and a specific nesting depth). This won't match other contributors' setups and reinforces that this is personal, non-portable config that should not be committed.

Comment thread .gdbinit
# =========================================================================

# Main entry: heap_update
break heapam.c:3210

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These absolute source-line breakpoints (e.g. heapam.c:3210, heapam.c:4019, pruneheap.c:1802, indexam.c:299, heapam_indexscan.c:182) are extremely fragile: they drift with every unrelated edit and are tied to a specific in-progress "HOT indexed updates" branch. As a committed artifact they will silently point at the wrong lines. This file should not be in the tree at all (local-only tooling), but at minimum line-number breakpoints are unmaintainable.

Comment thread .gdbinit
# =========================================================================

# Predict augmented tuple size (returns 0 if t_hoff would overflow)
break heap_hot_indexed_tuple_size

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These function-name breakpoints reference symbols that do not exist anywhere in this tree (verified: no heap_hot_indexed_tuple_size, heap_hot_indexed_create_tuple, heap_hot_indexed_serialize_bitmap, etc. in the heap sources). They belong to an unmerged patch series not present in this change, so GDB will fail to set them. This confirms the file is aspirational scaffolding for work that has not landed.

Comment thread pg-aliases.sh
Comment on lines +16 to +17
trash "$build_dir" 2>/dev/null || rm -rf "$build_dir"
mkdir -p "$build_dir"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rm -rf "$build_dir" (and the trash fallback) runs with a variable that can be empty: pg_clean_for_compiler defaults build_dir to $PG_BUILD_DIR, which is unset if the shell hook never ran. With an empty build_dir this becomes rm -rf "" / mkdir -p "" and, combined with the later cd-free usage, is a data-loss footgun. Guard against an empty/unset path before destructive removal.

Suggested change
trash "$build_dir" 2>/dev/null || rm -rf "$build_dir"
mkdir -p "$build_dir"
if [ -z "$build_dir" ]; then
echo "Refusing to clean: build_dir is empty" >&2
return 1
fi
trash "$build_dir" 2>/dev/null || rm -rf "$build_dir"
mkdir -p "$build_dir"

Comment thread pg-aliases.sh
alias pg-full-clean='trash "$PG_BUILD_DIR" "$PG_INSTALL_DIR" 2>/dev/null || rm -rf "$PG_BUILD_DIR" "$PG_INSTALL_DIR"; echo "Build and install directories cleaned"'

# Database management
alias pg-init='trash "$PG_DATA_DIR" 2>/dev/null || rm -rf "$PG_DATA_DIR"; "$PG_INSTALL_DIR/bin/initdb" --debug --no-clean "$PG_DATA_DIR"'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pg-init does rm -rf "$PG_DATA_DIR" with no empty-variable guard. If PG_DATA_DIR is unset (shell hook not sourced), this reduces to rm -rf "" and then initdb into an empty path. Same data-loss footgun as the build-dir cleanup; guard the variable before the destructive call.

Comment on lines +400 to +403
select f.id, gist_join_pt(f.p) <-> point(5,5) as d
from gist_join_fact f join gist_join_dim j on j.id = f.id
order by gist_join_pt(f.p) <-> point(5,5) limit 3;
select nextval('gist_join_cnt') - 1 as calls_during_join;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same portability concern: this join select prints full-precision float8 distances (1.4142135623730951, 2.8284271247461903). The surrounding counting-sequence checks already prove the function isn't re-evaluated; printing the raw distances adds a non-portable row to the expected output. Wrap in set extra_float_digits = 0;/reset or drop the printed distance column.

Comment on lines +625 to +627
* indexorderbytlist counts the replacements actually made (0 if none),
* for EXPLAIN.
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment claims indexorderbytlist is "for EXPLAIN", but the field is actually read in the executor as a behavioral flag: nodeIndexscan.c uses if (node->indexorderbytlist > 0) in ExecInitIndexScan both to force projection setup (ExecInitResultSlot/ExecAssignProjectionInfo) and to allocate iss_OrderBySlot. EXPLAIN is only a secondary consumer. Comments must describe what the code does now; please correct the "for EXPLAIN" characterization so it reflects that this count drives executor projection/slot setup (and is also displayed by EXPLAIN).

Suggested change
* indexorderbytlist counts the replacements actually made (0 if none),
* for EXPLAIN.
*/
* indexorderbytlist counts the replacements actually made (0 if none).
* The executor keys slot/projection setup off it, and EXPLAIN displays it.
*/

Comment on lines +187 to +192
for (i = 0; i < node->iss_NumOrderByKeys; i++)
{
Assert(node->iss_OrderByTypByVals[i]);
oslot->tts_values[i] = vals[i];
oslot->tts_isnull[i] = nulls[i];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop processes all iss_NumOrderByKeys keys and asserts every one is pass-by-value, but that invariant is stronger than what is guaranteed. indexorderbytlist > 0 (and hence a non-NULL iss_OrderBySlot) only guarantees that at least one key was replaced in the targetlist, and index_orderby_returnable() restricts only the replaced keys to float8/float4. The non-replaced keys in a mixed multi-key ORDER BY can be any type the AM's ordering operators return -- index_store_float8_orderby_distances() explicitly handles non-float ORDER BY types. For such a key with a non-by-value type, Assert(node->iss_OrderByTypByVals[i]) fires in assert-enabled builds (i.e. all development/buildfarm builds), and in the reorder-queue caller (line ~286) the subsequently-stored Datum is a pointer that reorderqueue_pop() immediately pfrees. Core GiST/SP-GiST only expose point distance (float8), so this is not reachable with in-tree AMs, but it is reachable for third-party amcanreturnorderby AMs. Since the executor has no per-key returnable flag, consider only storing/asserting the keys that were actually replaced, or skipping non-by-value keys rather than asserting on them.

Comment on lines +3553 to +3560
if (path_emits_extras(subpath))
{
rows = compute_gather_rows(subpath);
add_path(rel, (Path *)
create_gather_path(root, rel, subpath,
subpath->pathtarget, NULL, rowsp));
break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop gathers only the first partial path that emits extras, then breaks. When partial paths emit different extra-value subsets (e.g. two indexes each returning a distinct returnable expression the query needs), only one subset is ever gathered; the other extras-emitting partial path is silently dropped and the optimization is lost for it. Since add_path() treats distinct extra subsets as non-dominating (compare_path_extras returns PATHKEYS_DIFFERENT), there can legitimately be more than one such partial path worth gathering. Consider gathering each distinct extras-emitting partial path rather than breaking after the first. Low severity: affects plan quality, not correctness.

Comment on lines +628 to +629
List *indexorderbyexact;
int indexorderbytlist;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indexorderbyexact is planner scratch state: it is produced in createplan.c and consumed only by replace_orderby_tlist_refs() in setrefs.c (during set_plan_references, the final planning pass). It is never read by the executor or EXPLAIN (confirmed: no references under src/backend/executor or src/backend/commands). As a plain List * field of the IndexScan Node struct it will nonetheless be emitted by gen_node_support.pl into copy/out/read/equal for every index-scan plan, so it is copied and serialized (e.g. for parallel workers / stored plans) despite being dead after setrefs. Consider not persisting it in the plan node -- either pass it transiently to replace_orderby_tlist_refs(), or, if it must stay a field, annotate it to avoid serialization. Note: read_write_ignore alone is not permitted here because IndexScan is readable; it would need read_as(NIL) as well. (moderate confidence)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant