Skip to content

Fix prepared fulltext score threshold; push prepared distance bounds - #27765

Merged
mergify[bot] merged 8 commits into
matrixorigin:mainfrom
cpegeric:bug_27400
Aug 28, 2026
Merged

Fix prepared fulltext score threshold; push prepared distance bounds#27765
mergify[bot] merged 8 commits into
matrixorigin:mainfrom
cpegeric:bug_27400

Conversation

@cpegeric

@cpegeric cpegeric commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this?

  • API-change
  • BUG
  • Improvement
  • Documentation
  • Feature
  • Test and CI
  • Code Refactoring

Which issue(s) this PR fixes:

issue #27400

What this PR does / why we need it:

Fix prepared fulltext score threshold; push prepared distance bounds

Fixes #27400

What was broken

A MATCH() can only be answered from the index. So the planner refuses the rewrite — error 20105 — whenever a document the index never returns (relevance 0) could satisfy the predicate. collectDrivingFullTextMatches ran that test against a literal threshold. A ? is still a ParamRef at PREPARE time, so nothing was harvested and EXECUTE failed even for an ordinary > 0:

MATCH(body) AGAINST(? IN NATURAL LANGUAGE MODE) > 0   -- 1,2,3
MATCH(body) AGAINST(? IN NATURAL LANGUAGE MODE) > ?   -- HY000 / 20105

Re-planning at EXECUTE doesn't help: rebuildPreparePlan builds from the parse tree, where the marker is still a marker.

Fix 1 — carry the test to the engine

fulltextRuntimeScoreGuard takes the actual operator and the actual threshold off the predicate and asks the same question about a relevance of 0 — 0 <op> ? — as an optional trailing table-function argument. Both fulltext_index_scan and fulltext2_search raise 20105 on it when true. Nothing is attached when every threshold is a literal, so unprepared queries carry no runtime cost.

A runtime threshold now behaves exactly like the same literal at every value:

predicate literal parameter
> 0 1 2 3 1 2 3
>= 0.001 1 2 3 1 2 3
>= 0 20105 20105
> -1 20105 20105
c > MATCH(...) 20105 20105

That closes the whole shape, not just the reported value — >= ? works now too.

Conjuncts are ANDed, not ORed. MATCH > ? AND MATCH < ? is satisfiable at relevance 0 only when both halves are; ORing refused (0,5), a query the identical literals are accepted for. A literal conjunct that already excludes relevance 0 makes the rewrite safe whatever the runtime half is, and emits no guard at all.

Fix 2 — prepared distance bounds (ivfflat)

Same root cause, different symptom. mergeUpper/LowerBound demanded a numeric literal, so a prepared bound lost the range pushdown:

literal  dist < 5  ->  Vector Index Scan on idx
param    dist < ?  ->  Join
                         ->  Table Scan (Filter: l2_distance(...) < ...)
                         ->  Vector Index Scan on idx

Same rows, a base-table scan and a join more work. The consumer already handled it — vectorscan constant-folds the bound and fails loudly if it doesn't reduce to a number — only the planner refused to create one.

The distinction that matters

Not literal vs non-literal, but execution-constant vs per-row. A ? folds once before a scan; a column reference never can. Shared as isExecutionConstantExpr in utils.go.

This is load-bearing: a first version of fix 2 tested merely "not a literal" and broke TestGetDistRangeFromFiltersKeepsTightestBound, the #25639 guard. A per-row bound still stays a residual filter, and a second same-side bound still falls back in both orders, so no bound is ever dropped.

Scope notes

  • HNSW is unaffectedgetDistRangeFromFilters has one caller, apply_indices_ivfflat.go.
  • A prepared query vector already worked — only the threshold lost its pushdown.
  • Binary COM_STMT_PREPARE isn't reachable from mo-tester; it shares this plan path and the issue reports the same failure for both.

Testing

Unit coverage of new code:

