Skip to content

Empty-partition placeholder histograms still trigger terror.Log via (*Time).Sub during plan-time cardinality estimation in v8.5.6 (#56480 follow-up) #68527

Closed

Description

@developer-Bushido

Summary

Issue #56480 reported a terror.Log flood of [types:1292]Incorrect time value: '{0 0 0 0 0 0 0}' originating from statistics.convertDatumToScalar. PR #52615 (merged and cherry-picked into v8.5.x — present in v8.5.6, commit ae18096e02) addresses one call site, but the same root cause is still reachable from a different code path at plan time, through (*types.Time).Sub at pkg/types/time.go:705. The fix in #52615 did not cover this path.

We are seeing the same error spammed ~6 GB/hour per tidb-server instance on a v8.5.6 cluster, filling disks within ~30 hours. The trigger is a partitioned table where some partitions are permanently empty, combined with an OLTP query that runs partition:all and pulls OutOfRangeRowCount for every partition (including empty ones whose stats_histograms row is a placeholder with a zero-value types.Time bound).

This is a regression-in-effect of #56480 even though the literal PR #52615 was applied. The bug is one layer higher in the cardinality stack.

Environment

Field Value
TiDB release version v8.5.6
Edition Community
Build time 2026-04-14
Git commit ae18096e023780bb56bfce33698abec0d4640d0a
Cluster shape 9 tidb-server + PD + TiKV + TiFlash, baremetal, CentOS
tidb_enable_auto_analyze tested both ON and OFF — error rate unchanged
Confirmed PR #52615 present yes (cherry-pick 1d698c78)

Stack trace (verbatim from one occurrence, 1 of 82 830 in a single rotated 300 MB log file)

[ERROR] [terror.go:324] ["encountered error"] [error="[types:1292]Incorrect time value: '{0 0 0 0 0 0 0}'"]
[stack="github.com/pingcap/tidb/pkg/parser/terror.Log
            pkg/parser/terror/terror.go:324
        github.com/pingcap/tidb/pkg/types.(*Time).Sub
            pkg/types/time.go:705
        github.com/pingcap/tidb/pkg/statistics.convertDatumToScalar
            pkg/statistics/scalar.go:81
        github.com/pingcap/tidb/pkg/statistics.(*Histogram).OutOfRangeRowCount
            pkg/statistics/histogram.go:1002
        github.com/pingcap/tidb/pkg/planner/cardinality.GetColumnRowCount
            pkg/planner/cardinality/row_count_column.go:306
        github.com/pingcap/tidb/pkg/planner/cardinality.GetRowCountByColumnRanges
            pkg/planner/cardinality/row_count_column.go:71
        github.com/pingcap/tidb/pkg/planner/cardinality.expBackoffEstimation
            pkg/planner/cardinality/row_count_index.go:559
        github.com/pingcap/tidb/pkg/planner/cardinality.getIndexRowCountForStatsV2
            pkg/planner/cardinality/row_count_index.go:297
        github.com/pingcap/tidb/pkg/planner/cardinality.GetRowCountByIndexRanges
            pkg/planner/cardinality/row_count_index.go:85
        github.com/pingcap/tidb/pkg/planner/core.detachCondAndBuildRangeForPath
            pkg/planner/core/stats.go:411
        github.com/pingcap/tidb/pkg/planner/core.fillIndexPath
            pkg/planner/core/stats.go:202
        github.com/pingcap/tidb/pkg/planner/core.deriveStats4DataSource
            pkg/planner/core/stats.go:142
        ... (DataSource.DeriveStats / RecursiveDeriveStats frames, then physicalOptimize -> DoOptimize -> executor.Compile -> session.ExecuteStmt -> server.handleQuery) ..."]

Compared to #56480 / PR #52615:

Reproduction (sketch)

We have not yet reduced this to a runnable bug-bash test, but the conditions are precise enough that it should be straightforward to construct:

  1. Create a partitioned table whose partitioning function admits permanently-empty partitions, with a multi-column UNIQUE/secondary key whose last column is DATETIME. Conceptually:
    CREATE TABLE t_part (
      id      BIGINT NOT NULL AUTO_INCREMENT,
      a       VARBINARY(64) NOT NULL,
      b       INT NOT NULL,
      ts      DATETIME NOT NULL,
      payload JSON NULL,
      PRIMARY KEY (id, b) /*T![clustered_index] CLUSTERED */,
      UNIQUE KEY uk_a_b_ts (a, b, ts)
    ) PARTITION BY HASH (b) PARTITIONS 16;
  2. Populate only a subset of partitions with rows; leave the rest empty.
  3. ANALYZE TABLE t_part; — partitions with no data get placeholder rows in mysql.stats_histograms (distinct_count=0, null_count=0, no buckets in mysql.stats_buckets). The histogram's Bounds.GetRow(0) for the ts column returns the Go zero value types.Time{}.
  4. Run a high-QPS OLTP template that does:
    SELECT id, payload FROM t_part WHERE a = ? AND b = ? ORDER BY ts DESC LIMIT 1;
    The optimizer chooses IndexLookUp with partition:all, IndexRangeScan ... keep order:true, desc. For every partition (including empties), cardinality is estimated, and for the empty ones the (ts) column path drives the planner into OutOfRangeRowCount against a zero-value bound.
  5. One terror.Log line (~3 KB stack) per query, per affected partition.

We see this at ~750 QPS on the production template, which produces the ~6 GB/hour log growth quoted above.

What we verified does NOT fix it

Mitigation Outcome
ANALYZE TABLE <t> (full) Stats rebuilt cleanly for non-empty partitions, SHOW STATS_HEALTHY >= 99, stats_buckets populated correctly. Error rate unchanged. Empty partitions have no rows to sample -> placeholder re-created with same zero-time bound.
ANALYZE TABLE <t> UPDATE HISTOGRAM ON <ts_col> Same as above.
DROP STATS <t> + re-ANALYZE Same.
Rolling restart of all tidb-server instances (cache invalidation) Brief dip during reconnect window, then full rate resumes once stats reload from disk.
SET GLOBAL tidb_enable_auto_analyze = OFF No effect — the error is plan-time, not analyze-time.
Optimizer hints USE_INDEX(<other_idx>) / IGNORE_INDEX(uk_a_b_ts) Silently rejected. The 3-column unique key satisfies ORDER BY ts DESC LIMIT 1 via keep order:true, desc and the optimizer considers it cost-strictly-better.
Upgrading to v8.5.6 (which includes PR #52615) No effect — different call path, see stack above.

We have no in-engine workaround. Today we keep nodes alive with an external cron that prunes rotated tidb-*.log / tidb_slow_query-*.log files older than 60 minutes every 10 minutes — purely to outpace the flood. On our side, the durable plan is to remove partitioning from the affected legacy table entirely (side-table + cutover, no in-place ALTER), which should eliminate the empty-partition condition that triggers the placeholder histogram. That work is independent of TiDB and will not help anyone else hitting the same shape.

Production impact

On a 9-node cluster with / = 199 GB and tidb_log_level = 'info':

  • Rotation rate: ~6 GB/hour per tidb-server (one 300 MB tidb-YYYY-...log every ~3 minutes).
  • One rotated 300 MB file contains 82 830 copies of the same stack trace (verified by grep -c 'types:1292').
  • [log.file] max-days=7 / max-size=300 (TiDB defaults plus our overrides) cannot keep up — 6 GB/h x 7d ~= 1 TB > disk size.
  • Disks fill from 0 -> 100% in ~30 hours.

The blast radius is anyone running a partitioned table with permanently-empty partitions and a DATETIME column in any index that the planner traverses with partition:all. This is not exotic — any time-bucketed or hash-bucketed partitioning with sparse buckets and a DATETIME secondary-key column will hit it.

Suggested fix directions

We do not have a PR ready, but the obvious candidates seem to be (in increasing order of invasiveness):

  1. Mirror PR statistics: reduce allocation of types.Context #52615 at the second site. Suppress the terror flag for the (*Time).Sub call at pkg/types/time.go:705 when invoked from convertDatumToScalar via OutOfRangeRowCount. The bound being a zero types.Time is a known sentinel for "placeholder histogram", not a real data error — it shouldn't produce ERROR-level logs.
  2. Short-circuit OutOfRangeRowCount on placeholder histograms. In pkg/statistics/histogram.go:1002, if the histogram has 0 buckets (i.e. stats_buckets is empty for this (table_id, is_index, hist_id)), skip the bound-difference calculation entirely and return 0 (or defaultEstimation) without dereferencing Bounds. This avoids the bad call site rather than suppressing its symptom.
  3. Skip stats lookup for partitions with NDV = 0. In pkg/planner/cardinality/row_count_index.go / row_count_column.go, when iterating partitions for a partition:all plan, detect partitions whose stats_histograms.distinct_count = 0 and use a fixed pseudo-estimate instead of walking their histograms.

Direction (2) feels the most surgical and least likely to regress other estimates. We would be happy to test a patch on the affected workload.

Cross-references

Notes for whoever picks this up

Anonymised supporting artefacts (full ~3 KB live stack, mysql.stats_histograms / stats_buckets per-partition snapshot demonstrating the placeholder-histogram condition, a slow-log entry for the offending template, and EXPLAIN ANALYZE showing partition:all + keep order:true, desc) are posted as a follow-up comment below.

Activity

  1. added
    contributionThis PR is from a community contributor.
    first-time-contributorIndicates that the PR was contributed by an external member and is a first-time contributor.
    on May 20, 2026
  2. ti-chi-bot commented on May 20, 2026

    @ti-chi-bot

    Welcome @developer-Bushido! It looks like this is your first issue to pingcap/tidb 🎉

  3. developer-Bushido commented on May 20, 2026

    @developer-Bushido
    Author

    Additional anonymised artefacts from the same v8.5.6 cluster as the original report.

    All identifiers have been substituted: schema/table/column/index names → generic placeholders; user/service names → app_user; IPs → <ip>; long hex strings (hash literals, digests) → <hash>; resource group → <rg>; large encoded plan blobs in slow-log → <elided>. Timestamps and numeric performance counters are kept verbatim because they are not identifying.


    1. Full ~3 KB terror stack (verbatim, single occurrence)

    github.com/pingcap/tidb/pkg/parser/terror.Log
            /workspace/source/tidb/pkg/parser/terror/terror.go:324
    github.com/pingcap/tidb/pkg/types.(*Time).Sub
            /workspace/source/tidb/pkg/types/time.go:705
    github.com/pingcap/tidb/pkg/statistics.convertDatumToScalar
            /workspace/source/tidb/pkg/statistics/scalar.go:81
    github.com/pingcap/tidb/pkg/statistics.(*Histogram).OutOfRangeRowCount
            /workspace/source/tidb/pkg/statistics/histogram.go:1002
    github.com/pingcap/tidb/pkg/planner/cardinality.GetColumnRowCount
            /workspace/source/tidb/pkg/planner/cardinality/row_count_column.go:306
    github.com/pingcap/tidb/pkg/planner/cardinality.GetRowCountByColumnRanges
            /workspace/source/tidb/pkg/planner/cardinality/row_count_column.go:71
    github.com/pingcap/tidb/pkg/planner/cardinality.expBackoffEstimation
            /workspace/source/tidb/pkg/planner/cardinality/row_count_index.go:559
    github.com/pingcap/tidb/pkg/planner/cardinality.getIndexRowCountForStatsV2
            /workspace/source/tidb/pkg/planner/cardinality/row_count_index.go:297
    github.com/pingcap/tidb/pkg/planner/cardinality.GetRowCountByIndexRanges
            /workspace/source/tidb/pkg/planner/cardinality/row_count_index.go:85
    github.com/pingcap/tidb/pkg/planner/core.detachCondAndBuildRangeForPath
            /workspace/source/tidb/pkg/planner/core/stats.go:411
    github.com/pingcap/tidb/pkg/planner/core.fillIndexPath
            /workspace/source/tidb/pkg/planner/core/stats.go:202
    github.com/pingcap/tidb/pkg/planner/core.deriveStats4DataSource
            /workspace/source/tidb/pkg/planner/core/stats.go:142
    github.com/pingcap/tidb/pkg/planner/core/operator/logicalop.(*DataSource).DeriveStats
            /workspace/source/tidb/pkg/planner/core/operator/logicalop/logical_datasource.go:365
    github.com/pingcap/tidb/pkg/planner/core/operator/logicalop.(*BaseLogicalPlan).RecursiveDeriveStats
            /workspace/source/tidb/pkg/planner/core/operator/logicalop/base_logical_plan.go:211
    ... (deeper RecursiveDeriveStats frames, then physicalOptimize -> DoOptimize -> executor.Compile -> session.ExecuteStmt -> server.handleQuery)
    

    Note the path through both GetRowCountByIndexRanges (which is what PR #52615 partially addressed at the histogram-load site) and GetColumnRowCount -> OutOfRangeRowCount (the still-unfixed site, called via expBackoffEstimation). The terror itself is logged through (*Time).Sub @ pkg/types/time.go:705.

    2. mysql.stats_histograms / mysql.stats_buckets snapshot per partition

    app_db.t_part is PARTITION BY HASH(<b_partcol>) PARTITIONS 12, and 9 of 12 buckets currently contain no rows. Run immediately after a fresh ANALYZE TABLE app_db.t_part:

    SELECT 
      p.partition_name,
      p.table_rows,
      (SELECT count(DISTINCT h.hist_id) FROM mysql.stats_histograms h
        WHERE h.table_id=p.tidb_partition_id AND h.is_index=0)                        AS col_hists,
      (SELECT count(DISTINCT h.hist_id) FROM mysql.stats_histograms h
        WHERE h.table_id=p.tidb_partition_id AND h.is_index=0 AND h.distinct_count=0) AS col_hists_zero_ndv,
      (SELECT count(*) FROM mysql.stats_buckets b
        WHERE b.table_id=p.tidb_partition_id AND b.is_index=0)                        AS n_buckets_col,
      (SELECT count(*) FROM mysql.stats_buckets b
        WHERE b.table_id=p.tidb_partition_id AND b.is_index=1)                        AS n_buckets_idx
    FROM information_schema.partitions p
    WHERE p.table_schema='app_db' AND p.table_name='t_part'
    ORDER BY p.tidb_partition_id;

    Result:

    +----------------+------------+-----------+--------------------+---------------+---------------+
    | partition_name | table_rows | col_hists | col_hists_zero_ndv | n_buckets_col | n_buckets_idx |
    +----------------+------------+-----------+--------------------+---------------+---------------+
    | p0             |          0 |         7 |                  7 |             0 |             0 |
    | p1             |          0 |         7 |                  7 |             0 |             0 |
    | p2             |          0 |         7 |                  7 |             0 |             0 |
    | p3             |       ~5e8 |         7 |                  2 |          1024 |           768 |
    | p4             |       ~5e8 |         7 |                  2 |          1024 |           768 |
    | p5             |       ~5e8 |         7 |                  2 |          1024 |           768 |
    | p6             |          0 |         7 |                  7 |             0 |             0 |
    | p7             |          0 |         7 |                  7 |             0 |             0 |
    | p8             |          0 |         7 |                  7 |             0 |             0 |
    | p9             |          0 |         7 |                  7 |             0 |             0 |
    | p10            |          0 |         7 |                  7 |             0 |             0 |
    | p11            |          0 |         7 |                  7 |             0 |             0 |
    +----------------+------------+-----------+--------------------+---------------+---------------+
    

    Reading: each of the 9 empty partitions has all 7 column histograms present in mysql.stats_histograms (one per column, col_hists=7) but all of them have distinct_count=0 (col_hists_zero_ndv=7) and zero rows in mysql.stats_buckets (n_buckets_col=0, n_buckets_idx=0). These are placeholder rows that ANALYZE cannot fill because there is no data to sample. The placeholder's Bounds.GetRow(0) for any DATETIME column returns the Go zero value types.Time{}, which is exactly the value reported as '{0 0 0 0 0 0 0}' in the terror message.

    For comparison, the 3 populated partitions (p3, p4, p5) have proper buckets (1024 per column, 768 per index — TiDB defaults).

    3. Slow-log entry for one offending query (one of ~750 QPS on this template)

    # Time: 2026-05-11T10:28:45.276943013Z
    # Txn_start_ts: 466221878193422463
    # User@Host: app_user[app_user] @ <ip> [<ip>]
    # Conn_ID: 969258904
    # Query_time: 1.339301166
    # Parse_time: 0.000037419
    # Compile_time: 0.000384539
    # Rewrite_time: 0.000069136
    # Optimize_time: 0.000236156
    # Wait_TS: 0.000711162
    # Cop_time: 1.337... Process_time: 0.001415265 Wait_time: 0.000485314 Request_count: 13 Process_keys: 2 Total_keys: 14
    # DB: app_db
    # Index_names: [t_part:a]
    # Is_internal: false
    # Digest: <hash>
    # Stats: t_part global:<txn-ts>[<row-count>;<modify-count>]
    # Num_cop_tasks: 13
    # Cop_proc_avg: 0.000108866 Cop_proc_p90: 0.000197451 Cop_proc_max: 0.000527024 Cop_proc_addr: <ip>:20160
    # Cop_wait_avg: 3.7331e-05 Cop_wait_p90: 4.5657e-05 Cop_wait_max: 4.6167e-05 Cop_wait_addr: <ip>:20160
    # Mem_max: 26597
    # Prepared: false
    # Plan_from_cache: false
    # Plan_from_binding: false
    # KV_total: 1.344832916
    # PD_total: 0.000703846
    # Backoff_total: 0
    # Result_rows: 1
    # Warnings: [{"Level":"Note","Message":"[t_part,t_part(tiflash),idx_a_b_global] remain after pruning paths for t_part given Prop{SortItems: [], TaskTp: copSingleReadTask}","IsExtra":true},
                {"Level":"Note","Message":"[t_part,t_part(tiflash),idx_a_b_global] remain after pruning paths for t_part given Prop{SortItems: [], TaskTp: copMultiReadTask}","IsExtra":true},
                {"Level":"Note","Message":"[t_part(tiflash)] remain after pruning paths for t_part given Prop{SortItems: [], TaskTp: mppTask}","IsExtra":true}]
    # Succ: true
    # Plan: tidb_decode_plan('<elided ~3 KB base64>')
    # Plan_digest: <hash>
    # Binary_plan: tidb_decode_binary_plan('<elided ~3 KB base64>')
    # Resource_group: <rg>
    # Request_unit_read: 7.724165780273435
    # Tikv_cpu_time: 0.001415265
    use app_db;
    SELECT payload, ts FROM t_part WHERE a = '<hash>' AND b = 1 ORDER BY ts DESC LIMIT 1;
    

    Worth highlighting: this is Result_rows: 1, Process_keys: 2, returning a single row, but Num_cop_tasks: 13 and KV_total: 1.34s — 13 cop tasks for a one-row lookup because the plan does partition:all across all 12 partitions plus one extra round. Most of the wall-clock is spent fanning out to empty partitions and unwinding their (placeholder) stats during planning + cop_task dispatch.

    4. EXPLAIN ANALYZE (synthetic 64-char literal, no row match — plan structure is what matters)

    *************************** 1. row ***************************
                id: Projection_7
           estRows: 1.00
           actRows: 0
              task: root
     access object:
    execution info: time:1.69ms, loops:1, RU:6.28, Concurrency:OFF
     operator info: app_db.t_part.payload, app_db.t_part.ts
            memory: 1.98 KB
    *************************** 2. row ***************************
                id: └─Projection_27
           estRows: 1.00
           actRows: 0
              task: root
     access object:
    execution info: time:1.69ms, loops:1, Concurrency:OFF
     operator info: app_db.t_part.a, app_db.t_part.b, app_db.t_part.payload, app_db.t_part.ts
    *************************** 3. row ***************************
                id:   └─IndexLookUp_26
           estRows: 1.00
           actRows: 0
              task: root
     access object: partition:all
    execution info: time:1.68ms, loops:1
     operator info: limit embedded(offset:0, count:1)
    *************************** 4. row ***************************
                id:     ├─Limit_25(Build)
           estRows: 1.00
           actRows: 0
              task: cop[tikv]
     access object:
    execution info: time:1.5ms, loops:1, cop_task: {num: 12, max: 1.38ms, min: 480.4µs, avg: 818.4µs, p95: 1.38ms, tot_proc: 1.75ms, tot_wait: 428.6µs, copr_cache_hit_ratio: 0.00, build_task_duration: 46.8µs, max_distsql_concurrency: 1}, rpc_info:{Cop:{num_rpc:12, total_time:9.69ms}}, tikv_task:{proc max:1ms, min:0s, avg: 250µs, p80:1ms, p95:1ms, iters:12, tasks:12}
     operator info: offset:0, count:1
    *************************** 5. row ***************************
                id:     │ └─IndexRangeScan_23
           estRows: 1.00
           actRows: 0
              task: cop[tikv]
     access object: table:t_part, index:a(a, b, ts)
    execution info: tikv_task:{proc max:1ms, min:0s, avg: 250µs, p80:1ms, p95:1ms, iters:12, tasks:12}
     operator info: range:["<elided byte-encoded key>" 1, "<elided byte-encoded key>" 1], keep order:true, desc
    *************************** 6. row ***************************
                id:     └─TableRowIDScan_24(Probe)
           estRows: 1.00
           actRows: 0
              task: cop[tikv]
     access object: table:t_part
     operator info: keep order:false
    

    Key observations for the diagnosis:

    • IndexLookUp_26: access object: partition:all — every read does cardinality estimation against all 12 partitions, including the 9 empty ones documented in artefact parser: add gitignore #2.
    • cop_task: {num: 12, ..., iters:12, tasks:12} and rpc_info:{Cop:{num_rpc:12, ...}} — the partition fan-out is real at execution time too, not only at plan time.
    • IndexRangeScan_23 ... keep order:true, desc on (a, b, ts) — the index walk preserves order on the ts (DATETIME) column to satisfy ORDER BY ts DESC LIMIT 1. This is the cardinality path that drops into OutOfRangeRowCount against the empty-partition placeholder histogram.

    Combined: artefact #2 shows the placeholder histograms exist on 9 partitions; artefact #4 shows the plan touches all 12; artefact #1 shows where in the planner the placeholder is read; artefact #3 shows the production query template that drives ~750 QPS of these together.

  4. truffle-dev commented on May 20, 2026

    @truffle-dev

    Some HEAD-sha pointers (12bf86642f81cca7c7d62ea8dafe7b295d6febbb) on where the path lives and one citation correction:

    The PR that closed #56480 was actually #57300 ("statistics: avoid throw error when data contains invalid date value"), not #52615 (which is "statistics: reduce allocation of types.Context" and closes #52342). #57300 was a one-line patch on statistics/scalar.go that added AllowInvalidDate: true, IgnoreZeroInDate: true to the stmtctx.StatementContext handed to valueTime.Sub. After the planner-context refactor, that became the package-level UTCWithAllowInvalidDateCtx at pkg/statistics/scalar.go:50, defined with types.DefaultStmtFlags|types.FlagIgnoreInvalidDateErr|types.FlagIgnoreZeroInDateErr. So the context still carries the suppression intent the original fix shipped.

    The reason the suppression is moot at HEAD is one screen up from the terror site. (*Time).Sub at pkg/types/time.go:701-710 only consults ctx.Location() to forward into t.GoTime; it never reads ctx.Flags(). So the two terror.Log(errors.Trace(err)) calls at pkg/types/time.go:705,707 fire unconditionally on any GoTime error, regardless of whether the caller set FlagIgnoreInvalidDateErr / FlagIgnoreZeroInDateErr. The intent of #57300 is defeated by a method that doesn't honor the flags it was handed. A minimal mirror would be to guard the two terror.Log calls with ctx.Flags().IgnoreInvalidDate() / IgnoreZeroInDate() so the suppression contract works as the original fix shipped.

    Two secondary notes on the diagnosis:

    • The hg.Len() == 0 early-return at pkg/statistics/histogram.go:1110 does exist (it's at pkg/statistics/histogram.go:946 on release-8.5 too), so the histogram reaching convertDatumToScalar at histogram.go:1176 (= the line 1002 site cited in v8.5.6) must have Len() > 0. The slow log shows # Stats: t_part global:..., so the planner is reading the merged global histogram, not the per-partition placeholder. The global has buckets from the populated partitions; whatever the partition→global merge contributes from the 9 empty placeholders is what puts a zero-coreTime bound at Bounds.GetRow(0). So direction (3) — "skip stats for NDV=0 partitions" — would need to act during partition→global merging, not at planner stats-lookup time.

    • The Sub branch that hits the terror only fires when t.Type() == mysql.TypeTimestamp && t1.Type() == mysql.TypeTimestamp; the else branch is calcTimeTimeDiff with no terror.Log reachable. The repro sketch in the issue body uses ts DATETIME NOT NULL, but a DATETIME column would take the else branch and not produce this stack. The production column is likely TIMESTAMP, worth confirming in the actual schema.

  5. lanfuchao commented on Jun 15, 2026

    @lanfuchao

    Fix approach: skip OutOfRangeRowCount for empty-partition placeholder histograms at the call site

    After tracing the full call path, we believe the most correct fix is not to suppress the terror.Log in (*types.Time).Sub (that log is legitimately useful as a signal for corrupt/invalid time values in real data), and not to add a zero-value guard in convertDatumToScalar (that only treats the symptom).

    The root cause is that empty partitions should never reach OutOfRangeRowCount in the first place. An empty partition has a placeholder histogram with Len() == 0 (no buckets) — there is no statistical data, so out-of-range estimation is meaningless and should return 0 immediately without touching Bounds.

    Why the existing hg.Len() == 0 guard in OutOfRangeRowCount doesn't help

    The placeholder histogram for an empty partition has len(Buckets) == 0 (so Len() == 0), but Bounds.NumRows() > 0 — the Bounds chunk still contains a zero-value types.Time{} entry. The current early-return in OutOfRangeRowCount checks hg.Len() == 0 correctly, but the real execution path reaches OutOfRangeRowCount via getIndexRowCountForStatsV2 after outOfRangeOnIndex returns false for an empty histogram (because OutOfRange also short-circuits on Len() == 0, so the outer guard doesn't fire either). The code then falls through to the c.Histogram.OutOfRangeRowCount / idx.Histogram.OutOfRangeRowCount calls — and for the column-stats path, the c.Histogram.Len() > 0 guard already exists; the index-stats path is the missing case.

    Proposed fix: guard OutOfRangeRowCount calls with Len() > 0 in getIndexRowCountForStatsV2

    In pkg/planner/cardinality/row_count_index.go, inside the out-of-range block of getIndexRowCountForStatsV2:

    // Stats version 2 path
    if isSingleColRange && c != nil && c.Histogram.NDV > 0 && c.Histogram.Len() > 0 {
        // already guarded — column histogram path is fine
        count.Add(c.Histogram.OutOfRangeRowCount(...))
    } else if idx.Histogram.Len() > 0 { // <-- ADD THIS GUARD (was unconditional before)
        histNDV -= int64(idx.TopN.Num())
        count.Add(idx.Histogram.OutOfRangeRowCount(...))
    }
    // empty-partition case: idx.Histogram.Len() == 0 → skip silently, contribute 0
    
    // Stats version 1 path (else branch)
    if idx.Histogram.Len() > 0 { // <-- ADD THIS GUARD
        count.Add(idx.Histogram.OutOfRangeRowCount(...))
    }

    This is also consistent with the existing column path in getColumnRowCount (row_count_column.go), where c.OutOfRange already returns false for empty histograms and the path is safe — the index path just needs the same treatment.

    Why this is correct semantically

    An empty partition has zero rows. Its contribution to cardinality estimation for any range is 0. Calling OutOfRangeRowCount on a placeholder histogram whose only content is a zero-value bound sentinel is both semantically wrong (there is nothing out of range when there are no rows) and the proximate cause of the terror.Log flood. Gating on Len() > 0 is the minimal, surgical fix that:

    1. Does not touch the terror.Log behavior (the log remains correct for real invalid-date data)
    2. Does not change estimation for non-empty partitions
    3. Fixes the error log flood by preventing the code from ever dereferencing zero-value Bounds for placeholder histograms
    4. Produces the correct estimate (0) for empty partitions

    Happy to test a patch against the production workload if that would help move this forward.

  6. winoros commented on Jun 24, 2026

    @winoros
    Member

    Closing this as a duplicate of #69440.

    This issue is a concrete production trigger of the same root cause: TIMESTAMP bounds in statistics/cardinality estimation can reach statistics.convertDatumToScalar -> types.Time.Sub -> GoTime -> terror.Log, so TiDB emits ERROR-level stacks during plan-time cardinality estimation while the optimizer continues.

    #69440 tracks the broader root cause and includes both:

    • the empty-partition placeholder-histogram case reported here, and
    • a non-partitioned-table reproduction where a zero TIMESTAMP enters a Stats V2 histogram bucket bound.

    Please continue the discussion in #69440.

  7. winoros commented on Jun 24, 2026

    @winoros
    Member

    Closed as duplicate of #69440.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    contributionThis PR is from a community contributor.first-time-contributorIndicates that the PR was contributed by an external member and is a first-time contributor.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions