pingcap / pingcap/tidb

planner: nil pointer dereference when a derived table is cross-joined with a LEFT JOIN

Open
#69,986 5 comments 0 reactions 0 assignees View on GitHub
affects-8.5 contribution first-time-contributor severity/major sig/planner type/bug
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.