function before after
checkFulltextZeroRelevanceGuard 13.3% 100%
zeroRelevanceSatisfies 100%
fulltextRuntimeScoreGuard 15.9% 81.5%
mergeUpperBound 75.0% 95.0%
mergeLowerBound 65.0% 85.0%
isExecutionConstantExpr 90.0%

Remainder is unreachable binder-error branches.

BVTfulltext_prepare_score_threshold.sql (95 statements) and vector_ivf_prepare_dist_range.sql (37). The vector case asserts the plan shape, since the pushdown is invisible in results.

Mutation-checked, each independently:

mutation caught by
OR instead of AND unit test and BVT
guard dropped entirely BVT — 4 failures, > -1/>= 0, both index types
any non-literal treated as execution-constant unit test and the pre-existing #25639 guard

Baseline comparison on the fulltext, fulltext2 and vector suites: only the two new cases differ (12 and 1 baseline failures respectively); every other file identical. fulltext2 is byte-identical, confirming its existing failures are pre-existing type-metadata drift.

Commits

cpegeric and others added 2 commits August 27, 2026 16:04
…atrixorigin#27400)

A MATCH can only be answered from the index, so the planner refuses the rewrite --
20105 -- whenever a document the index never returns (relevance 0) could satisfy
the predicate. collectDrivingFullTextMatches ran that test on a LITERAL threshold.
A '?' is still a ParamRef at PREPARE, so nothing was harvested, and EXECUTE failed
even for an ordinary `> 0`:

  MATCH(body) AGAINST(? IN NATURAL LANGUAGE MODE) > 0   -> 1,2,3
  MATCH(body) AGAINST(? IN NATURAL LANGUAGE MODE) > ?   -> 20105

Re-planning at EXECUTE does not help: rebuildPreparePlan builds from the parse
tree, where the marker is still a marker. So carry the test to the engine instead.
fulltextRuntimeScoreGuard takes the ACTUAL operator and the ACTUAL threshold off
the predicate and asks the same question about a relevance of 0 -- `0 <op> ?` --
as an optional trailing table-function argument, which both fulltext_index_scan
and fulltext2_search raise 20105 on when it is true. Nothing is attached when
every threshold is a literal, so an unprepared query carries no runtime cost.

A runtime threshold now behaves exactly like the same literal at every value:
`> ?` and `>= ?` work where the literal works, and still raise 20105 where it does
(`> -1`, `>= 0`). That closes the whole shape rather than the reported value.

The conjuncts are ANDed, not ORed. `MATCH > ? AND MATCH < ?` is satisfiable at
relevance 0 only when BOTH halves are, so ORing refused (0,5) -- a query the
identical literals are accepted for. A literal conjunct that already excludes
relevance 0 makes the rewrite safe whatever the runtime half is, and emits no
guard at all.

isExecutionConstantExpr is shared out to utils.go because the distinction that
matters is execution-constant vs per-row, not literal vs non-literal: a '?' folds
once before a scan, a column reference never can.

Covered by unit tests (planner guard 81.5%, engine guard 100%,
zeroRelevanceSatisfies 100%) and a BVT matrix over classic FULLTEXT and FULLTEXT2:
term-only, threshold-only and both parameterized, positive/zero/negative bounds,
CAST(? AS DOUBLE), both operand orders, conjunctions, repeated execution, reuse
after a refusal, with the literal forms as controls. Binary-protocol
COM_STMT_PREPARE is not reachable from mo-tester; it shares this plan path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getDistRangeFromFilters peels `distfn(v, lit) <op> K` off the filter list and
pushes it onto the vector index reader, so the query collapses to one Vector
Index Scan. mergeUpperBound/mergeLowerBound demanded a numeric LITERAL, so a
prepared bound lost the pushdown entirely:

  literal  dist < 5  ->  Vector Index Scan on idx
  param    dist < ?  ->  Join
                           ->  Table Scan (Filter: l2_distance(...) < ...)
                           ->  Vector Index Scan on idx

