UnionScan returns wrong result for ORDER BY virtual generated column with LIMIT through IndexLookUp
- 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
Assessment
This issue has not been assessed yet.