Skip to content

planner: replace outer-join null-reject evaluation with structural proof | tidb-test=pr/2724 - #67129

Merged
winoros merged 25 commits into
pingcap:masterfrom
winoros:new-null-reject
Apr 15, 2026
Merged

planner: replace outer-join null-reject evaluation with structural proof | tidb-test=pr/2724#67129
winoros merged 25 commits into
pingcap:masterfrom
winoros:new-null-reject

Conversation

@winoros

@winoros winoros commented Mar 18, 2026

Copy link
Copy Markdown
Member

What problem does this PR solve?

Issue Number: close #60080, close #61327, close #59162, close #60081, close #66825, close #58793, close #67048, close #60370, close #66824, close #67330, close #67373

Problem Summary:

What changed and how does it work?

Rewrite the current null-reject judging function with a well-designed algorithm.

You can view the doc for details.

The algorithm provides a reliable way to perform the check and address issues from the old buggy version.

Check List

Tests

  • Unit test
  • Integration test
  • Manual test (add detailed scripts or steps below)
  • No need to test
    • I checked and no code files have been changed.

Side effects

  • Performance regression: Consumes more CPU
  • Performance regression: Consumes more Memory
  • Breaking backward compatibility

Documentation

  • Affects user behaviors
  • Contains syntax changes
  • Contains variable changes
  • Contains experimental features
  • Changes MySQL compatibility

Release note

Please refer to Release Notes Language Style Guide to write a quality release note.

None

Summary by CodeRabbit

  • Bug Fixes

    • Corrected outer-to-inner join conversions for NULL-valued predicates, IN-lists with NULLs, and NULL-safe comparisons; improved prepared-plan cache behavior for these cases.
  • Improvements

    • Added a conservative null-rejection proof framework to strengthen optimizer decisions around NULLs and predicate folding.
  • Documentation

    • New design document describing the null-rejection framework and proof rules.
  • Tests

    • Expanded unit and integration test coverage, including registry snapshot checks and many edge-case scenarios.

@ti-chi-bot ti-chi-bot Bot added do-not-merge/needs-triage-completed release-note-none Denotes a PR that doesn't merit a release note. labels Mar 18, 2026
@pantheon-ai

pantheon-ai Bot commented Mar 18, 2026

Copy link
Copy Markdown

Review Complete

Findings: 0 issues
Posted: 0
Duplicates/Skipped: 0

ℹ️ Learn more details on Pantheon AI.

@ti-chi-bot ti-chi-bot Bot added sig/planner SIG: Planner size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. labels Mar 18, 2026
@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a proof-based null-rejection framework for outer-join simplification, a builtin function registry accessor, migrates join logic to use the new proof API, refines partition-constraint checks, and expands unit and integration tests exercising null-handling, IN/IS/COALESCE semantics, and plan-cache interactions.

Changes

Cohort / File(s) Summary
Design & Docs
docs/design/2026-03-16-null-reject-proof-framework.md
New design doc specifying proof properties (nonTrue, mustNull), composition rules, builtin/test classification, folding rules, implementation mapping, tests, examples and unresolved questions.
Builtin Function Registry
pkg/expression/builtin_registry.go, pkg/expression/BUILD.bazel
Add RegisteredBuiltinFunctionNames() returning sorted builtin names; include new file in expression build targets.
Null-Rejection Core
pkg/planner/util/null_misc.go, pkg/planner/util/null_misc_test.go, pkg/planner/util/BUILD.bazel
Replace ad-hoc null-reject checks with recursive proof machinery, add nullified-constant folding, builtin classification registries, keep public IsNullRejected(...) API; add unit tests and test that registry snapshot matches expectations.
Logical Join Integration
pkg/planner/core/operator/logicalop/logical_join.go, pkg/planner/core/operator/logicalop/BUILD.bazel
Remove local null-rejection helpers and imports; call util.IsNullRejected(...) instead and drop deprecated helper functions; update BUILD deps.
Partition Index Checks
pkg/planner/core/partidx/check_constraint.go
Refine AlwaysMeetConstraints() to require a single IS NOT NULL pre-predicate and add checkIsNullRejected() helper to detect filters that reject nulls for a target column.
Planner Case Tests / Expected Plans
pkg/planner/core/casetest/rule/rule_outer2inner_test.go, pkg/planner/core/casetest/rule/testdata/outer2inner_out.json, pkg/planner/core/casetest/rule/testdata/outer2inner_xut.json
Add null-reject smoke tests and edge cases; update expected plan JSONs to reflect Selection-wrapped IN predicates and left-outer join labeling / plan shape changes.
Integration Tests & Results
tests/integrationtest/t/planner/core/plan_cache.test, tests/integrationtest/r/planner/core/plan_cache.result, tests/integrationtest/t/planner/core/rule_outer2inner.test, tests/integrationtest/r/planner/core/rule_outer2inner.result
Add prepared-plan-cache tests exercising parameterized LEFT JOINs and null-handling (Issue #67048); expand outer-to-inner rule integration tests covering multiple linked issues and richer scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Planner
    participant LogicalJoin
    participant NullProof as util.IsNullRejected
    participant Builtins as BuiltinRegistry

    Client->>Planner: build/optimize query with outer join
    Planner->>LogicalJoin: evaluate join predicate / push-down
    LogicalJoin->>NullProof: IsNullRejected(ctx, innerSchema, predicate, ...)
    NullProof->>Builtins: query function classifications (preserve/reject tests)
    Builtins-->>NullProof: classification results
    NullProof-->>LogicalJoin: proof result (nonTrue/mustNull)
    LogicalJoin-->>Planner: decide outer->inner conversion or keep left join
    Planner-->>Client: return plan / execute
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

ok-to-test, approved, lgtm

Suggested reviewers

  • guo-shaoge
  • qw4990
  • AilinKid

Poem

🐰 A little rabbit hops in code so bright,
Proving when NULLs should hide or take flight.
Joins simplified by logical art,
Functions cataloged, tests do their part.
Hip-hop—nulls conquered, plans now light!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Code changes implement structural null-reject proof framework addressing multiple null-handling bugs (issues #60080, #61327, #59162, #60081, #66825, #58793, #67048, #60370) through algorithm rewrite.
Out of Scope Changes check ✅ Passed All changes are tightly scoped to null-reject proof framework: design doc, builtin registry, null-rejection logic, test updates, and planner integration—no extraneous modifications detected.
Title check ✅ Passed The title directly and specifically describes the main change: replacing outer-join null-reject evaluation with a structural proof mechanism.
Description check ✅ Passed PR description includes issue numbers, a clear problem summary mentioning a rewritten algorithm, and specifies unit tests are included.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (3)
pkg/expression/builtin_registry.go (1)

19-20: Make the doc comment describe the returned value.

This API returns a sorted snapshot of builtin function names, not the registry itself.

✏️ Suggested wording
-// RegisteredBuiltinFunctionNames returns the sorted builtin function registry.
+// RegisteredBuiltinFunctionNames returns a sorted snapshot of registered builtin function names.
As per coding guidelines, keep exported-symbol doc comments, and prefer semantic constraints over name restatement.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/expression/builtin_registry.go` around lines 19 - 20, The doc comment for
RegisteredBuiltinFunctionNames should describe the returned value rather than
restate the function name; update the comment above the
RegisteredBuiltinFunctionNames function to say it returns a sorted snapshot
(slice) of builtin function names (e.g., "RegisteredBuiltinFunctionNames returns
a sorted snapshot of builtin function names.") so the comment documents the
semantic constraint of the return value.
tests/integrationtest/t/planner/core/plan_cache.test (1)

1560-1589: Reset tidb_enable_prepared_plan_cache after this case.

Line 1560 forces the session switch on, but nothing restores it. That makes later plan-cache cases depend on this block's side effect if the default or surrounding setup changes.

🧪 Minimal isolation fix
 set `@v`=2;
 execute stmt67048b using `@v`;
 select @@last_plan_from_cache;
+set tidb_enable_prepared_plan_cache=DEFAULT;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integrationtest/t/planner/core/plan_cache.test` around lines 1560 -
1589, This test turns on the session variable tidb_enable_prepared_plan_cache
but never restores it; wrap the case so you save the original
tidb_enable_prepared_plan_cache value, set it to 1 for the prepared-plan checks
(the block that prepares stmt67048a and stmt67048b and executes them), then
restore the saved value at the end (use a defer/teardown or explicit reset) so
subsequent tests are not affected.
docs/design/2026-03-16-null-reject-proof-framework.md (1)

3-5: Fill in the TBD placeholders before merging.

The author, discussion PR, and tracking issue fields are still TBD. These should be populated to maintain proper documentation provenance.

📝 Proposed fix
 # Design: Structural Null-Reject Proof under Three-Valued Logic

-- Author(s): TBD
-- Discussion PR: TBD
-- Tracking Issue: TBD
+- Author(s): winoros
+- Discussion PR: https://github.com/pingcap/tidb/pull/67129
+- Tracking Issue: `#60737`, `#60080`, `#61327`, `#59162`, `#60081`, `#66825`, `#58793`, `#67048`, `#60370`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/design/2026-03-16-null-reject-proof-framework.md` around lines 3 - 5,
Populate the TBD placeholders in the document header by replacing "Author(s):
TBD", "Discussion PR: TBD", and "Tracking Issue: TBD" with the actual author
name(s), the PR number or link for the discussion, and the tracking issue number
or link respectively; update the fields Author(s), Discussion PR, and Tracking
Issue at the top of docs/design/2026-03-16-null-reject-proof-framework.md so the
metadata is complete before merging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@docs/design/2026-03-16-null-reject-proof-framework.md`:
- Around line 3-5: Populate the TBD placeholders in the document header by
replacing "Author(s): TBD", "Discussion PR: TBD", and "Tracking Issue: TBD" with
the actual author name(s), the PR number or link for the discussion, and the
tracking issue number or link respectively; update the fields Author(s),
Discussion PR, and Tracking Issue at the top of
docs/design/2026-03-16-null-reject-proof-framework.md so the metadata is
complete before merging.

In `@pkg/expression/builtin_registry.go`:
- Around line 19-20: The doc comment for RegisteredBuiltinFunctionNames should
describe the returned value rather than restate the function name; update the
comment above the RegisteredBuiltinFunctionNames function to say it returns a
sorted snapshot (slice) of builtin function names (e.g.,
"RegisteredBuiltinFunctionNames returns a sorted snapshot of builtin function
names.") so the comment documents the semantic constraint of the return value.

In `@tests/integrationtest/t/planner/core/plan_cache.test`:
- Around line 1560-1589: This test turns on the session variable
tidb_enable_prepared_plan_cache but never restores it; wrap the case so you save
the original tidb_enable_prepared_plan_cache value, set it to 1 for the
prepared-plan checks (the block that prepares stmt67048a and stmt67048b and
executes them), then restore the saved value at the end (use a defer/teardown or
explicit reset) so subsequent tests are not affected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2b19fcf1-6da1-4369-b276-c89929edc36d

📥 Commits

Reviewing files that changed from the base of the PR and between 1871ffa and 38ba330.

📒 Files selected for processing (16)
  • docs/design/2026-03-16-null-reject-proof-framework.md
  • pkg/expression/BUILD.bazel
  • pkg/expression/builtin_registry.go
  • pkg/planner/core/casetest/rule/rule_outer2inner_test.go
  • pkg/planner/core/casetest/rule/testdata/outer2inner_out.json
  • pkg/planner/core/casetest/rule/testdata/outer2inner_xut.json
  • pkg/planner/core/operator/logicalop/BUILD.bazel
  • pkg/planner/core/operator/logicalop/logical_join.go
  • pkg/planner/core/partidx/check_constraint.go
  • pkg/planner/util/BUILD.bazel
  • pkg/planner/util/null_misc.go
  • pkg/planner/util/null_misc_test.go
  • tests/integrationtest/r/planner/core/plan_cache.result
  • tests/integrationtest/r/planner/core/rule_outer2inner.result
  • tests/integrationtest/t/planner/core/plan_cache.test
  • tests/integrationtest/t/planner/core/rule_outer2inner.test
💤 Files with no reviewable changes (1)
  • pkg/planner/core/operator/logicalop/BUILD.bazel

@codecov

codecov Bot commented Mar 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.00000% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.4678%. Comparing base (997e75c) to head (cec6306).
⚠️ Report is 27 commits behind head on master.

Additional details and impacted files
@@               Coverage Diff                @@
##             master     #67129        +/-   ##
================================================
- Coverage   77.5871%   77.4678%   -0.1194%     
================================================
  Files          1981       1966        -15     
  Lines        547950     551914      +3964     
================================================
+ Hits         425139     427556      +2417     
- Misses       122001     124344      +2343     
+ Partials        810         14       -796     
Flag Coverage Δ
integration 41.0784% <79.8995%> (+6.7387%) ⬆️
unit 76.7121% <90.0000%> (+0.3788%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
dumpling 61.5065% <ø> (+0.0901%) ⬆️
parser ∅ <ø> (∅)
br 49.9220% <ø> (-10.5092%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pantheon-ai pantheon-ai 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.

✅ Code looks good. No issues found.

@guo-shaoge
guo-shaoge self-requested a review March 19, 2026 09:53
# Conflicts:
#	pkg/planner/core/casetest/rule/testdata/outer2inner_out.json
#	pkg/planner/core/casetest/rule/testdata/outer2inner_xut.json

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
pkg/planner/util/null_misc_test.go (1)

168-182: Consider also validating nullRejectNullPreservingFunctions keys.

The test validates that nullRejectRejectNullTests keys are present in the builtin registry, but does not perform the same check for nullRejectNullPreservingFunctions. If a null-preserving function name is misspelled or removed from the registry, it would silently fail to match during proof evaluation.

♻️ Suggested enhancement
 	for name := range nullRejectRejectNullTests {
 		require.Contains(t, names, name)
 	}
+
+	for name := range nullRejectNullPreservingFunctions {
+		require.Contains(t, names, name)
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/planner/util/null_misc_test.go` around lines 168 - 182,
TestNullRejectBuiltinRegistrySnapshot currently verifies that keys from
nullRejectRejectNullTests exist in the registered builtin names but misses
validating nullRejectNullPreservingFunctions; update the test
(TestNullRejectBuiltinRegistrySnapshot) to also iterate over the keys of
nullRejectNullPreservingFunctions and assert each name is present in
expression.RegisteredBuiltinFunctionNames() (use require.Contains like the
existing loop) so misspelled or removed null-preserving functions are caught.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@pkg/planner/util/null_misc_test.go`:
- Around line 168-182: TestNullRejectBuiltinRegistrySnapshot currently verifies
that keys from nullRejectRejectNullTests exist in the registered builtin names
but misses validating nullRejectNullPreservingFunctions; update the test
(TestNullRejectBuiltinRegistrySnapshot) to also iterate over the keys of
nullRejectNullPreservingFunctions and assert each name is present in
expression.RegisteredBuiltinFunctionNames() (use require.Contains like the
existing loop) so misspelled or removed null-preserving functions are caught.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 531226e1-3368-442e-9b8c-4c4946bb489b

📥 Commits

Reviewing files that changed from the base of the PR and between 38ba330 and 767d317.

📒 Files selected for processing (6)
  • docs/design/2026-03-16-null-reject-proof-framework.md
  • pkg/expression/builtin_registry.go
  • pkg/planner/util/null_misc.go
  • pkg/planner/util/null_misc_test.go
  • tests/integrationtest/r/planner/core/plan_cache.result
  • tests/integrationtest/t/planner/core/plan_cache.test
✅ Files skipped from review due to trivial changes (2)
  • pkg/expression/builtin_registry.go
  • tests/integrationtest/t/planner/core/plan_cache.test
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/planner/util/null_misc.go

Comment thread pkg/planner/util/null_misc.go Outdated

// nullRejectNullPreservingFunctions lists builtins that return NULL when any
// argument is NULL. See the file-level comment for how this drives the proof.
var nullRejectNullPreservingFunctions = map[string]struct{}{

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.

How do we determine whether a function should belong to this list?
For example, in PostgreSQL each function has a dedicated property (proisstrict) to indicate whether it is strict (i.e., returns NULL when any input is NULL).

I’m asking this because when I had Codex review this PR, it pointed out that ast.ConvertTz, ast.RegexpLike, ast.RegexpInStr, ast.RegexpSubstr, and ast.RegexpReplace do not seem to belong in nullRejectNullPreservingFunctions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think the best way to do it is to have agents conduct a multi-round check.

I'll check these functions later.

// `innerSchema` is replaced with SQL NULL.
func IsNullRejected(ctx base.PlanContext, innerSchema *expression.Schema, predicate expression.Expression,
skipPlanCacheCheck bool) bool {
_ = skipPlanCacheCheck // kept for API compatibility; the new proof does not use EvaluateExprWithNull

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.

Should we update the API directly here?
Do we need to maintain backward compatibility, or should that be handled separately later?

@ti-chi-bot ti-chi-bot Bot added the needs-1-more-lgtm Indicates a PR needs 1 more LGTM. label Mar 24, 2026
Comment thread pkg/planner/util/null_misc.go
Comment thread pkg/planner/util/null_misc.go
Comment thread pkg/planner/util/null_misc.go
Comment thread pkg/planner/util/null_misc.go
premal pushed a commit to premal/tidb that referenced this pull request Apr 30, 2026
@winoros

winoros commented May 29, 2026

Copy link
Copy Markdown
Member Author

/cherry-pick release-8.5

@ti-chi-bot

Copy link
Copy Markdown
Member

@winoros: new pull request created to branch release-8.5: #68744.
But this PR has conflicts, please resolve them!

Details

In response to this:

/cherry-pick release-8.5

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the ti-community-infra/tichi repository.

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

Labels

approved lgtm ok-to-test Indicates a PR is ready to be tested. release-note-none Denotes a PR that doesn't merit a release note. sig/planner SIG: Planner size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

7 participants