Same rows, a base-table scan and a join more work. The consumer side already
handles it -- vectorscan constant-folds the bound before the scan and fails
loudly if it does not reduce to a number -- so only the planner refused to
create one.

Accept a non-literal bound only into an EMPTY slot, where there is no tightness
to compare, and only when it is constant for the whole execution. A per-row
expression (a column reference) must still stay a residual filter: it has no
single value to fold. That is the matrixorigin#25639 case, and
TestGetDistRangeFromFiltersKeepsTightestBound caught a first version of this
change that tested merely "not a literal" -- the reason the condition is
isExecutionConstantExpr and not GetLit() == nil.

A second bound on the same side still falls back to a residual filter once
either side is non-literal, in both orders, so no bound is ever dropped.

Covered by unit tests (mergeUpperBound 95%, mergeLowerBound 85%) over all four
operators in both operand roles, plus a BVT case that asserts the PLAN SHAPE --
the pushdown is invisible in the results, so a results-only case would pass just
as happily with it gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@mergify mergify Bot added the kind/bug Something isn't working label Aug 27, 2026
@cpegeric cpegeric changed the title in .md format please Fix prepared fulltext score threshold; push prepared distance bounds Aug 27, 2026
@matrix-meow matrix-meow added the size/XL Denotes a PR that changes [1000, 1999] lines label Aug 27, 2026

@XuPeng-SH XuPeng-SH left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I found two correctness regressions in the IVFFLAT part. The FULLTEXT runtime-guard design and its classic/FULLTEXT2 paths look sound, but the distance-bound optimization is not safe to merge yet.

[P1] Preserve NULL semantics for a prepared distance bound

mergeUpperBound / mergeLowerBound now peel a ParamRef out of the SQL filter and make the index reader its sole consumer. A prepared parameter can legally be NULL, though. At EXECUTE, parameter replacement turns the bound into a NULL literal; constant folding visits IndexReaderParam.DistRange but leaves that NULL literal as NULL, and vectorDistanceBound then rejects it with IVF distance bound did not fold to a numeric literal.

For example:

prepare s from 'select id from t
  where l2_distance(v, ''[1,1,1]'') < ?
  order by l2_distance(v, ''[1,1,1]'') limit 2';
set @d = null;
execute s using @d;

The predicate is UNKNOWN for every row, so the correct result is an empty set; before this optimization it remained a residual filter and produced that result. This PR turns the valid query into an error. Please represent a runtime-NULL range as an empty result (or retain an equivalent runtime predicate/guard), and cover non-NULL -> NULL -> non-NULL reuse for both lower and upper bounds.

Relevant flow: apply_indices_vector.go:950-965/990-1005 -> rule/constant_fold.go:107-110 -> vectorindex/ivfflat/relation_search.go:444-452.

[P1] Do not classify round(?, per_row_column) as execution-constant

isExecutionConstantExpr follows only fn.Args[0] for every whitelisted wrapper. ROUND has a value-affecting second argument, so round(?, u.lim) varies per row even though its first argument is a parameter. The helper currently returns true, the distance predicate is peeled into one global DistRange, constant folding cannot eliminate the ColRef, and the reader fails with the same “did not fold” error. Before this PR the predicate stayed residual and executed normally.

I added a temporary counterexample on exact head 18a62532e013a1ba5cdc2e47ba821c69affbdb41 asserting that

l2_distance(v, '[1,2,3]') < round(?, per_row_digits)

stays residual; it failed because the PR removed the filter and installed the expression as UpperBound. Please validate all value-affecting wrapper arguments (and wrapper arity), not only argument 0, and add this case to TestIsExecutionConstantExpr / the distance-range tests.

Focused existing planner and table-function tests pass on the exact head. These counterexamples are outside the current BVT matrix, which uses only non-NULL scalar bounds and a bare per-row column.

