pingcap / pingcap/tidb

FLOAT/DOUBLE→string rendering is inconsistent between evaluation contexts (TiDB root vs TiKV coprocessor, and across inlined metadata) — a predicate comparing such rendered strings flips between equivalent query forms

Open
#70,752 2 comments 0 reactions 0 assignees View on GitHub
contribution may-affects-25.10 may-affects-26.3 may-affects-7.5 may-affects-8.1 may-affects-8.5 severity/critical sig/execution type/bug
Dominant language
Go
Stars
40.5k
Forks
6.2k
PR merge metrics
PR metrics pending

Description

## Bug Report

Please answer these questions before submitting your issue. Thanks!

### 1. Minimal reproduce step (Required)

A `CASE` whose THEN branch is a FLOAT column is derived as FLOAT, so the ELSE constant is
silently FLOAT-rounded (e.g. `1778388382` → `1778388400`, gaining a `'0'` digit). Rendering
that FLOAT value to a string then **differs by evaluation context**: the TiDB root (Go)
expands to decimal (`'1778388400'`), while the TiKV coprocessor (Rust) renders scientific
notation (`'1.7783884e9'`) — different characters, so any downstream string predicate
(REGEXP / RIGHT / LIKE) flips. Relocating the identical predicate between the WHERE clause
and a grouped derived-table projection changes which engine (or which cast metadata) evaluates
it, and the two equivalent queries diverge.

**Main trigger shape — NOT REGEXP over a GROUP BY (2 rows):**

```sql
DROP DATABASE IF EXISTS repro_render_family;
CREATE DATABASE repro_render_family;
USE repro_render_family;
CREATE TABLE t0(c0 float unsigned zerofill DEFAULT NULL);
INSERT INTO t0 VALUES (0.04138246),(0.063843496);

-- Base query (predicate over the GROUP BY key in the WHERE clause):
SELECT t0.c0 FROM t0
WHERE ((CASE (t0.c0) WHEN 0 THEN t0.c0 ELSE 1778388382 END) NOT REGEXP ((t0.c0) LIKE ('a')))
GROUP BY t0.c0;
-- (empty result set) <-- root(Go) evaluates: '1778388400' REGEXP '0' matches -> NOT -> FALSE

-- Rewritten query (same predicate materialized into the derived-table projection):
SELECT ref0 FROM (
SELECT t0.c0 AS ref0,
((CASE (t0.c0) WHEN 0 THEN t0.c0 ELSE 1778388382 END) NOT REGEXP ((t0.c0) LIKE ('a'))) AS ref1
FROM t0 GROUP BY t0.c0
) s WHERE ref1;
-- 2 rows: 0.04138246, 0.063843496 <-- WRONG (the coprocessor (Rust) renders '1.7783884e9',
-- no '0' digit -> REGEXP '0' does not match -> NOT -> TRUE)
```

Semantics: the predicate references only the grouping key, so it is an invariant per group —
filtering with it in WHERE and filtering with the very same expression relocated into a
derived-table projection over the same GROUP BY must yield the same group set.

Root-side proof of the rendering mechanism (run in the same database):

```sql
SELECT CAST((CASE (t0.c0) WHEN 0 THEN t0.c0 ELSE 1778388382 END) AS CHAR) FROM t0;
-- '1778388400' for both rows (note: NOT '1778388382' — the float-rounded value contains '0')
```

`EXPLAIN` proof of the evaluation-context split (the ONLY plan difference):

```
base : └─Selection_9 root not(regexp(cast(case(...), var_string(87)), ...))
moved: └─Selection_16 cop[tikv] not(regexp(cast(case(...), var_string(87)), ...)) (same expression text)
```

`EXPLAIN ANALYZE`: base Selection emits 0 of 2 rows; moved Selection emits 2 of 2 — identical
expression, identical data, identical plan except the task placement.

### 2. What did you expect to see? (Required)
A FLOAT value renders to the same string regardless of where it is evaluated (TiDB root or
TiKV coprocessor, base-table binding or inlined derived-table binding). Therefore both queries
must return the same group set.
### 3. What did you see instead (Required)
The same expression renders different strings in different evaluation contexts, so string
predicates over the rendered value flip: the WHERE form keeps 0 groups while the relocated
projection form keeps 2. Groups are silently added/dropped depending on where the optimizer
places the selection.
### 4. What is your TiDB version? (Required)

