pingcap / pingcap/tidb

planner proposal: introduce trivial plan

Open
#66,621 0 comments 0 reactions 0 assignees View on GitHub
type/enhancement
Dominant language
Go
Stars
40.5k
Forks
6.2k
PR merge metrics
PR metrics pending

Description

## Introduction

This design proposes a SQL Server-like "Trivial Plan" optimization-level framework for TiDB's Volcano planner, aiming to solve "simple-enough" queries with lower compile cost. The framework has two levels:

- `TRIVIAL_SAFE`: when the optimization problem can be **proven to have a single feasible physical plan** under semantic constraints, build that plan directly.
- `TRIVIAL_AGGRESSIVE`: when there are still `> 1` candidates but the query shape is simple enough, use a limited set of rules/heuristics (a "trivial stage") to quickly produce a "good-enough" implementation and decide not to enter FULL (not guaranteed to match FULL); fall back to FULL on ineligibility or high-risk signals.

Both paths happen after logical optimization and before FULL physical optimization (`after logicalOptimize` / `before physicalOptimize`), so we can reduce compile cost without sacrificing semantic correctness.

To avoid further scattering shortcut branches on top of today's fast path, this document also proposes a Phase 0 cleanup: explicitly separate **context shaping** (bindings/hints/set_var/engine constraints) from **optimization level selection / plan construction / shared validations**, so `FAST_POINT/TRIVIAL_SAFE/TRIVIAL_AGGRESSIVE/FULL` can reuse the same finalized semantics and common checks.

## Motivation or Background

### From first principles: what an optimizer should minimize

In practice, an optimizer should not only minimize execution cost, but:

```
TotalCost = CompileCost(search effort) + ExpectedExecCost(plan)
```

In OLTP / short-query / high-concurrency compilation workloads, `CompileCost` can be comparable to (or even dominate) `ExpectedExecCost`. For ad-hoc statements with low reuse, deeper search often has diminishing returns.

So we need a mechanism to stop searching early when the potential execution benefit is smaller than the compilation cost of continued search.

### What "trivial" really means

Trivial does not mean execution is trivial. It means the **optimization problem** is trivial: under semantic constraints and physical capabilities, the plan space is already tiny or can be stably solved under low optimization effort, so the marginal value of full cost-based search is near zero.

This document borrows SQL Server's idea of treating trivial as a distinct optimization stage, and then maps it into two engineering levels in TiDB: `TRIVIAL_SAFE` (provably single-solution) and `TRIVIAL_AGGRESSIVE` (limited-rule fast solving with strong fallback).

### TiDB current state and pain points

TiDB's (Volcano) planning pipeline (simplified):

```
executor.(*Compiler).Compile
-> planner.Optimize
-> optimizeNoCache
-> core.TryFastPlan (PointGet/BatchPointGet/...)
-> build logical plan
-> core.VolcanoOptimize
-> logicalOptimize
-> physicalOptimize
- RecursiveDeriveStats
- FindBestTask (skyline + cost search)
-> postOptimize
```

Today there is only one shortcut mode: FAST_POINT (existing fast path).

- Entry points: `pkg/planner/optimize.go:optimizeNoCache`, `pkg/planner/core/point_get_plan.go:TryFastPlan`.
- Behavior: recognizes point-access patterns from AST and builds a physical plan directly, bypassing Volcano physical optimization.

All non-FAST_POINT queries go through FULL. The main compile hotspot is `pkg/planner/core/optimizer.go:physicalOptimize`:

- `RecursiveDeriveStats` builds ranges and loads/estimates stats for many candidate access paths on `DataSource`;
- then `FindBestTask` performs skyline + cost-based search over those candidates.

The pain points:

- FAST_POINT is narrow (point reads/updates/deletes).
- Many "simple but single-table with many indexes" queries still go through FULL, with compile cost roughly linear in the number of access path candidates.
- Fast path happens too early in the pipeline (e.g. before bindings are finalized), forcing ad-hoc semantic eligibility checks inside fast path (e.g. stable result mode disables it) and risking inconsistency in common semantic checks (table mode/lock/privilege). Adding another shortcut layer without cleanup would further hurt maintainability.

## Detailed Design

### Goals and Non-goals

Goals (target state):

- Introduce two trivial levels in Volcano, `TRIVIAL_SAFE` and `TRIVIAL_AGGRESSIVE`, positioned between `FAST_POINT` and `FULL`.
- Enable `TRIVIAL_SAFE` only when "trivial" is provable (single solution), and treat "FULL-equivalent" as a hard constraint.
- Enable `TRIVIAL_AGGRESSIVE` only for very simple query shapes (in `AGGRESSIVE` mode): use a limited set of rules/heuristics to build a "good-enough" plan inside a trivial stage and stop; it is not guaranteed to match FULL, but must preserve semantic correctness and must fall back to FULL when ineligible or risky.
- Reduce compile overhead in Volcano's physical stage:
- avoid repeating range building, stats derivation, and cost evaluation across a large number of candidates;
- for `TRIVIAL_SAFE` hits, skip FULL `physicalOptimize` entirely (especially the full `FindBestTask` search).
- Provide observability: record hit/fallback reasons.

