Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions src/columnar_customscan.c
Original file line number Diff line number Diff line change
Expand Up @@ -555,8 +555,45 @@ ColumnarSetRelPathlist(PlannerInfo *root, RelOptInfo *rel, Index rti,
cpath->path.parallel_safe = false;
cpath->path.parallel_workers = 0;
cpath->path.rows = rel->rows;
cpath->path.startup_cost = seqpath ? seqpath->startup_cost : 0;
cpath->path.total_cost = seqpath ? seqpath->total_cost : rel->rows;

/*
* Inherit the sequential scan's cost when there is one, since this path
* reads the same relation and the comparison against every other path
* should turn on what it does differently, not on a different cost model.
*
* When there is none, cost the work: every page read once and the
* restriction evaluated on every row. The fallback used to be rel->rows,
* which is an output row count and not a cost at all.
*
* That fallback was not the rare case it looks like. add_path frees a path
* it finds dominated, so the seqscan is gone from rel->pathlist by the time
* this hook runs exactly when some index path beat it on both cost and
* pathkeys -- which is to say, precisely on the selective lookups where
* using the index matters most. Before ANALYZE the row estimate was a large
* default and the resulting "cost" was accidentally large enough to lose. Once
* ANALYZE supplied real statistics (#159), a selective predicate estimated
* one row, so a full scan of the table was priced at 1.00, beat an index scan
* of the same query costed at 174.29, and the planner stopped using the index.
* That is issue #171: a point lookup went from 23.75 ms to 1251.88 ms the
* moment the table had statistics. Regression-tested in test/analyze_stats.sh.
*/
if (seqpath != NULL)
{
cpath->path.startup_cost = seqpath->startup_cost;
cpath->path.total_cost = seqpath->total_cost;
}
else
{
QualCost qcost = rel->baserestrictcost;
double ntuples = (rel->tuples >= 0) ? rel->tuples : rel->rows;
Cost run;

run = seq_page_cost * (double) rel->pages;
run += (cpu_tuple_cost + qcost.per_tuple) * ntuples;

cpath->path.startup_cost = qcost.startup;
cpath->path.total_cost = qcost.startup + run;
}
cpath->path.pathkeys = NIL;
cpath->flags = 0;
cpath->custom_paths = NIL;
Expand Down
45 changes: 45 additions & 0 deletions test/analyze_stats.sh
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,49 @@ check "a selective equality is estimated from the data, not the 0.5% default" \
'BEGIN { r = e / t; print (r > 0.2 && r < 0.5) ? "yes" : "no (" e " of " t ")" }')" \
"yes"

# --- 5. statistics must not cost the index out of a point lookup (#171) --------

# Collecting statistics made one query shape dramatically worse. The custom scan
# inherits the seqscan's cost, but add_path frees a dominated path, so when an
# index path beats the seqscan there is no seqscan left to inherit from -- and the
# fallback was rel->rows, an output row count used as a cost. With real statistics
# a selective predicate estimates one row, so a full scan was priced at 1.00 and
# won. Measured on a 6M-row table: 23.75 ms before ANALYZE, 1251.88 ms after.
#
# Both checks below are behavioural rather than assertions about a cost number,
# so neither can be satisfied by a differently-shaped wrong cost.

psql_run "CREATE INDEX IF NOT EXISTS as_c_id ON as_c (id); ANALYZE as_c;" >/dev/null

target=$((ROWS / 2))
plan="$(q "EXPLAIN (COSTS off) SELECT * FROM as_c WHERE id = $target;")"
echo "-- point-lookup plan after ANALYZE: $(printf '%s' "$plan" | head -1)"

check "a point lookup on an indexed column still uses the index after ANALYZE" \
"$( grep -qE 'Index (Only )?Scan|Bitmap Heap Scan' <<<"$plan" \
&& echo yes || echo "no ($(printf '%s' "$plan" | head -1))")" \
"yes"

# The consequence, independent of plan shape: having the index available must
# actually save work. The comparison is against the same query with the index
# denied rather than against a fixed number of rows, so it stays discriminating
# whatever the row group size is -- a fixed threshold would only separate the two
# for as long as a group happens to be larger than it.
discarded() { # extra SET statements -> rows the filter threw away
local ea
ea="$(q "$1 EXPLAIN (ANALYZE, COSTS off, TIMING off, SUMMARY off)
SELECT * FROM as_c WHERE id = $target;")"
awk 'match($0, /Rows Removed by Filter: [0-9]+/) {
s = substr($0, RSTART, RLENGTH); sub(/[^0-9]+/, "", s); t += s } END { print t + 0 }' <<<"$ea"
}

with_index="$(discarded "")"
no_index="$(discarded "SET enable_indexscan = off; SET enable_bitmapscan = off;")"
echo "-- rows discarded to return one row: ${with_index} with the index, ${no_index} without it"

check "having an index available saves the point lookup real work" \
"$(awk -v a="$with_index" -v b="$no_index" \
'BEGIN { print (b > 0 && a < b / 2) ? "yes" : "no (" a " with the index, " b " without)" }')" \
"yes"

pgc_summary