Skip to content

fix(cost): refuse the index path when the pool cannot cover the demand - #383

Closed
pavanmanishd wants to merge 1 commit into
timescale:mainfrom
pavanmanishd:fix/scan-pool-demand-cost
Closed

pavanmanishd wants to merge 1 commit into
timescale:mainfrom
pavanmanishd:fix/scan-pool-demand-cost

Conversation

@pavanmanishd

@pavanmanishd pavanmanishd commented Oct 18, 2026 •

Copy link
Copy Markdown

Fixes #187.

A distance-ordered scan elects its whole top-k before it returns the first row and work_mem bounds that pool, so when the planner can see that a query will pull more rows than the pool holds it planned the index anyway — and the scan reported end-of-scan early, leaving the executor short of the LIMIT it asked for. The cost model now refuses that path for a demand it actually knows, so seqscan + sort answers the query: on a 2M-row table a LIMIT 50000 that returned about a seventh of the rows now returns all of them.

Only a bound the planner knows counts — a LIMIT it can fold, or a tuple fraction that resolved to an absolute count. A fractional one (a cursor, a generic plan's LIMIT $1, a correlated LIMIT) and having no bound to attribute at all are guesses the executor sizes from the plan tree at rescan, so the index is kept there; prism.query_limit is the deliberate exception, since capping rows is what that lever is for. docs/tuning.md says all of this, including the shapes that still return fewer rows than asked for.

A distance-ordered scan elects its whole top-k before it returns the first
row, and work_mem bounds that pool. When the planner can see that a query
will pull more rows than the pool holds -- a LIMIT the pool cannot cover --
it planned the index anyway: the scan reported end-of-scan early and the
executor came up short, silently truncating the result (issue timescale#187).

Price such a path as unable, the way the access method's costestimate
refuses a path with no order-by, so seqscan + sort answers the query and
the result is complete.

Only a demand the planner knows counts: a LIMIT it can fold, or a tuple
fraction that resolved to an absolute count, including the outer LIMIT over
the subquery holding the ORDER BY, which set_subquery_pathlist hands down.
A fractional tuple fraction -- a cursor, a generic plan's LIMIT $1, an
unfoldable LIMIT on a correlated scan -- and a query with no bound to
attribute are guesses, and the executor sizes those from the plan tree at
rescan; refusing them would trade a correct index plan for a full sort.

prism.query_limit is the deliberate exception: a query whose pool is
bounded by it keeps the index path, since capping the rows a query returns
is what that lever is for. The exemption compares the cap against the
resolved pool with <=, because the sizing floor can raise a cap set below
it.

On a 2M x 128d cosine table with work_mem = 4MB, LIMIT 50000 returned 7,182
rows before and all 50,000 after, planned as seqscan + sort.
@mostafa mostafa closed this Oct 21, 2026
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.

scan: silent truncation when demand exceeds the candidate pool (count() over index scan returns k)

2 participants