Phased delivery: phase 1 focuses on `TRIVIAL_SAFE`. We still include `TRIVIAL_AGGRESSIVE` in the overall framework and observability, but land it later behind `AGGRESSIVE` mode.

Non-goals (phase 1):

- Not trying to cover all "simple" SQL (start conservative).
- Not primarily about "more aggressive pruning" or cost model changes.
- Not covering join/agg/window/subquery/CTE/union (strong coupling).
- Not covering TiFlash/MPP initially.

### Terminology and Optimization Levels

We make "optimization level" a first-class concept:

- `FAST_POINT`: existing PointGet/BatchPointGet and point DML fast path.
- `TRIVIAL_SAFE`: new. Prove "single feasible physical plan" and build it directly.
- `FULL`: existing Volcano physical optimization (stats + cost search).
- `TRIVIAL_AGGRESSIVE`: a SQL Server-like trivial stage (only in `AGGRESSIVE` mode). Use a limited set of rules/heuristics to quickly build a "good-enough" plan and stop; it is not guaranteed to match FULL and must fall back to FULL when ineligible or risky (see below).

Core distinction (first principles):

- `TRIVIAL_SAFE`: because `|P(C)| = 1` (feasible plan set under constraints `C`), search is unnecessary.
- `TRIVIAL_AGGRESSIVE`: when `|P(C)| > 1` but the query is simple enough, accept an "optimization effort tradeoff": end optimization quickly within a limited trivial stage to reduce compile cost materially. The plan is not guaranteed to match FULL, and risk is controlled via strong fallback conditions.

### Architecture: shape semantics first, then pick an optimization level

To avoid scattered `if/else` shortcuts, we explicitly layer planner responsibilities:

1. Context Shaping
- Resolve/apply: statement hints, bindings, `set_var` hints;
- Apply engine/mode constraints: e.g. temporarily removing TiFlash under strict mode;
- Output: a finalized "effective PlannerContext" shared by all optimization levels.
2. Optimization Level Selection
- Order: `FAST_POINT` -> `TRIVIAL_SAFE` -> `TRIVIAL_AGGRESSIVE` -> `FULL`.
3. Plan Construction
- Different solvers per level, but shared semantic checks and post-processing.

This matches SQL Server's placement: after simplification (logicalOptimize), before full optimization.

Target-state pipeline placement (simplified):

```
planner.Optimize
-> Context Shaping (hints/bindings/set_var/engine constraints)
-> Try FAST_POINT (AST-based)
-> Build logical plan
-> logicalOptimize
-> Try TRIVIAL_SAFE (after logicalOptimize, before physicalOptimize)
-> Try TRIVIAL_AGGRESSIVE (after logicalOptimize, before physicalOptimize)
-> physicalOptimize (FULL: RecursiveDeriveStats + FindBestTask)
-> postOptimize
```

### Phase 0: Fast Path Cleanup (clarify responsibilities)

The essence of Phase 0 is clarity, not speed: decouple semantic shaping from solver selection so fast/trivial/full can share one context and common semantic checks.

Phase 0 establishes the structural prerequisites for TRIVIAL_* levels:

1. Context shaping first: fast path must not bypass finalized semantics from bindings/hints
- Current: in `optimizeNoCache`, `TryFastPlan` runs before `MatchSQLBinding`, and fast path carries additional semantic eligibility checks / special-case handling (stable result mode, `sql_select_limit`, privilege checks, lock wait time, etc.).
- Target: fold semantics-affecting logic (statement hints, binding hints, `set_var` hints, engine/isolation constraints, `TryAddExtraLimit`, etc.) into a single "context shaping" phase, then select an optimization level.
2. Centralize semantic eligibility checks: whether FAST/TRIVIAL is allowed should be decided by "optimization level selection"
- For example stable result mode, `SELECT ... INTO`, `sql_select_limit`, fixcontrol, etc. should ideally not be scattered inside the fast-path builder.
3. Consolidate shared validations and cross-cutting behavior across fast/trivial/full
- Common checks such as table mode / table lock / privilege should not be skipped by shortcuts; the entry points for collecting access info and validating should be unified.
- Cross-cutting behaviors such as lock wait time, transaction warm-up, and explain observability should be consistent (or explicitly documented) across all optimization levels.
4. Observability: must explain "why hit / why fallback"
- Record optimization level, hit reason, fallback reason, candidate set sizes, etc. for rollout and debugging.