@aptend aptend left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I independently reproduced two blocking IVFFLAT regressions on exact head 18a62532e013a1ba5cdc2e47ba821c69affbdb41: a prepared NULL distance bound turns valid three-valued SQL into an execution error, and round(?, per_row_column) is misclassified as one execution-wide value. I also found a fulltext literal/parameter parity gap outside the current matrix. The existing focused and full package tests pass, as do focused -race -count=3 runs, but temporary counterexample tests fail for all three cases.

Validation performed after make thirdparties and make cgo: full pkg/sql/plan, pkg/sql/colexec/table_function, and pkg/vectorindex/ivfflat tests; focused race runs; exact-head counterexamples. The temporary tests were removed afterward.

Comment thread pkg/sql/plan/apply_indices_vector.go
Comment thread pkg/sql/plan/utils.go Outdated
Comment thread pkg/sql/plan/apply_indices_fulltext.go Outdated
Two correctness regressions from the prepared-distance-bound pushdown, both
reported in review of matrixorigin#27400.

1. A NULL prepared bound turned a valid query into an error.

A parameter may legally bind NULL. `distance < NULL` is UNKNOWN for every row, so
the answer is the empty set -- which is what the residual filter produced before
the bound was peeled. After peeling, the range is the predicate's ONLY consumer,
and vectorDistanceBound rejected the folded NULL literal with "IVF distance bound
did not fold to a numeric literal". Reproduced on the unfixed head:

  set @d = null; execute s using @d;
  -> ERROR 20301 invalid input: IVF distance bound did not fold to a numeric literal

vectorDistanceBound now separates "evaluated to NULL" from "is not a number at
all", and the range reports the empty set for the first while still failing loudly
on the second. That mirrors how a NULL query vector is already handled one layer
up, where vectorscan's RequestAt returns no request rather than an error.

2. isExecutionConstantExpr accepted a per-row wrapper argument.

It followed fn.Args[0] for every whitelisted wrapper, so `round(?, per_row_col)`
was reported constant even though ROUND's second argument is value-affecting and
varies per row. The bound was then peeled into one scan-wide range that cannot
fold. The walk is now per-wrapper: CAST checks only argument 0 (argument 1 is the
target TYPE, not a value), while ROUND/FLOOR/CEIL check every argument, and an
expression must actually contain a parameter to qualify.

Worth recording for the reviewer: this one is latent, not live. MO's ROUND refuses
a non-constant second argument outright -- on an indexed table AND on a table with
no index -- so `round(?, per_row_col)` never reaches the rule from SQL. It is
reachable only by hand-building the plan expression, which is what the reported
counterexample does. Fixed anyway: the helper is shared with the fulltext guard,
and a classification this rule gets wrong is exactly the matrixorigin#25639 failure mode.

Covered by TestVectorDistanceBoundNull, the ROUND cases in
TestIsExecutionConstantExpr, and BVT coverage of non-NULL -> NULL -> non-NULL reuse
on both bounds plus both-NULL. Mutation-checked: removing the NULL detection fails
the BVT on exactly the two NULL statements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runtime branch harvested a MATCH threshold for ANY comparison operator once
the bound was a parameter, while the literal test below it accepts only `>` and
`>=`. `MATCH(...) < c` is refused for every literal c -- a relevance-0 document
satisfies it whenever c > 0, and nothing satisfies it when c <= 0 -- so:

  MATCH(body) AGAINST('fox') < 0   -> 20105
  MATCH(body) AGAINST(?)    < ?    -> executes and returns the empty set at ? = 0

The parameter form gained an evaluation path the literal form does not have, and
the comment claiming the two behave identically at every value was simply wrong.

Split the two decisions instead of conflating them. The VALUE is what a runtime
threshold hides, and the engine guard re-checks it -- that is why `> ?` is
harvested even though `> -1` is refused. The OPERATOR is known at plan time on
both paths, so the runtime branch is now gated on `>` / `>=` as well, and `< ?`
raises 20105 exactly as `< 0` does. No shape the literal path accepts is lost.

`=` and `<>` were already refused on both paths (they fall to the default arm) and
stay refused. They are conservatively refused rather than wrong: `MATCH = 0.5` and
`MATCH <> 0` ARE membership-implying and could drive the index. That is a missed
optimisation, uniform across both paths, and out of scope here.

