pingcap / pingcap/tidb

Prepared statement incorrectly materializes a single-reference CTE after unrelated DDL

Open
#70,759 2 comments 0 reactions 0 assignees View on GitHub
severity/moderate sig/planner type/bug
Dominant language
Go
Stars
40.5k
Forks
6.2k
PR merge metrics
PR metrics pending

Description

## Bug Report

### 1. Minimal reproduce step (Required)

A server-side prepared statement containing a single-reference, non-recursive CTE is inlined on its first execution. After another session executes unrelated DDL and the prepared-statement session observes the newer schema version, the next execution of the same prepared statement materializes the CTE. Direct SQL execution after the DDL still inlines the CTE.

The behavior reproduces with the prepared plan cache both disabled and enabled. The following regression test asserts the expected behavior and fails on affected versions:

```go
package executor_test

import (
"fmt"
"strings"
"testing"

"github.com/pingcap/tidb/pkg/expression"
plannercore "github.com/pingcap/tidb/pkg/planner/core"
"github.com/pingcap/tidb/pkg/planner/core/base"
"github.com/pingcap/tidb/pkg/testkit"
)

func TestPreparedCTERemainsInlinedAfterUnrelatedDDL(t *testing.T) {
for _, planCacheEnabled := range []bool{false, true} {
t.Run(fmt.Sprintf("plan_cache_%t", planCacheEnabled), func(t *testing.T) {
store := testkit.CreateMockStore(t)
tk := testkit.NewTestKit(t, store)
tk.MustExec("use test")
tk.MustExec(fmt.Sprintf("set tidb_enable_prepared_plan_cache = %t", planCacheEnabled))
tk.MustExec("set tidb_opt_force_inline_cte = off")
tk.MustExec("create table source (a int)")
tk.MustExec("insert into source values (1)")

stmtID, _, _, err := tk.Session().PrepareStmt("with cte as (select * from source) select * from cte where a = ?")
if err != nil {
t.Fatalf("PrepareStmt() error = %v", err)
}

currentPlan := func() string {
stmtPlan := tk.Session().GetSessionVars().StmtCtx.GetPlan()
plan, ok := stmtPlan.(base.Plan)
if !ok {
t.Fatalf("statement plan has type %T, want base.Plan", stmtPlan)
}
return plannercore.ToString(plan)
}
executePrepared := func() string {
rs, err := tk.Session().ExecutePreparedStmt(t.Context(), stmtID, expression.Args2Expressions4Test(1))
if err != nil {
t.Fatalf("ExecutePreparedStmt() error = %v", err)
}
tk.ResultSetToResult(rs, "execute prepared CTE").Check(testkit.Rows("1"))
return currentPlan()
}

initialPlan := executePrepared()
if strings.Contains(initialPlan, "CTEReader(") {
t.Fatalf("initial prepared CTE plan = %s, want single-reference CTE inlined", initialPlan)
}

ddlTK := testkit.NewTestKit(t, store)
ddlTK.MustExec("use test")
ddlTK.MustExec("create table unrelated (a int)")

planAfterDDL := executePrepared()
tk.MustQuery("with cte as (select * from source) select * from cte where a = 1").Check(testkit.Rows("1"))
directQueryPlanAfterDDL := currentPlan()
if strings.Contains(directQueryPlanAfterDDL, "CTEReader(") {
t.Fatalf("direct CTE query plan after unrelated DDL = %s, want single-reference CTE inlined", directQueryPlanAfterDDL)
}
if strings.Contains(planAfterDDL, "CTEReader(") {
t.Fatalf("prepared CTE plan after unrelated DDL = %s, want single-reference CTE inlined", planAfterDDL)
}
})
}
}
```

Run it with:

```bash
go test -tags=intest ./pkg/executor \
-run '^TestPreparedCTERemainsInlinedAfterUnrelatedDDL$' \
-count=1 -v
```

### 2. What did you expect to see? (Required)

Rebuilding the prepared statement plan after an unrelated schema change should recompute the CTE reference count from the statement structure. A non-recursive CTE referenced once should remain inlined, matching both the initial prepared execution and direct SQL execution of the same query.

The regression test should pass with either value of `tidb_enable_prepared_plan_cache`.

### 3. What did you see instead (Required)

After the prepared-statement session observes the newer schema version, the prepared statement switches to a materialized CTE plan:

```text
prepared CTE plan after unrelated DDL =
CTEReader(0)->Sel([eq(test.source.a, 1)])
```

Both `plan_cache_false` and `plan_cache_true` fail. The initial prepared execution and direct SQL execution after the same DDL remain inlined, so the behavior is specific to re-executing the prepared statement after a schema-version change. `CTEReader` is shown as `CTEFullScan` in user-facing `EXPLAIN` output.

The query result remains correct, but materializing the CTE can prevent predicate or index pushdown and regress query performance.

### 4. What is your TiDB version? (Required)

Reproduced with a source build from the current `master` branch:

```text
Git Commit Hash: e0cfb27df3212f1112bddba141aee3d6f743b3fb
Git Branch: master
Commit Time: 2026-08-31T03:52:38Z
Store: mockstore
```

Contributor guide

Open the contributing guide

Research direction

Start in pkg/executor with the provided TestPreparedCTERemainsInlinedAfterUnrelatedDDL regression test, and run it using the documented go test command. Trace prepared-statement plan rebuilding after the schema-version change and compare its CTE reference handling with direct SQL planning; done means the test passes with both plan-cache settings and the prepared plan remains inlined.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
databases
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.