To reduce refactor risk, Phase 0 should be implemented as a minimum-viable cleanup: first ensure context shaping (bindings/hints/set_var/engine constraints) is finalized before any shortcut is attempted; then centralize shortcut eligibility checks and observability. Avoid forcing FAST_POINT to build a full logical plan just to share validations; during the transition it is acceptable to keep defensive checks inside fast path, as long as the centralized eligibility logic is the source of truth.

(Aligning server-side multi-stmt prefetch with planner's fast path is follow-up work and does not block TRIVIAL_SAFE scaffolding.)

### TRIVIAL_SAFE: a provably-single-solution trivial plan

#### Placement

Insert TRIVIAL_SAFE into Volcano between `logicalOptimize` and `physicalOptimize`:

```
VolcanoOptimize
-> logicalOptimize
-> tryTrivialSafeOptimize (new)
- hit -> physical plan (bounded effort; no FULL search)
- miss -> physicalOptimize (existing FULL)
-> postOptimize
```

This is the key constraint for TRIVIAL_SAFE: it does not bypass logical optimization (rules already applied), but it returns before entering FULL physical optimization, avoiding the full stats/cost search.

#### Eligibility (conservative subset in phase 1)

Phase 1 only supports `SELECT` and enforces hard shape constraints:

- Logical tree: single `DataSource` leaf; only allow unary operators:
- `Selection`
- `Projection`
- Must not contain:
- `Join/Apply/Agg/Window/Union/CTE/Subquery`
- Mode/engine constraints (start conservative):
- not in stable result mode (consistent with the current fast path which hard-disables it)
- TiKV-only (no TiFlash/MPP)

If any condition is not met: fallback to `FULL` with no behavior change.

#### Reusable building blocks: stats-free index pruning in logicalOptimize (already exists)

TiDB already has a stats-free "candidate access path shrinking" step during logical optimization:

- Rule entry: `pkg/planner/core/rule/rule_collect_plan_stats.go:CollectPredicateColumnsPoint`
- It prunes `DataSource.AllPossibleAccessPaths` via `PruneIndexesByWhereAndOrder` (`pkg/planner/core/rule/rule_prune_indexes.go`), based on `ds.InterestingColumns`.
- It is controlled by `tidb_opt_index_prune_threshold` (can be disabled by setting it to `< 0`).

This pruning does not depend on statistics and happens before `physicalOptimize`. Therefore, placing TRIVIAL_SAFE after `logicalOptimize` naturally reuses it and reduces the work needed for both "uniqueness proof" and physical construction.

One clarification: `tidb_opt_index_prune_threshold` is fundamentally a "heuristic effort-reduction" knob (it ranks indexes by interesting-column coverage/order and keeps only up to a threshold), which may prune indexes that FULL would otherwise consider. It is an acceptable, controllable engineering trade-off.

Note that this pruning is already part of today's FULL pipeline (it runs in `logicalOptimize`). Therefore, TRIVIAL_SAFE "reusing it" does not introduce extra semantic drift: FULL will see the same candidate set under the same shaped context and the same variable settings; if users disable the rule, FULL/SAFE will both see the unpruned candidates.

Skyline-R phase 1 (moving the `keepIndex` feasibility prefilter earlier) shares a similar goal (reduce candidates and compile work), but has a different semantic positioning: it is intended to be FULL-equivalent (or a strict subset) feasibility filtering — "drop candidates that FULL would definitely drop anyway". The two are complementary and can be stacked.

#### Candidate shrinking before TRIVIAL_SAFE: semantic layering

TRIVIAL_SAFE must remain FULL-equivalent. Therefore, any pre-SAFE candidate shrinking must be classified by semantics:

| Category | Example | Relation to FULL | SAFE may depend? | Restore needed on fallback? |
| --- | --- | --- | --- | --- |
| Effort pruning (heuristic effort reduction) | `tidb_opt_index_prune_threshold` | already in today's FULL pipeline (may change search effort) | reusable (same behavior) | No |
| Feasibility pruning (feasibility filtering) | skyline `keepIndex` feasibility filter, store/engine feasibility, partial-order feasibility | FULL-equivalent / strict subset (drop only what FULL would definitely drop) | **Yes** | No |
| Aggressive-only shortlist (AGGRESSIVE only) | TRIVIAL_AGGRESSIVE shortlist | not FULL-equivalent (engineering trade-off) | No | **Yes** |

TRIVIAL_SAFE depends only on feasibility pruning (plus constraints such as hints/engine isolation). Any stats-aware or heuristic winner-comparisons must stay in FULL.

#### "Provably trivial": FULL feasible plan set collapses to a single equivalence class

TRIVIAL_SAFE is not about picking a good-looking index; it is about proving that, under the same shaped-context constraint set `C` (bindings/hints/isolation read engines/session switches), FULL optimization would enumerate only a single feasible physical-plan **equivalence class**. In other words, there does not exist another plan that is semantically and property-wise feasible but differs in physical implementation.

With the phase-1 hard shape restrictions (single-table unary chain + TiKV-only, and no `Sort/TopN/Limit` property competition), the remaining physical degrees of freedom largely collapse to the `DataSource` access-path choice and its reader shape (table/index/index lookup; single-scan vs double-read). Therefore, a practical sufficient condition for a TRIVIAL_SAFE hit is:

- the feasible access path set is unique under `C` (including single-scan vs double-read as a key attribute); or
- it becomes unique after a FULL-equivalent rule-only feasibility filtering (Skyline-R phase 1: `keepIndex` prefilter; see below).

When we later extend to property-sensitive operators such as `Sort/TopN/Limit`, the definition must include required-property feasibility and enforcer-induced alternatives; otherwise we must fall back to FULL.

Typical convergence signals (ordered by safety):

1. Semantic single-solution cases (safest)
- primary/unique key full equality constraints (point access)
- contradictory predicates -> `TableDual`
2. Constraint shrinkage
- `FORCE/USE INDEX` leading to only one feasible path
- engine capability or session settings making other paths infeasible
3. Property single-solution cases (future extension)
- `ORDER BY ... LIMIT` can be satisfied by only one path (see Unary Chain extension)

Red lines:

- The proof must be stats-free and explainable.
- If we cannot prove "single equivalence class", we must fall back to FULL.

#### Where TRIVIAL_SAFE hits come from (phase 1 intuition)

From first principles, `TRIVIAL_SAFE` can only hit when **constraints collapse the solution space to 1**; it is not about picking "the best" index. In the phase-1 subset (single-table unary chain, no required-property competition), hits typically come from two sources:

1. **Constraint-driven uniqueness**: context-shaping constraints (especially bindings/hints/engine limits) directly reduce feasible access paths to exactly one.
- Typical example: when `USE/FORCE INDEX` (and related constraints) collapses candidates to a single feasible path (or only the table path), FULL enumeration/search is unnecessary.
2. **Rule-pruning-driven uniqueness**: reuse FULL-semantic (or strict-subset) stats-free rules to prune index paths that are guaranteed to be useless, so the remaining candidates naturally converge to one.
- Typical example: logical-phase `PruneIndexesByWhereAndOrder` plus Skyline-R (moving the FULL `keepIndex` feasibility prefilter earlier, sargable-like), which drops indexes that provably have no leading-column access predicate and have no other "must-keep" reason.

So hints/rules can improve SAFE hit rate, but the essence of SAFE remains "provably single-solution", not "more aggressive index choosing".

#### Skyline-R: split skyline pruning into Rule-only and Cost-aware

Today, skyline pruning happens inside `FindBestTask` (`pkg/planner/core/find_best_task.go`), and `compareCandidates()` mixes rule-based and stats-based dimensions.

To support TRIVIAL_SAFE, we explicitly split skyline into:

1. Skyline-R (Rule-only, new, used by TRIVIAL_SAFE)
- Phase 1 does only one thing: move the FULL skyline **`keepIndex` feasibility filter** earlier (a sargable-like rule), so we can drop index paths that are guaranteed to be unusable and avoid eager ranges/stats work for them.
- Here "sargable-like" means a feasibility check ("can the predicate become an access condition for building index ranges?"), not a proof that an index is globally optimal.
- Importantly, `keepIndex` is a **multi-factor keep/filter check**, and `access predicate` is only one factor: even if an index is not sargable for filtering, it may still be kept by FULL due to forced/covering/order/partial-order reasons. Phase 1 prunes only when all such keep reasons are absent and "no access" is provable.
- This filter already exists in FULL `skylinePruning()` (`pkg/planner/core/find_best_task.go`). Intuitively, an index path is worth considering only if it may help filtering, satisfy required properties, is hint-forced, is a covering single-scan, or can be used for partial-order optimization.
- Phase-1 semantics in one sentence: **prune an index path only when we are sure FULL would never keep it**. Concretely, an *index path* is pruned only when all of the following hold:
- it is not hint-forced (`path.Forced=false`);
- it is not a covering single-scan (`path.IsSingleScan=false`);
- we do not need it for properties: if `!prop.IsSortItemEmpty()` then FULL `keepIndex` is trivially true, so phase 1 does not do keepIndex pruning; if there is a partial-order property (TopN optimization), reuse FULL's partial-order feasibility filtering (indexes that cannot match partial-order are dropped);
- and we can *prove* the leading index column has no access predicate (see `AccessSummary` below).

Pseudocode (readability-first):

```text
keep(tablePath) = true
keep(indexPath) =
path.Forced || path.IsSingleScan || !prop.IsSortItemEmpty() || matchPartialOrderIndex ||
AccessSummary.HasLeadingAccessPredicate(path)
```
- To make the "prove no access predicate" check feasible without building full ranges, introduce an `AccessSummary` (stats-free):
- Apply the same expression normalization as FULL (at least `expression.EliminateNoPrecisionLossCast`, consistent with `deriveStats4DataSource()`), to avoid false negatives caused by expression shapes.
- `AccessSummary` uses three values: `YES/NO/UNKNOWN`. We prune only on `NO`; otherwise keep the path for FULL (allow false positives, disallow false negatives). Phase 1 treats top-level OR/DNF as `UNKNOWN` by default.
- Implementation sketch: reuse `pkg/util/ranger`'s `conditionChecker` semantics to do a lightweight "leading column has any access predicate" check; do not build full ranges.
2. Skyline-C (Cost-aware, kept in FULL)
- Remains in `FindBestTask` and continues to use stats-based comparisons to preserve FULL plan quality.

Implementation notes:

- Skyline-R phase 1 does not attempt skyline dominance / `compareCandidates`. Stats-aware comparisons (risk ratio, pseudo, `CountAfterAccess`, empirical thresholds) remain entirely in FULL (Skyline-C).
- Correctness invariant (say it explicitly): `keepIndexEarly(path)=false => keepIndexFULL(path)=false`. Practically, `AccessSummary` may allow false positives (keep extra), but must not allow false negatives (prune incorrectly). If in doubt, keep the path and let FULL decide.
- Fallback isolation and reuse: since this prefilter is FULL-equivalent (or a strict subset), its pruning can be committed and reused by FULL; only `TRIVIAL_AGGRESSIVE` temporary shortlists (not done by FULL) must be restored on fallback.

Essence: push provable pruning earlier; keep uncertain comparisons in FULL.

#### Other front-move boundaries (phase 1)

Phase 1 front-moves only skyline feasibility prefiltering (Skyline-R) and keeps all cost/stats winner comparisons in FULL. `derivePathStatsAndTryHeuristics` depends on artifacts produced by `fillIndexPath()` (ranges/access filters) and is stats-aware, so we do not front-move it in phase 1; we only reduce its total work indirectly via earlier candidate shrinking. Future reuse must be strictly budgeted and uncertainty must fall back to FULL.

#### Plan construction on TRIVIAL_SAFE hit

On hit, two principles:

1. Do necessary work only for the unique candidate path (range building, index choice), avoiding N-times repeated construction.
2. Reuse existing physical post-processing (`postOptimize`) and required semantic checks to keep behavior consistent.

Engineering approach (prioritize maintainability):

- Recommendation: reuse Volcano physical construction, but restrict the candidate set to one path so `RecursiveDeriveStats/FindBestTask` degenerates to constant work.

Must fall back to FULL when encountering unsupported shapes or corner cases.

#### Unary Chain extension: include Sort/TopN/Limit (later)

We can extend to:

```
DataSource -> Selection/Projection -> Sort/TopN/Limit
```

but only under provable conditions:

- `ORDER BY` expressions are deterministic (no non-deterministic functions like `rand()`)
- the required ordering can be satisfied by the unique access path (full match or provable prefix match)
- `LIMIT/OFFSET` are compile-time constants

Otherwise, fallback to FULL.

### TRIVIAL_AGGRESSIVE: exploratory trivial stage (limited rules/heuristics)

`TRIVIAL_AGGRESSIVE` is enabled only when `tidb_opt_trivial_plan=AGGRESSIVE`. This document's primary deliverable is `TRIVIAL_SAFE`; `TRIVIAL_AGGRESSIVE` is intentionally positioned as an exploratory follow-up: we first establish the framework, boundaries, and observability, then expand rules iteratively when we have enough low-cost signals to justify it.

Why SQL Server can do more in a trivial stage (high level): it can afford lightweight index analysis (coarse selectivity/pages) cheaply. In TiDB, access selectivity estimation typically goes through building index ranges and deriving stats per candidate, which is the main compilation cost we try to avoid. Therefore phase 1 does not attempt a broad stats-based index comparison inside the trivial stage.

#### Phase-1 positioning (conservative)

- Handle only obvious cases; if evidence is weak or ambiguous, fall back to FULL.
- Do not introduce heavyweight computation inside the trivial stage (ranges/stats/cost for many candidates). If it needs that work to decide, it belongs to FULL.
- Fallback isolation: temporary shrinking must not leak into FULL on fallback.

Supported shapes (phase 1):

Phase 1 attempts TRIVIAL_AGGRESSIVE only for single-table unary chains (`DataSource -> Selection/Projection`) and the following cases:

- Unique-1Row: provably at most 1 row via unique/PK equality access (with or without `ORDER BY`/`LIMIT`; they do not change the 1-row bound);
- Covering-dominance: no `ORDER BY`/`LIMIT`, and there exists a provably-dominant covering index access (see below);
- LIMIT-based: queries with `LIMIT/OFFSET` (row-goal plus a conservative limit-only subset; see below).

Otherwise, fall back to FULL (TRIVIAL_SAFE may still hit earlier).

#### Phase-1 strategy: shrink via structural signals only (uncertainty => FULL)

Input: `P0` candidates after context shaping + logical pruning + Skyline-R (`keepIndex`) feasibility prefilter.

First principles for entering TRIVIAL_AGGRESSIVE: only stop in the trivial stage when we can reach a **structural dominance conclusion** without statistics (hard bound / early stop / avoiding a heavyweight operator) and shrink candidates to a tiny set; otherwise defer to FULL.

Red lines (phase 1):

- do not compute ranges/stats/cost for many candidates;
- insufficient evidence, ambiguity, or budget exceeded: fall back to FULL;
- internal budgets (TBD): caps on candidate count / shortlist size / exact evals; exceeding any cap falls back to FULL;
- for LIMIT-based scenarios, `k = LIMIT + OFFSET` too large means the query is no longer "trivial" under row-goal/limit-only rules: fall back to FULL (threshold TBD; can start by aligning with `tidb_opt_limit_push_down_threshold` or using a more conservative internal cap).

Why this is a separate (AGGRESSIVE-only) step, not covered by Skyline-R / logical pruning:

- Skyline-R is FULL-equivalent feasibility filtering; with `ORDER BY`, a non-order-matching path can still satisfy required ordering via a `Sort` enforcer, so it cannot be pruned there.
- Logical index pruning (e.g. `tidb_opt_index_prune_threshold`) is a generic effort-control knob; it is not designed for row-goal/limit-only early stop and does not guarantee a tiny candidate set.

Case U: Unique-1Row (hard upper bound 1)

- Eligibility checks (cheap and decidable):
- there exists a PK or unique index with globally-unique semantics (for partitioned tables, only when global uniqueness can be proven; otherwise fall back to FULL);
- its key columns are all covered by single-value equality predicates (e.g. `col = const`);
- exclude NULL corner cases: for nullable unique-key columns, require the equality values are provably non-NULL (e.g. compile-time non-NULL constants), or the columns are declared NOT NULL (so NULL cannot match). PK columns are inherently NOT NULL, no extra proof needed;
- predicates are simple conjunctions (no OR/DNF) and do not contain subqueries.
- Plan selection:
- pick the corresponding unique/PK access path directly; since the result has at most 1 row, the presence of `ORDER BY`/`LIMIT` does not change the bound and we avoid ambiguous trade-offs (sort/double-read) in the trivial stage.
- if multiple unique/PK paths qualify, use a deterministic priority (e.g. clustered PK > covering unique > unique), otherwise fall back to FULL.

Examples (simplified):

- `WHERE pk=1` or `WHERE uk1=1 AND uk2=2`: at most 1 row, pick the corresponding PK/unique access.

Case P: Covering-dominance (provably-dominant covering index access; no `ORDER BY`/`LIMIT`)

- Eligibility checks (cheap and decidable):
- no `ORDER BY`, no `LIMIT/OFFSET`;
- predicates are simple conjunctions (no OR/DNF) and do not contain subqueries;
- `AccessProfile` construction considers only the range-buildable predicate subset (equalities/ranges); other predicates are allowed but are ignored when building `AccessProfile`.
- candidates must be covering single-read and have no residual filter (no back-to-table filtering). If no such candidate exists, fall back to FULL.
- Rule (dominance-only):
- build a lightweight `AccessProfile` (from equality/range predicates only) for each covering index path: the maximal index-range prefix it can use (equality prefix + optional 1 range column). This is similar in spirit to `AccessSummary`: extend "leading column has access predicate" into "maximal access-prefix length/set", and keep a `YES/NO/UNKNOWN` discipline to avoid mis-pruning; if any key column becomes `UNKNOWN`, treat it as "cannot prove" and fall back to FULL.
- Strict definition (provable dominance):
- Let `AccessPredCols(path) = EqPrefixCols(path) ∪ {RangeCol(path)}` (omit the range column if absent).
- For two paths with known `AccessProfile`, `A` dominates `B` iff `AccessPredCols(B) ⊆ AccessPredCols(A)` (so `A`'s scanned key range is a subset of `B`'s).
- Only when there exists a path that dominates all other eligible covering paths do we choose it; otherwise fall back to FULL.

Example (simplified):

- `WHERE a=1 AND b=2 AND c>10`: index `(a,b,c)` dominates `(a,b)` on access prefix; if both are covering, choose `(a,b,c)`.

Case A/B: row-goal (`ORDER BY ... LIMIT`)

- Eligibility checks (cheap and decidable):
- `LIMIT/OFFSET` are compile-time constants and `k` is small enough (threshold TBD);
- predicates are simple conjunctions (no OR/DNF) and do not contain subqueries;
- `IN` list sizes are bounded (threshold TBD).
- Rule A (equality-prefix order-matching):
- allow order-matching under a sub-range where some leading index columns are fixed by single-value equality (`col = const`).
- Rule B (prefer covering single-read):
- if there exist order-matching covering single-read candidates: keep only them in the shortlist;
- if order-matching is only possible via double-read (IndexLookup): fall back to FULL (avoid ambiguous double-read comparisons in the trivial stage).
- Winner decision:
- if shortlist has exactly one candidate: return it;
- if shortlist is small enough: reuse FULL ranger/stats/cost only on the shortlist (bounded exact eval) and return the winner;
- otherwise: fall back to FULL.

Examples (simplified):

- A: `WHERE c=1 ORDER BY a LIMIT 10`, if there is an index `(c,a)`, the scan within `c=1` can be ordered by `a` and early-stop.
- B: `SELECT b FROM t WHERE a>=1 ORDER BY a LIMIT 10`, if there is an index `(a,b)`, it can be covering and early-stop.

Case C/D: limit-only (`LIMIT`, no `ORDER BY`)

- Eligibility: no `ORDER BY`; `LIMIT/OFFSET` are compile-time constants and `k` is small enough (threshold TBD).
- Rule C (no WHERE): if there is a covering single-read candidate, deterministically pick a "narrower" index key (fewer key columns); otherwise fall back to FULL.
- Example: `SELECT a FROM t LIMIT 10`, if there is an index `(a)`, scan the index for `k` rows.
- Rule D (with WHERE): attempt only when we can **prove** it is covering and has no residual filter (all predicates are evaluated on the index side; no back-to-table); otherwise fall back to FULL.
- Example: `SELECT a,b FROM t WHERE a=1 LIMIT 10`, if there is an index `(a,b)` and the predicate can be fully applied on the index side, scan the index for `k` rows.

#### TODO: lightweight index analysis (future work)

Treat `TRIVIAL_AGGRESSIVE` as the host framework for future lightweight index analysis (mid/long term). If we can make "estimate selectivity/bytes read" cheap without fully building ranges for every candidate, `TRIVIAL_AGGRESSIVE` can expand to more base-scenario range predicates in a SQL Server-like way. This is explicitly future work and must remain budgeted and conservative (uncertainty triggers fallback to FULL).

### Configuration, rollout, and observability

#### User-facing switch

- New session/global variable: `tidb_opt_trivial_plan`
- Default: `OFF` (explicit enable for gradual rollout)
- Values:
- `OFF`: disable the trivial framework (behavior unchanged from today).
- `SAFE`: enable `TRIVIAL_SAFE` only (provably single-solution; FULL-equivalence is a hard constraint).
- `AGGRESSIVE`: enable `TRIVIAL_SAFE` + `TRIVIAL_AGGRESSIVE` (trivial-aggressive stage; not guaranteed to match FULL; fall back to FULL when ineligible or risky).

#### StmtCtx / EXPLAIN observability

We must answer:

1. Which optimization level was used, and why?
2. If TRIVIAL_SAFE / TRIVIAL_AGGRESSIVE missed, which hard condition failed?

Suggested fields (in `StmtCtx` or via explain notes):

- `OptimizerOptLevel`: `FAST_POINT | TRIVIAL_SAFE | TRIVIAL_AGGRESSIVE | FULL`
- `TrivialReason` / `FallbackReason`: structured reason (or at least readable strings)
- `Evidence`: candidate counts (before/after shrinking), unique path identifier (SAFE), shortlist rationale (AGGRESSIVE), etc.

### Compatibility and edge cases

Correctness and compatibility must be prioritized:

- Bindings/Hints: optimization level selection must happen after context shaping; bindings must not be bypassed by fast/trivial.
- Stable result mode: conservatively disabled in phase 1; later support requires equivalence proofs.
- Engine/Store: phase 1 is TiKV-only; TiFlash/MPP needs additional proofs over task types and plan alternatives.
- Consistent semantic checks: table mode / lock / privilege checks must be unified or their differences must be explicit.
- Fallback isolation: TRIVIAL_* must not change FULL's candidate plan space on miss/fallback (e.g. access-path shrinking must be reversible).
- Plan cache: include `tidb_opt_trivial_plan` in the plan-cache key (other interaction details are left to implementation).

### Suggested phases

Incremental rollout to reduce risk:

1. S0 (Phase 0): fast path cleanup (context shaping + level selection + common checks + observability)
2. S1: introduce optimization level enum + TRIVIAL_SAFE scaffolding (always miss), add UT to ensure no behavior change
3. S2: implement TRIVIAL_SAFE phase 1 (single-table + Selection/Projection + unique path), add Skyline-R
4. S3: (optional) implement TRIVIAL_AGGRESSIVE initial version (`AGGRESSIVE`: Unique-1Row + Covering-dominance + LIMIT-based scenarios (row-goal + a conservative limit-only subset), shortlist shrinking + bounded exact eval (reusing FULL ranger/stats/cost) + strong fallback; not guaranteed to match FULL)
5. S4: gradually expand coverage and rules (e.g. more complete row-goal `ORDER BY ... LIMIT/TopN`, more single-table operators and DML), continuously evaluating benefits/risks with observability and benchmarks

### Code references (current entry points)

- Planner entry & fast path trigger: `pkg/planner/optimize.go:optimizeNoCache`
- Fast path builder: `pkg/planner/core/point_get_plan.go:TryFastPlan`
- Volcano pipeline: `pkg/planner/core/optimizer.go:VolcanoOptimize`
- FULL physical hotspot: `pkg/planner/core/optimizer.go:physicalOptimize`
- Skyline pruning (FULL): `pkg/planner/core/find_best_task.go`
- Multi-stmt prefetch fast path: `pkg/server/conn.go:prefetchPointPlanKeys`

## Test Design

### Functional Tests (unit tests)

- Eligibility boundaries: each hard condition should have coverage.
- Plan equivalence vs FULL on the "provably single-solution" set:
- access path type (table/index/index lookup) is identical;
- key operator shape remains consistent (e.g. no extra Sort/TopN differences).
- TRIVIAL_AGGRESSIVE (`AGGRESSIVE`) semantic correctness:
- results must be identical;
- plan drift vs FULL is allowed but must be observable and explainable via explain/logs (for rollout comparisons and rollback decisions).
- Fallback stability: unsupported cases must fall back to FULL with unchanged behavior.

### Scenario Tests (integrationtest)

- Single-table with many indexes + simple predicates: verify compile time reduction and identical results; include Unique-1Row / covering-dominance / row-goal / limit-only cases; under `AGGRESSIVE`, compare execution-cost / plan-shape distributions against FULL to avoid obvious regressions.
- Various hints/bindings combinations: verify bindings priority is not bypassed.
- Transaction/locking related statements: validate no behavior changes (TRIVIAL_SAFE can conservatively fall back in phase 1).

### Compatibility Tests

- Interactions with plan cache, bindings, restricted SQL, partition pruning, strict mode (temporary TiFlash removal).
- Upgrade/downgrade: new variables and explain outputs should not break existing clients.

### Benchmark Tests

Measure both:

- compile latency (P50/P95) and CPU, especially for single-table, many-index, short SQL patterns;
- impact on online workloads: reduced compilation contention and latency jitter.
- hit rate and fallback reason distribution, to guide safe coverage expansion.

## Impacts & Risks

Expected impacts:

- For provably single-solution queries, significantly reduce `physicalOptimize` CPU/latency, improving throughput and reducing compilation jitter under concurrency.
- `TRIVIAL_SAFE` may have a limited hit rate, but it provides a low-risk framework and observability foundation for safely expanding `AGGRESSIVE` coverage later.
- For simple Unique-1Row / covering-dominance / row-goal / limit-only queries, `AGGRESSIVE` can further reduce compilation cost and concurrency jitters, at the expense of potential plan drift, which is controlled via switch/fallback/observability.
- With a first-class optimization level concept, boundaries between fast/trivial/full become clearer, improving maintainability.

Main risks and mitigations:

- Plan-quality regressions under `AGGRESSIVE` (performance variance caused by plan drift)
- Mitigation: strict eligibility + strong fallback; gradual rollout; record rationale in explain/logs and allow quick rollback to `SAFE/OFF`.
- Skyline-R diverging from existing skyline behavior
- Mitigation: explicit split (Skyline-R stats-free vs Skyline-C cost-aware) with clear responsibilities.
- Lack of observability hurts debugging and rollout
- Mitigation: hit/fallback reasons must be visible at statement level.

## Unresolved Questions

- How should phase-1 rules evolve to balance compile gain, explainability, and fallback stability?
- Do we need explicit quality guardrails in `AGGRESSIVE` mode (auto fallback / denylist / degrade-on-unreliable-stats), and how should thresholds be defined?
- How should plan cache interaction details (beyond including `tidb_opt_trivial_plan` in the cache key) be finalized?
- What is the final external shape of observability fields (hit reason, shortlist evidence, fallback reason), and how do we keep compatibility?

Contributor guide

Open the contributing guide

Research direction

Start with pkg/planner/optimize.go, pkg/planner/core/point_get_plan.go, and pkg/planner/core/optimizer.go to trace the current FAST_POINT and FULL planning paths. Read the index-pruning rules in pkg/planner/core/rule/rule_collect_plan_stats.go and rule_prune_indexes.go, then use the Phase 0 and TRIVIAL_SAFE eligibility requirements as the completion criteria, including safe fallback to FULL and hit/fallback observability.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, sql
Domain
databases
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.