pingcap / pingcap/tidb

UnionScan returns wrong result for ORDER BY virtual generated column with LIMIT through IndexLookUp

Open
#68,003 2 comments 0 reactions 0 assignees View on GitHub
may-affects-7.1 may-affects-7.5 may-affects-8.1 may-affects-8.5 severity/major sig/execution type/bug
Dominant language
Go
Stars
40.5k
Forks
6.2k
PR merge metrics
PR metrics pending

Description

## Bug Report

### 1. Minimal reproduce step

```sql
DROP TABLE IF EXISTS union_vgc_limit_bug;
CREATE TABLE union_vgc_limit_bug (
id INT PRIMARY KEY,
a INT,
b INT GENERATED ALWAYS AS (a + 1) VIRTUAL,
KEY idx_b(b)
);

INSERT INTO union_vgc_limit_bug(id, a) VALUES (1, 1), (2, 4);

BEGIN;
INSERT INTO union_vgc_limit_bug(id, a) VALUES (3, 2);

EXPLAIN
SELECT id, a, b
FROM union_vgc_limit_bug USE INDEX(idx_b)
WHERE b >= 2
ORDER BY b, id
LIMIT 1;

SELECT id, a, b
FROM union_vgc_limit_bug USE INDEX(idx_b)
WHERE b >= 2
ORDER BY b, id
LIMIT 1;

SELECT id, a, b
FROM union_vgc_limit_bug IGNORE INDEX(idx_b)
WHERE b >= 2
ORDER BY b, id
LIMIT 1;

ROLLBACK;
```

The plan of the indexed query is:

```text
Limit
└─Selection
└─UnionScan
└─IndexLookUp
├─IndexFullScan on idx_b(b), keep order:true
└─TableRowIDScan
```

### 2. What did you expect to see?

Both queries should return the same first row after ordering by the virtual generated column `b`.

Expected result:

```text
+----+------+------+
| id | a | b |
+----+------+------+
| 1 | 1 | 2 |
+----+------+------+
```

### 3. What did you see instead?

The indexed query returns a different row:

```sql
SELECT id, a, b
FROM union_vgc_limit_bug USE INDEX(idx_b)
WHERE b >= 2
ORDER BY b, id
LIMIT 1;
```

```text
+----+------+------+
| id | a | b |
+----+------+------+
| 3 | 2 | 3 |
+----+------+------+
```

But the full scan query returns the correct row:

```sql
SELECT id, a, b
FROM union_vgc_limit_bug IGNORE INDEX(idx_b)
WHERE b >= 2
ORDER BY b, id
LIMIT 1;
```

```text
+----+------+------+
| id | a | b |
+----+------+------+
| 1 | 1 | 2 |
+----+------+------+
```

### 4. Possible root cause

`UnionScanExec` merges snapshot rows and dirty rows before virtual generated columns are materialized for output.

In `UnionScanExec.getOneRow`, snapshot row and added row are compared here:

```go
isSnapshotRowInt, err := us.compare(us.Ctx().GetSessionVars().StmtCtx, snapshotRow, addedRow)
```

But virtual generated columns are evaluated later in `UnionScanExec.Next`:

```go
for _, idx := range us.virtualColumnIndex {
datum, err := us.Schema().Columns[idx].EvalVirtualColumn(...)
...
mutableRow.SetDatum(idx, castDatum)
}
```

When the ordering key is a virtual generated column, `UnionScan` may compare dirty rows using values that have not yet been materialized consistently with the snapshot/index path. Because the plan trusts `idx_b(b)` to preserve order, no extra `Sort` is added above `UnionScan`, and `LIMIT 1` can return the wrong row.

### 5. TiDB version

```sql
SELECT tidb_version();
```

```text
Release Version: v9.0.0-beta.2.pre-1598-g874ff3792e
Edition: Community
Git Commit Hash: 874ff3792e7e0f51068547f90cefc1acbfed7232
Git Branch: HEAD
UTC Build Time: 2026-04-22 02:24:07
GoVersion: go1.25.8
Race Enabled: false
Check Table Before Drop: false
Store: tikv
Kernel Type: Classic
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.