TestDrivingHarvestLiteralParameterParity states the invariant at the level it
actually holds: not "the same answer for every value" -- the first version of the
test asserted that and failed on the intended `> -1` case -- but "no operator is
harvestable via a parameter unless some literal makes it harvestable". It covers
all six operators in both operand orders, and includes `=` / `<>` precisely because
adding one path without the other is how this appeared. BVT adds the `< ?` and
`<= ?` cases against their literal twins.

Mutation-checked: removing the operator gate fails the parity test on `<`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@aptend aptend left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deep re-review of exact head 5fe815e. I read the complete review/comment/reply/thread history, compared the increment since 438a602, and rechecked the full planner, FULLTEXT/FULLTEXT2, and IVFFLAT diff. The prior IVFFLAT NULL-bound, execution-constant-wrapper, literal/parameter eligibility, and FULLTEXT2 guard-order blockers are materially closed. The new commit evaluates the runtime zero-relevance guard before both engines’ NULL-pattern paths and covers safe→unsafe operator reuse; direct engine tests reproduce the previous counterexample and now return the established 20105 refusal. The existing NULL/reuse, range-intersection, wrapper-arity, and operator-parity behavior remains intact. No new allocation, retained state, goroutine, lock, or wait edge is added. Exact-head full tests for pkg/sql/plan, pkg/sql/colexec/table_function, and pkg/vectorindex/ivfflat pass; the focused race matrix passes three runs; go vet and diff checks pass. Two exact-head CI jobs are still queued/running, with no failure reported. No blocking correctness, lifecycle, concurrency, performance, or compatibility issue remains.

@mergify

mergify Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

  • Entered queue2026-08-28 12:54 UTC · Rule: main · triggered by rule Automatic queue on approval for main
  • Checks passed · in-place
  • Merged2026-08-28 13:51 UTC · at 1a6b5a9a8aa0dec8797c2f5b7154256b5bf20256 · squash

This pull request spent 56 minutes 34 seconds in the queue, including 56 minutes 10 seconds running CI.

Required conditions to merge
  • #review-threads-unresolved = 0 [🛡 GitHub branch protection]
  • github-review-approved [🛡 GitHub branch protection]
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone CI / UT Test on Ubuntu/x86
    • check-neutral = Matrixone CI / UT Test on Ubuntu/x86
    • check-skipped = Matrixone CI / UT Test on Ubuntu/x86
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone CI / SCA Test on Linux/arm64
    • check-neutral = Matrixone CI / SCA Test on Linux/arm64
    • check-skipped = Matrixone CI / SCA Test on Linux/arm64
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Compose CI / multi cn e2e bvt test docker compose(PROXY)
    • check-neutral = Matrixone Compose CI / multi cn e2e bvt test docker compose(PROXY)
    • check-skipped = Matrixone Compose CI / multi cn e2e bvt test docker compose(PROXY)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Utils CI / Coverage
    • check-neutral = Matrixone Utils CI / Coverage
    • check-skipped = Matrixone Utils CI / Coverage
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone UT Coverage / UT Coverage on Ubuntu/x86
    • check-neutral = Matrixone UT Coverage / UT Coverage on Ubuntu/x86
    • check-skipped = Matrixone UT Coverage / UT Coverage on Ubuntu/x86
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Standlone CI / multi CN e2e BVT Test on Linux/x64(COMPOSE, PESSIMISTIC)
    • check-neutral = Matrixone Standlone CI / multi CN e2e BVT Test on Linux/x64(COMPOSE, PESSIMISTIC)
    • check-skipped = Matrixone Standlone CI / multi CN e2e BVT Test on Linux/x64(COMPOSE, PESSIMISTIC)

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

Labels

kind/bug Something isn't working size/XL Denotes a PR that changes [1000, 1999] lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Prepared fulltext score threshold parameter prevents MATCH index rewrite

5 participants