From 5f4e973e177aaf21e932aec7db35a3d2cb7dd562 Mon Sep 17 00:00:00 2001 From: tpp Date: Thu, 9 Jul 2026 16:29:33 -0700 Subject: [PATCH 1/6] planner: keep pruned index estimation ranges valid pruneEstimateRange truncates ranges built over the index columns plus the appended handle columns down to the declared index columns so they align with index statistics. The truncation kept the original exclusion flags and estimated every pruned range independently, which breaks in two ways once a handle predicate extends the range: - An exclusive bound whose values were dropped collapses the range to empty: a = 5 AND id > 10 builds (5 10, 5 +inf], which pruned to (5, 5] and estimated ~0 rows regardless of the selectivity of a = 5. - Multiple handle points under one prefix double count: a = 5 AND id IN (11, 22) pruned to [5,5], [5,5] and counted the a = 5 rows twice. Make a truncated bound inclusive, since dropping trailing dimensions widens it to the whole prefix, and merge the pruned ranges with ranger.UnionRanges so overlapping prefixes are counted once. Co-Authored-By: Claude Fable 5 --- pkg/planner/cardinality/selectivity_test.go | 49 +++++++++++++++++++++ pkg/planner/core/BUILD.bazel | 1 + pkg/planner/core/stats.go | 26 +++++++---- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/pkg/planner/cardinality/selectivity_test.go b/pkg/planner/cardinality/selectivity_test.go index 45eaa12931484..34686eec0203d 100644 --- a/pkg/planner/cardinality/selectivity_test.go +++ b/pkg/planner/cardinality/selectivity_test.go @@ -1713,6 +1713,55 @@ func TestIndexRangeEstimationWithAppendedHandleColumn(t *testing.T) { }) } +// TestIndexRangeEstimationWithTruncatedHandleRange verifies that estimation ranges pruned +// back to the declared index columns keep valid bounds. Truncating the appended handle +// dimensions widens a bound to the whole prefix, so an exclusive bound from a dropped +// dimension must become inclusive (otherwise (5 10, 5 +inf] collapses to the empty (5, 5] +// and estimates ~0 rows), and ranges collapsing to the same prefix must be merged instead +// of each contributing the full prefix row count. +func TestIndexRangeEstimationWithTruncatedHandleRange(t *testing.T) { + store := testkit.CreateMockStore(t) + tk := testkit.NewTestKit(t, store) + tk.MustExec("use test") + tk.MustExec("create table t(id bigint primary key clustered, a int, key ia(a))") + vals := make([]string, 0, 100) + for i := 1; i <= 100; i++ { + vals = append(vals, fmt.Sprintf("(%d, %d)", i, i%10)) + } + tk.MustExec("insert into t values " + strings.Join(vals, ",")) + tk.MustExec("analyze table t all columns") + + // Returns the estRows and operator info of the IndexRangeScan in the plan. + indexScanRow := func(sql string) (estRows, operatorInfo string) { + rows := tk.MustQuery("explain format='brief' " + sql).Rows() + for _, row := range rows { + if strings.Contains(row[0].(string), "IndexRangeScan") { + return row[1].(string), row[4].(string) + } + } + t.Fatalf("no IndexRangeScan in plan for %q", sql) + return "", "" + } + + // Each distinct value of a has 10 rows; the handle predicates below must estimate as if + // the handle dimension were absent, i.e. the 10 rows of a = 5. + + // Handle range with an exclusive low bound. + estRows, opInfo := indexScanRow("select * from t use index(ia) where a = 5 and id > 10") + require.Contains(t, opInfo, "range:(5 10,5 +inf]", "execution range must keep the handle dimension") + require.Equal(t, "10.00", estRows) + + // Handle range with an exclusive high bound. + estRows, opInfo = indexScanRow("select * from t use index(ia) where a = 5 and id < 10") + require.Contains(t, opInfo, "range:[5 -inf,5 10)", "execution range must keep the handle dimension") + require.Equal(t, "10.00", estRows) + + // Multiple handle points under the same prefix must not double count. + estRows, opInfo = indexScanRow("select * from t use index(ia) where a = 5 and id in (11, 22)") + require.Contains(t, opInfo, "range:[5 11,5 11], [5 22,5 22]", "execution range must keep the handle dimension") + require.Equal(t, "10.00", estRows) +} + func TestDeriveTablePathStatsNoAccessConds(t *testing.T) { store, dom := testkit.CreateMockStoreAndDomain(t) testKit := testkit.NewTestKit(t, store) diff --git a/pkg/planner/core/BUILD.bazel b/pkg/planner/core/BUILD.bazel index 4829304af1b2f..5eddff4b5f903 100644 --- a/pkg/planner/core/BUILD.bazel +++ b/pkg/planner/core/BUILD.bazel @@ -185,6 +185,7 @@ go_library( "//pkg/util/parser", "//pkg/util/plancodec", "//pkg/util/ranger", + "//pkg/util/ranger/context", "//pkg/util/rowcodec", "//pkg/util/sem", "//pkg/util/sem/compat", diff --git a/pkg/planner/core/stats.go b/pkg/planner/core/stats.go index 17082c4d632c7..9fbe407169417 100644 --- a/pkg/planner/core/stats.go +++ b/pkg/planner/core/stats.go @@ -40,6 +40,7 @@ import ( h "github.com/pingcap/tidb/pkg/util/hint" "github.com/pingcap/tidb/pkg/util/logutil" "github.com/pingcap/tidb/pkg/util/ranger" + rangerctx "github.com/pingcap/tidb/pkg/util/ranger/context" "go.uber.org/zap" ) @@ -435,28 +436,35 @@ func detachCondAndBuildRangeForPath( } } } - var estimateRanges []*ranger.Range + estimateRanges := path.Ranges if needPruneEstimateRange { // Non-unique index paths may append handle columns in `path.IdxCols` for execution ranges. // Rebuild estimation ranges with the same column set used in row-count estimation. - estimateRanges = pruneEstimateRange(path.Ranges, len(indexCols)) - } else { - estimateRanges = path.Ranges + estimateRanges, err = pruneEstimateRange(sctx.GetRangerCtx(), path.Ranges, len(indexCols)) + if err != nil { + return err + } } count, err := cardinality.GetRowCountByIndexRanges(sctx, histColl, path.Index.ID, estimateRanges, indexCols) path.CountAfterAccess, path.MinCountAfterAccess, path.MaxCountAfterAccess = count.Est, count.MinEst, count.MaxEst return err } -func pruneEstimateRange(ranges []*ranger.Range, keepColCnt int) []*ranger.Range { - estimateRanges := make([]*ranger.Range, 0, len(ranges)) +// pruneEstimateRange truncates ranges built over the index columns plus the appended handle +// columns down to keepColCnt columns, so that they align with the index statistics, which +// only cover the declared index columns. Truncating a bound widens it to the whole prefix: +// a bound that lost values must become inclusive (otherwise a range like (10 1, 10 +inf] +// would collapse to the empty (10, 10]), and ranges that collapse to the same prefix must +// be merged so the prefix rows are not counted once per pruned range. +func pruneEstimateRange(rctx *rangerctx.RangerContext, ranges []*ranger.Range, keepColCnt int) ([]*ranger.Range, error) { + estimateRanges := make(ranger.Ranges, 0, len(ranges)) for _, ran := range ranges { newRange := &ranger.Range{ LowVal: make([]types.Datum, 0, keepColCnt), HighVal: make([]types.Datum, 0, keepColCnt), Collators: make([]collate.Collator, 0, keepColCnt), - LowExclude: ran.LowExclude, - HighExclude: ran.HighExclude, + LowExclude: ran.LowExclude && len(ran.LowVal) <= keepColCnt, + HighExclude: ran.HighExclude && len(ran.HighVal) <= keepColCnt, } for idx := range min(keepColCnt, len(ran.LowVal)) { newRange.LowVal = append(newRange.LowVal, ran.LowVal[idx]) @@ -465,7 +473,7 @@ func pruneEstimateRange(ranges []*ranger.Range, keepColCnt int) []*ranger.Range } estimateRanges = append(estimateRanges, newRange) } - return estimateRanges + return ranger.UnionRanges(rctx, estimateRanges, false) } func getGeneralAttributesFromPaths(paths []*util.AccessPath, totalRowCount float64) (float64, bool) { From fd7b3f8450798a139bc8f8c89b3f2e476c7cb974 Mon Sep 17 00:00:00 2001 From: tpp Date: Thu, 9 Jul 2026 18:20:58 -0700 Subject: [PATCH 2/6] planner: credit appended handle predicates in index row count estimation For a non-unique secondary index, predicates on the handle columns become part of the execution ranges, but the path's CountAfterAccess is estimated from ranges pruned back to the declared index columns and gave those predicates no credit: a path whose benefit is the handle seek looked as expensive as one without it. Damp the pruned prefix estimate with the handle columns' selectivities using the exponential backoff series - the prefix keeps the full-weight slot and each handle column contributes sel^(1/2), sel^(1/4), ... from the most selective one - so the handle predicates tighten the estimate without assuming independence between the index columns and the primary key. A range that point-binds the declared columns plus the full handle matches at most one row because the physical key is unique, so such estimates are additionally capped by the number of ranges. Selectivity() already credits these predicates through exponential backoff, so when the damped CountAfterAccess falls below stats.RowCount, align it upward to stats.RowCount without the SelectionFactor penalty: the shortfall is expected damping, not the inconsistent-assumption case the penalty exists for. Non-point handle predicates currently receive no net credit after this alignment because expBackoffEstimation floors its result at 1/NDV of the declared index columns; lifting that floor needs the multi-column histogram bounds to be truncated first and is left as a follow-up. Co-Authored-By: Claude Fable 5 --- pkg/planner/cardinality/row_count_index.go | 108 ++++++++++++++++++++ pkg/planner/cardinality/selectivity_test.go | 40 +++++--- pkg/planner/core/stats.go | 39 ++++++- 3 files changed, 174 insertions(+), 13 deletions(-) diff --git a/pkg/planner/cardinality/row_count_index.go b/pkg/planner/cardinality/row_count_index.go index c0e9533aeec55..15855eee85879 100644 --- a/pkg/planner/cardinality/row_count_index.go +++ b/pkg/planner/cardinality/row_count_index.go @@ -16,6 +16,7 @@ package cardinality import ( "bytes" + "math" "slices" "strings" "time" @@ -568,6 +569,113 @@ func expBackoffEstimation(sctx planctx.PlanContext, idx *statistics.Index, coll return multResult, minSel, maxSel, true, nil } +// AdjustRowCountForAppendedHandleColumns damps a row count estimated from the declared +// index columns with the selectivity of the handle columns that fillIndexPath appended +// to the range columns of a non-unique index path. Index statistics only cover the +// declared columns, so prefixCount was computed from ranges pruned back to +// declaredColCnt dimensions and gives the appended handle predicates no credit; without +// an adjustment, a path whose benefit is the handle seek looks as expensive as one +// without it. Following expBackoffEstimation, the prefix estimate keeps the full-weight +// slot and each handle column contributes sel^(1/2), sel^(1/4), ... starting from the +// most selective one, which credits the handle predicates without assuming full +// independence between the index columns and the primary key. The full ranges must not +// reach the index statistics directly: bounds encoded from the appended dimensions sort +// past the truncated statistics keys and would collapse the estimate. +func AdjustRowCountForAppendedHandleColumns( + sctx planctx.PlanContext, + coll *statistics.HistColl, + ranges []*ranger.Range, + idxCols []*expression.Column, + declaredColCnt int, + prefixCount statistics.RowEstimate, +) statistics.RowEstimate { + realtimeCount := float64(coll.RealtimeCount) + if realtimeCount <= 0 || len(ranges) == 0 || len(idxCols) <= declaredColCnt { + return prefixCount + } + sels := make([]float64, 0, len(idxCols)-declaredColCnt) + for dim := declaredColCnt; dim < len(idxCols); dim++ { + col := idxCols[dim] + if col == nil || statistics.ColumnStatsIsInvalid(coll.GetCol(col.UniqueID), sctx, coll, col.UniqueID) { + continue + } + colRanges := make(ranger.Ranges, 0, len(ranges)) + allBound := true + for _, ran := range ranges { + if len(ran.LowVal) <= dim || len(ran.HighVal) <= dim { + // Some range does not constrain this dimension, so the column is not + // bound across the whole path and must not contribute selectivity. + allBound = false + break + } + colRanges = append(colRanges, &ranger.Range{ + LowVal: []types.Datum{ran.LowVal[dim]}, + HighVal: []types.Datum{ran.HighVal[dim]}, + Collators: []collate.Collator{ran.Collators[dim]}, + // The exclusion flags of a multi-column range apply to its last dimension. + LowExclude: ran.LowExclude && dim == len(ran.LowVal)-1, + HighExclude: ran.HighExclude && dim == len(ran.HighVal)-1, + }) + } + if !allBound { + continue + } + // Ranges that differ only in earlier dimensions repeat the same handle bound; + // merge them so the column row count is not summed once per range. + merged, err := ranger.UnionRanges(sctx.GetRangerCtx(), colRanges, false) + if err != nil { + continue + } + countEst, err := GetRowCountByColumnRanges(sctx, coll, col.UniqueID, merged, false) + if err != nil { + continue + } + if sel := countEst.Est / realtimeCount; sel > 0 && sel < 1 { + sels = append(sels, sel) + } + } + adjusted := prefixCount + if len(sels) > 0 { + slices.Sort(sels) + factor, indepFactor := 1.0, 1.0 + for i, sel := range sels { + indepFactor *= sel + // The prefix estimate occupies the full-weight slot, so the i-th handle + // selectivity gets weight 1/2^(i+1). + if i+1 < MaxExponentialBackoffCols { + for range i + 1 { + sel = math.Sqrt(sel) + } + factor *= sel + } + } + adjusted.Est *= factor + // Damping a usable estimate should not push it below one row. + adjusted.Est = max(adjusted.Est, min(prefixCount.Est, 1)) + // Full independence gives the most optimistic count; the unadjusted prefix + // estimate remains the upper bound in MaxEst. + adjusted.MinEst = min(adjusted.MinEst*indepFactor, adjusted.Est) + } + // A point range over the declared columns plus the full handle identifies at most + // one row, because the physical key of a non-unique index ends with the complete + // handle and is therefore unique. + fullPoints := true + for _, ran := range ranges { + if len(ran.LowVal) != len(idxCols) || len(ran.HighVal) != len(idxCols) || + !ran.IsPoint(sctx.GetRangerCtx()) { + fullPoints = false + break + } + } + if fullPoints { + pointCap := float64(len(ranges)) + adjusted.Est = min(adjusted.Est, pointCap) + adjusted.MinEst = min(adjusted.MinEst, adjusted.Est) + adjusted.MaxEst = min(adjusted.MaxEst, pointCap) + } + return adjusted +} + // outOfRangeOnIndex checks if the datum is out of the range. func outOfRangeOnIndex(idx *statistics.Index, val types.Datum) bool { if !idx.Histogram.OutOfRange(val) { diff --git a/pkg/planner/cardinality/selectivity_test.go b/pkg/planner/cardinality/selectivity_test.go index 34686eec0203d..63054914df1ba 100644 --- a/pkg/planner/cardinality/selectivity_test.go +++ b/pkg/planner/cardinality/selectivity_test.go @@ -1713,12 +1713,13 @@ func TestIndexRangeEstimationWithAppendedHandleColumn(t *testing.T) { }) } -// TestIndexRangeEstimationWithTruncatedHandleRange verifies that estimation ranges pruned -// back to the declared index columns keep valid bounds. Truncating the appended handle -// dimensions widens a bound to the whole prefix, so an exclusive bound from a dropped -// dimension must become inclusive (otherwise (5 10, 5 +inf] collapses to the empty (5, 5] -// and estimates ~0 rows), and ranges collapsing to the same prefix must be merged instead -// of each contributing the full prefix row count. +// TestIndexRangeEstimationWithTruncatedHandleRange verifies the row count estimation of +// index ranges extended with appended handle columns. The prefix over the declared index +// columns is estimated from pruned ranges that keep valid bounds (an exclusive bound from +// a dropped dimension becomes inclusive, and ranges collapsing to the same prefix merge +// instead of each contributing the full prefix row count), then the handle predicates +// receive exponential-backoff credit from their column statistics, capped at one row per +// range when the full handle is point-bound. func TestIndexRangeEstimationWithTruncatedHandleRange(t *testing.T) { store := testkit.CreateMockStore(t) tk := testkit.NewTestKit(t, store) @@ -1743,23 +1744,38 @@ func TestIndexRangeEstimationWithTruncatedHandleRange(t *testing.T) { return "", "" } - // Each distinct value of a has 10 rows; the handle predicates below must estimate as if - // the handle dimension were absent, i.e. the 10 rows of a = 5. + // Each distinct value of a has 10 rows. The estimate starts from the 10 rows of the + // a = 5 prefix (index statistics only cover the declared column), is damped by the + // handle column's selectivity with exponential backoff, and is then aligned upward to + // stats.RowCount (the Selectivity() result over all predicates) for consistency — + // without the SelectionFactor penalty that adjustCountAfterAccess would otherwise add. - // Handle range with an exclusive low bound. + // Handle range with an exclusive low bound. Before the exclusion-flag fix the pruned + // range collapsed to the empty (5, 5] and the consistency penalty produced 12.50. + // Non-point handle predicates receive no net credit yet: Selectivity() floors the + // backoff result at 1/NDV of the declared index columns, so the aligned estimate + // stays at the prefix row count. estRows, opInfo := indexScanRow("select * from t use index(ia) where a = 5 and id > 10") require.Contains(t, opInfo, "range:(5 10,5 +inf]", "execution range must keep the handle dimension") require.Equal(t, "10.00", estRows) - // Handle range with an exclusive high bound. + // Handle range with an exclusive high bound: same shape as above. estRows, opInfo = indexScanRow("select * from t use index(ia) where a = 5 and id < 10") require.Contains(t, opInfo, "range:[5 -inf,5 10)", "execution range must keep the handle dimension") require.Equal(t, "10.00", estRows) - // Multiple handle points under the same prefix must not double count. + // Point-bound handle predicates get real credit. The damped estimate + // 10 * sqrt(sel(id in (11, 22))) = 1.41 aligns to the Selectivity() result of 2.00, + // instead of the uncredited 10.00 (or 12.50 with the consistency penalty). estRows, opInfo = indexScanRow("select * from t use index(ia) where a = 5 and id in (11, 22)") require.Contains(t, opInfo, "range:[5 11,5 11], [5 22,5 22]", "execution range must keep the handle dimension") - require.Equal(t, "10.00", estRows) + require.Equal(t, "2.00", estRows) + + // A point over the index column plus the full handle matches at most one row, because + // the physical key of a non-unique index ends with the complete handle. + estRows, opInfo = indexScanRow("select * from t use index(ia) where a = 5 and id = 7") + require.Contains(t, opInfo, "range:[5 7,5 7]", "execution range must keep the handle dimension") + require.Equal(t, "1.00", estRows) } func TestDeriveTablePathStatsNoAccessConds(t *testing.T) { diff --git a/pkg/planner/core/stats.go b/pkg/planner/core/stats.go index 9fbe407169417..b8a2e801773cc 100644 --- a/pkg/planner/core/stats.go +++ b/pkg/planner/core/stats.go @@ -201,6 +201,20 @@ func fillIndexPath(ds *logicalop.DataSource, path *util.AccessPath, conds []expr return err } +// pathRangesIncludeAppendedHandle reports whether the ranges of a non-unique index path +// extend past the declared index columns into the appended handle columns. +func pathRangesIncludeAppendedHandle(path *util.AccessPath) bool { + if path.Index == nil || len(path.IdxCols) <= len(path.Index.Columns) { + return false + } + for _, ran := range path.Ranges { + if len(ran.LowVal) > len(path.Index.Columns) || len(ran.HighVal) > len(path.Index.Columns) { + return true + } + } + return false +} + // adjustCountAfterAccess adjusts the CountAfterAccess when it's less than the estimated table row count. func adjustCountAfterAccess(ds *logicalop.DataSource, path *util.AccessPath) { // If the `CountAfterAccess` is less than `stats.RowCount`, it means that paths were estimated using @@ -208,6 +222,21 @@ func adjustCountAfterAccess(ds *logicalop.DataSource, path *util.AccessPath) { // We prefer the `stats.RowCount` to provide consistency in estimation across all paths. // Add an arbitrary tolerance factor to account for comparison with floating point if (path.CountAfterAccess + cost.ToleranceFactor) < ds.StatsInfo().RowCount { + // When the ranges include the appended handle columns, the handle predicates were + // credited with deliberately damped exponential backoff, so falling below the + // independence-leaning stats.RowCount is expected rather than a sign of + // inconsistent assumptions. Align to stats.RowCount without the SelectionFactor + // penalty so the credited path is not made more expensive than an uncredited one. + if pathRangesIncludeAppendedHandle(path) { + if path.MinCountAfterAccess > 0 { + path.MinCountAfterAccess = min(path.MinCountAfterAccess, path.CountAfterAccess) + } else { + path.MinCountAfterAccess = path.CountAfterAccess + } + path.CountAfterAccess = ds.StatsInfo().RowCount + path.MaxCountAfterAccess = max(path.CountAfterAccess, path.MaxCountAfterAccess) + return + } // Store the MinCountAfterAccess "before" adjusting the "CountAfterAccess". This can be used to differentiate // the "Min" estimate for each index/inthandle path when CountAfterAccess has been equalized. if path.MinCountAfterAccess > 0 { @@ -446,8 +475,16 @@ func detachCondAndBuildRangeForPath( } } count, err := cardinality.GetRowCountByIndexRanges(sctx, histColl, path.Index.ID, estimateRanges, indexCols) + if err != nil { + return err + } + if needPruneEstimateRange { + // The pruned estimate gives the appended handle predicates no credit; damp it + // with the handle columns' selectivities. + count = cardinality.AdjustRowCountForAppendedHandleColumns(sctx, histColl, path.Ranges, path.IdxCols, len(indexCols), count) + } path.CountAfterAccess, path.MinCountAfterAccess, path.MaxCountAfterAccess = count.Est, count.MinEst, count.MaxEst - return err + return nil } // pruneEstimateRange truncates ranges built over the index columns plus the appended handle From 43889721c2c3d41011630fed5a15586a4cb68c0b Mon Sep 17 00:00:00 2001 From: tpp <146148086+terry1purcell@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:35:31 -0700 Subject: [PATCH 3/6] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/planner/cardinality/row_count_index.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/planner/cardinality/row_count_index.go b/pkg/planner/cardinality/row_count_index.go index 15855eee85879..76c25017c9ed6 100644 --- a/pkg/planner/cardinality/row_count_index.go +++ b/pkg/planner/cardinality/row_count_index.go @@ -650,7 +650,7 @@ func AdjustRowCountForAppendedHandleColumns( } } adjusted.Est *= factor - // Damping a usable estimate should not push it below one row. + // Damping should not push the estimate below 1 row unless the prefix estimate is already < 1. adjusted.Est = max(adjusted.Est, min(prefixCount.Est, 1)) // Full independence gives the most optimistic count; the unadjusted prefix // estimate remains the upper bound in MaxEst. From 6de88b8a0332edea3118bbcdd7127c550fbdde66 Mon Sep 17 00:00:00 2001 From: tpp Date: Mon, 13 Jul 2026 12:15:19 -0700 Subject: [PATCH 4/6] planner: address review comments on appended handle adjustment Rename the idxCols parameter of AdjustRowCountForAppendedHandleColumns to idxColsWithHandle to distinguish it from the declared-columns-only idxCols parameters elsewhere in this file, and document the contract that the appended dimensions cover the complete integer handle, which the full-point one-row cap relies on. Co-Authored-By: Claude Fable 5 --- pkg/planner/cardinality/row_count_index.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pkg/planner/cardinality/row_count_index.go b/pkg/planner/cardinality/row_count_index.go index 76c25017c9ed6..132a29b153507 100644 --- a/pkg/planner/cardinality/row_count_index.go +++ b/pkg/planner/cardinality/row_count_index.go @@ -581,21 +581,26 @@ func expBackoffEstimation(sctx planctx.PlanContext, idx *statistics.Index, coll // independence between the index columns and the primary key. The full ranges must not // reach the index statistics directly: bounds encoded from the appended dimensions sort // past the truncated statistics keys and would collapse the estimate. +// +// idxColsWithHandle must be the declared index columns followed by the complete handle: +// fillIndexPath only appends the handle when the table's primary key is the single +// integer handle column, so the dimensions past declaredColCnt always identify a row +// exactly. A partial handle suffix would break the full-point cap below. func AdjustRowCountForAppendedHandleColumns( sctx planctx.PlanContext, coll *statistics.HistColl, ranges []*ranger.Range, - idxCols []*expression.Column, + idxColsWithHandle []*expression.Column, declaredColCnt int, prefixCount statistics.RowEstimate, ) statistics.RowEstimate { realtimeCount := float64(coll.RealtimeCount) - if realtimeCount <= 0 || len(ranges) == 0 || len(idxCols) <= declaredColCnt { + if realtimeCount <= 0 || len(ranges) == 0 || len(idxColsWithHandle) <= declaredColCnt { return prefixCount } - sels := make([]float64, 0, len(idxCols)-declaredColCnt) - for dim := declaredColCnt; dim < len(idxCols); dim++ { - col := idxCols[dim] + sels := make([]float64, 0, len(idxColsWithHandle)-declaredColCnt) + for dim := declaredColCnt; dim < len(idxColsWithHandle); dim++ { + col := idxColsWithHandle[dim] if col == nil || statistics.ColumnStatsIsInvalid(coll.GetCol(col.UniqueID), sctx, coll, col.UniqueID) { continue } @@ -658,10 +663,11 @@ func AdjustRowCountForAppendedHandleColumns( } // A point range over the declared columns plus the full handle identifies at most // one row, because the physical key of a non-unique index ends with the complete - // handle and is therefore unique. + // handle and is therefore unique. This relies on the contract above: the appended + // dimensions cover the complete handle, not a prefix of a multi-column primary key. fullPoints := true for _, ran := range ranges { - if len(ran.LowVal) != len(idxCols) || len(ran.HighVal) != len(idxCols) || + if len(ran.LowVal) != len(idxColsWithHandle) || len(ran.HighVal) != len(idxColsWithHandle) || !ran.IsPoint(sctx.GetRangerCtx()) { fullPoints = false break From 18f3c60e7efaaa5efa84642f29160b5ce146739b Mon Sep 17 00:00:00 2001 From: tpp Date: Wed, 15 Jul 2026 07:08:18 +1000 Subject: [PATCH 5/6] planner: test that unsigned handles are excluded from appended handle estimation Co-Authored-By: Claude Fable 5 --- pkg/planner/cardinality/selectivity_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/planner/cardinality/selectivity_test.go b/pkg/planner/cardinality/selectivity_test.go index 63054914df1ba..0ae8e698c8682 100644 --- a/pkg/planner/cardinality/selectivity_test.go +++ b/pkg/planner/cardinality/selectivity_test.go @@ -1776,6 +1776,22 @@ func TestIndexRangeEstimationWithTruncatedHandleRange(t *testing.T) { estRows, opInfo = indexScanRow("select * from t use index(ia) where a = 5 and id = 7") require.Contains(t, opInfo, "range:[5 7,5 7]", "execution range must keep the handle dimension") require.Equal(t, "1.00", estRows) + + // An unsigned int handle is stored in the index key suffix in signed-encoded order, + // which wraps at the int64 boundary: values in [MaxInt64+1, MaxUint64] sort before + // [0, MaxInt64]. fillIndexPath therefore never appends an unsigned handle to the + // index columns, so unsigned handle predicates must stay out of the index ranges and + // receive no appended-handle credit: the estimate remains the prefix row count. + tk.MustExec("create table tu(id bigint unsigned primary key clustered, a int, key ia(a))") + tk.MustExec("insert into tu values " + strings.Join(vals, ",")) + tk.MustExec("analyze table tu all columns") + estRows, opInfo = indexScanRow("select * from tu use index(ia) where a = 5 and id in (11, 22)") + require.Contains(t, opInfo, "range:[5,5]", "unsigned handle must not extend the execution range") + require.NotContains(t, opInfo, "5 11", "unsigned handle must not extend the execution range") + require.Equal(t, "10.00", estRows) + estRows, opInfo = indexScanRow("select * from tu use index(ia) where a = 5 and id > 10") + require.Contains(t, opInfo, "range:[5,5]", "unsigned handle must not extend the execution range") + require.Equal(t, "10.00", estRows) } func TestDeriveTablePathStatsNoAccessConds(t *testing.T) { From 181d87faa3abecdea768f7901e8c9d49928142df Mon Sep 17 00:00:00 2001 From: tpp Date: Fri, 17 Jul 2026 09:14:47 +1000 Subject: [PATCH 6/6] build: regenerate bazel metadata for bindinfo tests shard count Co-Authored-By: Claude Fable 5 --- pkg/bindinfo/tests/BUILD.bazel | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/bindinfo/tests/BUILD.bazel b/pkg/bindinfo/tests/BUILD.bazel index 732e64505debd..a1a162f48d39b 100644 --- a/pkg/bindinfo/tests/BUILD.bazel +++ b/pkg/bindinfo/tests/BUILD.bazel @@ -11,7 +11,7 @@ go_test( ], flaky = True, race = "on", - shard_count = 25, + shard_count = 26, deps = [ "//pkg/bindinfo", "//pkg/domain",