Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/planner/core/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ go_library(
"task.go",
"telemetry.go",
"trace.go",
"trivial_plan.go",
"util.go",
],
importpath = "github.com/pingcap/tidb/pkg/planner/core",
Expand Down
3 changes: 2 additions & 1 deletion pkg/planner/core/tests/pointget/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ go_test(
srcs = [
"main_test.go",
"point_get_plan_test.go",
"trivial_plan_test.go",
],
flaky = True,
shard_count = 8,
shard_count = 10,
deps = [
"//pkg/config/kerneltype",
"//pkg/metrics",
Expand Down
104 changes: 104 additions & 0 deletions pkg/planner/core/tests/pointget/trivial_plan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright 2026 PingCAP, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package pointget

import (
"testing"

"github.com/pingcap/tidb/pkg/testkit"
"github.com/stretchr/testify/require"
)

func TestTrivialPlan(t *testing.T) {
store := testkit.CreateMockStore(t)
tk := testkit.NewTestKit(t, store)
tk.MustExec("use test")

// Table with no secondary indexes — eligible for trivial plan.
tk.MustExec("drop table if exists t_simple")
tk.MustExec("create table t_simple(a int primary key, b int, c varchar(32))")
tk.MustExec("insert into t_simple values(1, 10, 'hello'), (2, 20, 'world'), (3, 30, 'foo')")

// Basic full table scan: SELECT *
rows := tk.MustQuery("select * from t_simple").Sort().Rows()
require.Len(t, rows, 3)
require.Equal(t, "1", rows[0][0])
require.Equal(t, "hello", rows[0][2])

// SELECT with specific columns.
rows = tk.MustQuery("select a, c from t_simple").Sort().Rows()
require.Len(t, rows, 3)
require.Equal(t, "foo", rows[2][1])

// SELECT with column alias.
rows = tk.MustQuery("select a as id, b as val from t_simple").Sort().Rows()
require.Len(t, rows, 3)

// Verify EXPLAIN shows a table scan plan.
tk.MustQuery("explain format = 'brief' select * from t_simple").Check(testkit.Rows(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Tests don't prove the fast path is used

Why: TestTrivialPlan validates EXPLAIN output, but TryTrivialPlan explicitly returns nil in EXPLAIN mode (pkg/planner/core/trivial_plan.go:55), so the test can pass even if the fast path never triggers for actual queries.

Evidence: pkg/planner/core/trivial_plan.go:55:

if ctx.GetSessionVars().StmtCtx.InExplainStmt {
    return nil, nil
}

The test at pkg/planner/core/tests/pointget/trivial_plan_test.go:50 checks explain format='brief' select * from t_simple, which will always use the normal optimizer path, not the trivial fast path.

"TableReader 10000.00 root data:TableFullScan",
"└─TableFullScan 10000.00 cop[tikv] table:t_simple keep order:false, stats:pseudo",
))
}

func TestTrivialPlanFallback(t *testing.T) {
store := testkit.CreateMockStore(t)
tk := testkit.NewTestKit(t, store)
tk.MustExec("use test")
tk.MustExec("drop table if exists t_idx, t_part, t_gen")

// Table with a secondary index — should NOT use trivial plan (optimizer
// needs to decide between table scan and index scan).
tk.MustExec("create table t_idx(a int primary key, b int, index idx_b(b))")
tk.MustExec("insert into t_idx values(1, 10), (2, 20)")
rows := tk.MustQuery("select * from t_idx").Sort().Rows()
require.Len(t, rows, 2)

// Partitioned table — should NOT use trivial plan.
tk.MustExec("create table t_part(a int primary key, b int) partition by hash(a) partitions 4")
tk.MustExec("insert into t_part values(1, 10), (2, 20)")
rows = tk.MustQuery("select * from t_part").Sort().Rows()
require.Len(t, rows, 2)

// Table with virtual generated column — should NOT use trivial plan.
tk.MustExec("create table t_gen(a int primary key, b int, c int as (a + b))")
tk.MustExec("insert into t_gen(a, b) values(1, 10), (2, 20)")
rows = tk.MustQuery("select * from t_gen").Sort().Rows()
require.Len(t, rows, 2)

// Query with WHERE clause on a trivial table — should NOT use trivial plan.
tk.MustExec("drop table if exists t_no_idx")
tk.MustExec("create table t_no_idx(a int primary key, b int)")
tk.MustExec("insert into t_no_idx values(1, 10), (2, 20)")
rows = tk.MustQuery("select * from t_no_idx where b > 5").Rows()
require.Len(t, rows, 2)

// Query with ORDER BY — should NOT use trivial plan.
rows = tk.MustQuery("select * from t_no_idx order by b").Rows()
require.Len(t, rows, 2)

// Query with LIMIT — should NOT use trivial plan.
rows = tk.MustQuery("select * from t_no_idx limit 1").Rows()
require.Len(t, rows, 1)

// Query with DISTINCT — should NOT use trivial plan.
rows = tk.MustQuery("select distinct b from t_no_idx").Rows()
require.Len(t, rows, 2)

// Query with expression in SELECT — should NOT use trivial plan
// (buildSchemaFromFields returns nil for non-column expressions).
rows = tk.MustQuery("select a + 1 from t_no_idx").Rows()
require.Len(t, rows, 2)
}
256 changes: 256 additions & 0 deletions pkg/planner/core/trivial_plan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
// Copyright 2026 PingCAP, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package core

import (
"github.com/pingcap/tidb/pkg/domain"
"github.com/pingcap/tidb/pkg/expression"
"github.com/pingcap/tidb/pkg/kv"
"github.com/pingcap/tidb/pkg/meta/metadef"
"github.com/pingcap/tidb/pkg/meta/model"
"github.com/pingcap/tidb/pkg/parser/ast"
"github.com/pingcap/tidb/pkg/parser/mysql"
"github.com/pingcap/tidb/pkg/planner/core/base"
"github.com/pingcap/tidb/pkg/planner/core/operator/physicalop"
"github.com/pingcap/tidb/pkg/planner/core/resolve"
"github.com/pingcap/tidb/pkg/planner/property"
"github.com/pingcap/tidb/pkg/planner/util/fixcontrol"
"github.com/pingcap/tidb/pkg/sessionctx/stmtctx"
"github.com/pingcap/tidb/pkg/statistics"
"github.com/pingcap/tidb/pkg/types"
"github.com/pingcap/tidb/pkg/util/ranger"
)

// TryTrivialPlan attempts to build a physical plan directly for trivial queries
// that don't benefit from the full optimization pipeline. This is a fast path
// for queries like "SELECT * FROM t" or "SELECT a, b FROM t" on tables where
// the only viable plan is a full table scan (no secondary indexes, no TiFlash
// replicas, no predicates, etc.).
//
// This avoids all logical optimization rules, stats loading, cost estimation,
// and post-optimization passes that are unnecessary when the plan is predetermined.
// It is designed for execution performance, not EXPLAIN accuracy.
func TryTrivialPlan(ctx base.PlanContext, node *resolve.NodeW) (base.Plan, types.NameSlice) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Trivial plan skips UnionScan requirements

Why: Returns a plain PhysicalTableReader even when dirty-txn reads or local temp/cache tables require UnionScan. For local temp tables, the TableReader runs in dummy mode and returns no rows. For dirty transactions, this breaks read-your-writes semantics.

Evidence: TryTrivialPlan returns before logical plan building that would add UnionScan at pkg/planner/core/logical_plan_builder.go:5276:

if dirty || tableInfo.TempTableType == model.TempTableLocal || 
   tableInfo.TableCacheStatusType == model.TableCacheStatusEnable {
    ... LogicalUnionScan ...
}

The early return at pkg/planner/optimize.go:264 bypasses this critical wrapping.

if checkStableResultMode(ctx) {
return nil, nil
}
if fixcontrol.GetBoolWithDefault(ctx.GetSessionVars().OptimizerFixControl, fixcontrol.Fix52592, false) {
return nil, nil
}

// Skip during EXPLAIN — the normal optimizer produces richer plan
// metadata (costs, stats annotations) that EXPLAIN output depends on.
if ctx.GetSessionVars().StmtCtx.InExplainStmt {
return nil, nil
}

sel, ok := node.Node.(*ast.SelectStmt)
if !ok {
return nil, nil
}
if !isTrivialSelect(sel) {
return nil, nil
}

// Single table, no joins or subqueries.
tblName, tblAlias := getSingleTableNameAndAlias(sel.From)
if tblName == nil {
return nil, nil
}
// Index hints (USE/FORCE/IGNORE INDEX) affect path selection.
if len(tblName.IndexHints) > 0 {
return nil, nil
}

// Look up the already-resolved table info from the resolve context.
resolveCtx := node.GetResolveContext()
tnW := resolveCtx.GetTableName(tblName)
if tnW == nil {
return nil, nil
}
tblInfo := tnW.TableInfo
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Trivial plan bypasses table-source semantics validation

Why: Can scan VIEWs, SEQUENCEs, or explicit PARTITION clauses instead of erroring/expanding/converting, changing results or suppressing expected errors.

Evidence: pkg/planner/core/trivial_plan.go:83 never checks tblInfo.IsView()/IsSequence()/IsBaseTable() before building a PhysicalTableScan. Normal planner at pkg/planner/core/logical_plan_builder.go:5006 expands views via BuildDataSourceFromView and at :5041 converts sequences to TableDual. The fast path bypasses all of this, producing empty/wrong results for SELECT * FROM view.

if tblInfo == nil {
return nil, nil
}

// Resolve database name.
dbName := tblName.Schema
if dbName.L == "" {
dbName = ast.NewCIStr(ctx.GetSessionVars().CurrentDB)
}

// System/memory tables use special executors; skip fast path.
if metadef.IsMemDB(dbName.L) {
return nil, nil
}

// Views and sequences need the full optimizer to expand their definitions.
if tblInfo.IsView() || tblInfo.IsSequence() {
return nil, nil
}

if !isTrivialTable(tblInfo) {
return nil, nil
}

// Privilege check.
if err := checkFastPlanPrivilege(ctx, dbName.L, tblInfo.Name.L, mysql.SelectPriv); err != nil {
return nil, nil
}

// Build output schema and column names from the SELECT field list.
// buildSchemaFromFields returns nil when the field list contains expressions,
// function calls, or other constructs that need the full planner.
schema, names := buildSchemaFromFields(dbName, tblInfo, tblAlias, sel.Fields.Fields)
if schema == nil {
return nil, nil
}

// Build the full set of table columns (for row-size estimation) and the
// pruned set of scan columns (matching the schema the SELECT requests).
allColumns := make([]*model.ColumnInfo, 0, len(tblInfo.Columns))
tblCols := make([]*expression.Column, 0, len(tblInfo.Columns))
for i, col := range tblInfo.Columns {
if col.State == model.StatePublic {
allColumns = append(allColumns, col)
tblCols = append(tblCols, colInfoToColumn(col, i))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Trivial plan breaks SELECT output column mapping

Why: PhysicalTableScan.Columns is derived from an ID-set in table order, dropping select-list ordering/duplicates/pseudo columns. This causes queries like SELECT c,a, SELECT a,a, SELECT _tidb_rowid, or SELECT *,a to return wrong column order or fail with schema/codec mismatches.

Evidence: pkg/planner/core/trivial_plan.go:129 builds Columns by deduping on col.ID and iterating in table order:

schemaColIDs[col.ID] = struct{}{}
...
for _, col := range allColumns {
    if _, ok := schemaColIDs[col.ID]; ok {
        columns = append(columns, col)
    }
}

This loses SELECT field ordering and duplicates. The scan output won't match the schema positions, causing wrong results.

}

// Prune scan columns to only those present in the output schema. This
// ensures TiKV returns only the columns the client expects.
schemaColIDs := make(map[int64]struct{}, schema.Len())
for _, col := range schema.Columns {
schemaColIDs[col.ID] = struct{}{}
}
columns := make([]*model.ColumnInfo, 0, schema.Len())
for _, col := range allColumns {
if _, ok := schemaColIDs[col.ID]; ok {
columns = append(columns, col)
}
}

// Row count estimate from stats cache (no synchronous loading).
rowCount := trivialRowCountEstimate(ctx, tblInfo)
statsInfo := &property.StatsInfo{RowCount: rowCount}

// Provide a HistColl so downstream callers (e.g. GetAvgRowSize for
// network buffer sizing) don't hit a nil dereference.
pseudoHist := statistics.PseudoHistColl(tblInfo.ID, false)

ctx.GetSessionVars().PlanID.Store(0)
ctx.GetSessionVars().PlanColumnID.Store(0)

// Determine the correct full range based on handle type.
var scanRanges ranger.Ranges
if tblInfo.IsCommonHandle {
scanRanges = ranger.FullRange()
} else {
isUnsigned := false
if tblInfo.PKIsHandle {
if pkColInfo := tblInfo.GetPkColInfo(); pkColInfo != nil {
isUnsigned = mysql.HasUnsignedFlag(pkColInfo.GetFlag())
}
}
scanRanges = ranger.FullIntRange(isUnsigned)
}

// Build PhysicalTableScan.
ts := physicalop.PhysicalTableScan{
Table: tblInfo,
Columns: columns,
DBName: dbName,
TableAsName: &tblAlias,
Ranges: scanRanges,
AccessCondition: nil,
StoreType: kv.TiKV,
TblCols: tblCols,
TblColHists: &pseudoHist,
}.Init(ctx, 0)
ts.SetSchema(schema.Clone())
ts.SetStats(statsInfo)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Wrap in PhysicalTableReader.
tr := physicalop.PhysicalTableReader{
TablePlan: ts,
StoreType: kv.TiKV,
IsCommonHandle: tblInfo.IsCommonHandle,
}.Init(ctx, 0)
// Init() flattens TablePlan into TablePlans, copies schema, sets ReadReqType.
tr.SetStats(statsInfo)

// Record table access for privilege and lock checking.
ctx.GetSessionVars().StmtCtx.Tables = []stmtctx.TableEntry{
{DB: dbName.L, Table: tblInfo.Name.L},
}

return tr, names
}

// isTrivialSelect checks whether a SelectStmt is simple enough for the trivial
// fast path: single table, no predicates, no ordering, no grouping, no limits,
// no hints, no locking, no CTEs, no DISTINCT, no INTO.
func isTrivialSelect(sel *ast.SelectStmt) bool {
return sel.Kind == ast.SelectStmtKindSelect &&
sel.From != nil &&
sel.Where == nil &&
sel.GroupBy == nil &&
sel.Having == nil &&
sel.OrderBy == nil &&
sel.Limit == nil &&
sel.WindowSpecs == nil &&
!sel.Distinct &&
sel.SelectIntoOpt == nil &&
sel.LockInfo == nil &&
sel.With == nil &&
len(sel.TableHints) == 0
}

// isTrivialTable checks whether a table's metadata allows the trivial plan
// fast path: no partitioning, no TiFlash replicas, no secondary indexes,
// and no virtual generated columns.
func isTrivialTable(tblInfo *model.TableInfo) bool {
if tblInfo.GetPartitionInfo() != nil {
return false
}
if tblInfo.TiFlashReplica != nil && tblInfo.TiFlashReplica.Available {
return false
}
for _, idx := range tblInfo.Indices {
if idx.State == model.StatePublic && !idx.Primary {
return false
}
}
for _, col := range tblInfo.Columns {
if col.IsGenerated() && !col.GeneratedStored {
return false
}
}
return true
}

// trivialRowCountEstimate returns a row count estimate without triggering
// synchronous stats loading. Uses the cached RealtimeCount when available,
// falling back to PseudoRowCount.
func trivialRowCountEstimate(ctx base.PlanContext, tblInfo *model.TableInfo) float64 {
statsHandle := domain.GetDomain(ctx).StatsHandle()
if statsHandle != nil {
statsTbl := statsHandle.GetPhysicalTableStats(tblInfo.ID, tblInfo)
if statsTbl != nil && statsTbl.RealtimeCount > 0 {
return float64(statsTbl.RealtimeCount)
}
}
return statistics.PseudoRowCount
Comment thread
terry1purcell marked this conversation as resolved.
Outdated
}
7 changes: 7 additions & 0 deletions pkg/planner/optimize.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,13 @@ func optimizeNoCache(ctx context.Context, sctx sessionctx.Context, node *resolve
if fp != nil {
return fp, fp.OutputNames(), nil
}

// Try the trivial plan fast path for simple full-table scans where the
// plan is predetermined (no secondary indexes, no TiFlash, no predicates).
// This skips logical optimization, stats loading, and cost estimation.
if tp, tpNames := core.TryTrivialPlan(pctx, node); tp != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Trivial plan ignores sql_select_limit

Why: core.TryAddExtraLimit is applied only after the fast-path returns, so trivial plan can return unlimited rows when sql_select_limit is set (non-default configuration).

Evidence: pkg/planner/optimize.go:264 returns early:

if tp, tpNames := core.TryTrivialPlan(pctx, node); tp != nil {
    return tp, tpNames, nil
}

vs pkg/planner/optimize.go:276 which applies the limit:

stmtNode = core.TryAddExtraLimit(sctx, stmtNode)

The fast path skips this rewrite entirely.

return tp, tpNames, nil
}
}

enableUseBinding := sessVars.UsePlanBaselines
Expand Down
Loading