```
Release Version: v9.0.0-beta.2.pre-2174-g65ac2fad58
Edition: Community
Git Commit Hash: 65ac2fad582510b92d3c4b79999e48217c989737
Git Branch: HEAD
UTC Build Time: 2026-08-28 20:22:53
GoVersion: go1.25.12
Race Enabled: false
Check Table Before Drop: false
Store: unistore
Kernel Type: Classic
```
### 5. Additional trigger shapes of the same root cause (all reproduced live on this build)

**Shape B — RIGHT() over a FLOAT-rounded CASE (2 rows, deterministic 10/10):**

```sql
DROP DATABASE IF EXISTS repro_render_right;
CREATE DATABASE repro_render_right;
USE repro_render_right;
CREATE TABLE t0(c0 FLOAT UNSIGNED ZEROFILL);
INSERT INTO t0 VALUES (0.93873516),(0.12510837);

-- Base query (predicate over the GROUP BY key in the WHERE clause):
SELECT t0.c0 FROM t0 WHERE (RIGHT((CASE (t0.c0) WHEN 1 THEN t0.c0 ELSE 1888437279 END), t0.c0)) GROUP BY t0.c0;
-- (empty result set) <-- root: '1888437200' (float-rounded), RIGHT(...,1)='0' -> boolean FALSE

-- Rewritten query (same predicate materialized into the derived-table projection):
SELECT ref0 FROM (
SELECT t0.c0 AS ref0, (RIGHT((CASE (t0.c0) WHEN 1 THEN t0.c0 ELSE 1888437279 END), t0.c0)) AS ref1
FROM t0 GROUP BY t0.c0
) s WHERE ref1;
-- 1 row: 0.93873516 <-- WRONG: cop renders '1.8884372e9', RIGHT(...,1)='9' -> boolean TRUE
```

Note: here both plans show the Selection at cop[tikv] with **identical expression text**, yet
the actual row counts differ (EXPLAIN ANALYZE act 0 vs 1, stable 10/10) — the inlining of the
outer `WHERE ref1` back into the predicate changes the cast metadata the coprocessor sees,
switching the rendering.

**Shape C — FLOAT LIKE self-match in a grouped projection (5 rows):**

```sql
DROP DATABASE IF EXISTS repro_render_like;
CREATE DATABASE repro_render_like;
USE repro_render_like;
CREATE TABLE t(c FLOAT);
INSERT INTO t VALUES (1291934700), (0.5), (0.605), (NULL), (0.1);

-- Base query (predicate over the GROUP BY key in the WHERE clause):
SELECT c FROM t WHERE (c LIKE CASE 're' WHEN c THEN NULL WHEN 0.605 THEN 1261239660 ELSE c END) GROUP BY c;
-- 4 rows: 0.1, 0.5, 0.605, 1291934700 <-- correct (root renders both LIKE operands identically)

-- Rewritten query (same predicate materialized into the derived-table projection):
SELECT ref0 FROM (
SELECT c AS ref0, (c LIKE CASE 're' WHEN c THEN NULL WHEN 0.605 THEN 1261239660 ELSE c END) AS ref1
FROM t GROUP BY c
) s WHERE ref1;
-- 1 row: 0.5 <-- WRONG: only the binary-exact value survives; the two cast(c) renderings
-- (input vs pattern) use different precision, so c LIKE c is FALSE for 0.1/0.605/1291934700
```

(The CASE degenerates to `c LIKE c` on this data; the bare form `c LIKE c` agrees — the
wrapper is load-bearing.)

Contributor guide

Open the contributing guide

Research direction

Start by running the three SQL reproductions and their EXPLAIN ANALYZE plans, comparing TiDB root evaluation with the TiKV coprocessor and the cast metadata after predicate inlining. Trace the FLOAT/DOUBLE-to-string handling in the root and coprocessor evaluation paths. Done means equivalent query forms render the same value and return the same groups or rows in all supplied cases.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, rust, sql
Domain
backend, databases, distributed-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.