planner: nil pointer dereference when a derived table is cross-joined with a LEFT JOIN
- Dominant language
- Go
- Stars
- 40.5k
- Forks
- 6.2k
- PR merge metrics
- PR metrics pending
Description
## Bug Report
Please answer these questions before submitting your issue. Thanks!
A query fails with a nil pointer dereference when a derived table (or view) is
cross-joined with a `LEFT JOIN` over the same two tables. Setting
`tidb_opt_enable_advanced_join_reorder = 0` makes the same query succeed and return the
empty result set. That variable defaults to `ON`, so the failure happens on a default
configuration.
### 1. Minimal reproduce step (Required)
```sql
CREATE TABLE t0(c0 INT);
CREATE TABLE t1(c0 INT);
SELECT t0.c0 FROM (SELECT 1 AS c0 FROM t0, t1) v0, t1 LEFT JOIN t0 ON t1.c0 = t0.c0;
```
Also reproduces through a view instead of the derived table:
```sql
CREATE VIEW v0(c0) AS SELECT 1 FROM t0, t1;
SELECT t0.c0 FROM v0, t1 LEFT JOIN t0 ON t1.c0 = t0.c0;
```
Deterministic (3/3 runs) on empty tables — no rows needed.
Each condition below was checked on both the derived-table and the view form; removing any
one of them makes the query succeed:
- the derived table / view references **both** `t0` and `t1` — referencing only `t0`, only
`t1`, or an unrelated third table succeeds
- the join is an **outer** join — `RIGHT JOIN` reproduces it too, replacing `LEFT JOIN`
with `JOIN` succeeds
- a **specific column** is projected — `SELECT *` succeeds
- `tidb_opt_enable_advanced_join_reorder` is `ON` (the default) — with it `OFF` the query
succeeds
### 2. What did you expect to see? (Required)
The empty result set, which is what `tidb_opt_enable_advanced_join_reorder = 0` returns for
the same query on the same data.
### 3. What did you see instead (Required)
```
ERROR 1105 (HY000): runtime error: invalid memory address or nil pointer dereference
```
The server survives — the panic is recovered and the statement fails. Repeating the query
three times returns the same error each time and the server stays up.
Stack (trimmed to the relevant frames)
```
github.com/pingcap/tidb/pkg/util.GetRecoverError
github.com/pingcap/tidb/pkg/planner.optimizeNoCache.func1
runtime.gopanic
runtime.panicmem
runtime.sigpanic
github.com/pingcap/tidb/pkg/planner/core.logicalOptimize.func1 <-- faulting frame
github.com/pingcap/tidb/pkg/planner/core.logicalOptimize
github.com/pingcap/tidb/pkg/planner/core.VolcanoOptimize
github.com/pingcap/tidb/pkg/planner/core.doOptimize
github.com/pingcap/tidb/pkg/planner/core.DoOptimize
github.com/pingcap/tidb/pkg/planner.buildAndOptimizeLogicalPlanRound
github.com/pingcap/tidb/pkg/planner.optimize
github.com/pingcap/tidb/pkg/planner.Optimize
github.com/pingcap/tidb/pkg/executor.(*Compiler).Compile
github.com/pingcap/tidb/pkg/session.(*session).ExecuteStmt
```
### 4. What is your TiDB version? (Required)
```
Release Version: v9.0.0-beta.2.pre-2012-g955fd6550b
Edition: Community
Git Commit Hash: 955fd6550b68511ea5712290ac20592330ed38de
UTC Build Time: 2026-07-22 08:11:15
GoVersion: go1.25.10
Store: unistore
Kernel Type: Classic
```
Only tested on this build. `release-8.5` carries the same
`TryCreateCartesianCheckResult` guard but does not have the
`tidb_opt_enable_advanced_join_reorder` variable, so its enabling condition differs and I
have not tried to reproduce there.
### Possible cause
Reading the session context before the rule loop instead of inside the deferred closure
(see 2. below) replaces the panic with `failed to construct bushy tree: no valid join edge
found`, which points at 1.
**1. Join reorder fails to stitch the remaining groups together.**
That error string occurs at one place only:
`pkg/planner/core/joinorder/join_order.go:940`
```go
if !checkResult.Connected() {
checkResult = detector.TryCreateCartesianCheckResult(left, right)
if checkResult == nil {
return nil, errors.New("failed to construct bushy tree: no valid join edge found")
}
}
```
`TryCreateCartesianCheckResult` returns nil whenever the group is not all-inner-join:
`pkg/planner/core/joinorder/conflict_detector.go:167`
```go
func (d *ConflictDetector) TryCreateCartesianCheckResult(left, right *Node) *CheckConnectionResult {
if !d.allInnerJoin {
return nil
}
```
`allInnerJoin` is set per join (`join_order.go:202`, `join.JoinType == base.InnerJoin`) and
AND-combined as groups merge (`join_order.go:84`,
`g.allInnerJoin = g.allInnerJoin && other.allInnerJoin`).
I could not pin down what makes this path reachable, and an outer join alone is not
enough. The variants that succeed still contain a left outer join in their plans — only
the `LEFT JOIN` → `JOIN` variant removes it:
| variant | join types in the plan | result |
| --- | --- | --- |
| derived table on `t0` only | 1 inner, 1 left outer | succeeds |
| derived table on an unrelated `t2` | 1 inner, 1 left outer | succeeds |
| `SELECT *` | 2 inner, 1 left outer | succeeds |
| `LEFT JOIN` → `JOIN` | 3 inner | succeeds |
So whether this cartesian stitch is needed at all appears to depend on how the join group
is built for the failing shape, which I have not determined. The conditions in step 1 are
measured, not explained.
**2. The error is reported as a nil pointer dereference.**
`JoinReOrderSolver.Optimize` returns the framework's result unchanged, including the nil
plan that accompanies the error:
`pkg/planner/core/rule_join_reorder.go:316`
```go
if p.SCtx().GetSessionVars().TiDBOptEnableAdvancedJoinReorder {
p, err := joinorder.Optimize(p)
return p, false, err
}
```
`logicalOptimize` assigns that into `logic` and returns, while its deferred timing hook
reads the session context back off `logic`:
`pkg/planner/core/optimizer.go:1076`
```go
defer func(begin time.Time) {
logic.SCtx().GetSessionVars().DurationOptimizer.LogicalOpt = time.Since(begin)
}(time.Now())
```
`pkg/planner/core/optimizer.go:1089`
```go
logic, planChanged, err = rule.Optimize(ctx, logic)
if err != nil {
return nil, err
}
```
The same shape exists elsewhere: of the 29 rule files defining `Optimize`, three return a
nil plan together with their error in the function body — `rule_column_pruning.go`,
`rule_aggregation_elimination.go`, `rule_aggregation_skew_rewrite.go`.
Contributor guide
Research direction
Start by running the derived-table and view reproductions with advanced join reorder enabled, then trace pkg/planner/core/logicalOptimize, rule_join_reorder.go, and joinorder/join_order.go around the cited lines. Done means the queries return the expected empty result set without a nil pointer dereference, with regression coverage for the reported shape.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, sql
- Domain
- databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100