Skip to content

[fix](iceberg) Promote equality-delete keys to current schema type - #68267

Merged
Gabriel39 merged 1 commit into
apache:masterfrom
hubgeter:fix/iceberg-equality-delete-promotion
Sep 22, 2026
Merged

Gabriel39 merged 1 commit into
apache:masterfrom
hubgeter:fix/iceberg-equality-delete-promotion

Conversation

@hubgeter

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: N/A

Related PR: N/A

Problem Summary:

Iceberg equality deletes are compared in the physical type of the delete file.
When a column is legally promoted (for example INT to LONG), the data file can
hold values outside the old type, but the data key was cast into that old type.
An out-of-range value becomes NULL and then matches a NULL equality-delete key
through NULL-safe equality, so a valid row is silently deleted. With
enable_strict_cast the same cast makes the whole scan fail with
Value ... out of range for type int.

Resolve the comparison type from the current snapshot schema instead of the
delete file, and promote historical delete values into that domain. With both
sides in the wider type the cast is always lossless for an Iceberg-legal type
promotion, so a non-NULL key can no longer turn into NULL. Include the current
schema id in the delete-file cache key because the loaded block now depends on
the comparison type.

Covered by a new BE unit test: a data file holding LONG NULL, 0, 1, 4294967296
plus a NULL equality-delete key written under the old INT schema. Before the fix
the scan returned {0, 1}; after the fix it returns {0, 1, 4294967296}.

Release note

Fix Iceberg equality deletes being evaluated in the delete file's historical
type, which could silently drop valid rows after a column type promotion.

Check List (For Author)

  • Test

    • Regression test
    • Unit Test
    • Manual test (add detailed scripts or steps below)
    • No need to test or manual test. Explain why:
      • This is a refactor/code format and no logic has been changed.
      • Previous test can cover this change.
      • No code files have been changed.
      • Other reason
  • Behavior changed:

    • No.
    • Yes.
  • Does this need documentation?

    • No.
    • Yes.

Check List (For Reviewer who merge this PR)

  • Confirm the release note
  • Confirm test cases
  • Confirm document
  • Add branch pick label

@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

Iceberg equality deletes were compared in the delete file's physical type.
A NULL key written under an old INT schema therefore narrowed an
out-of-range LONG data value into NULL and deleted that row as well, and
strict cast made the whole scan fail.

Read the delete key, the data key and the delete side in the promoted type
of the current snapshot schema instead, and promote historical delete
values into that domain. With the comparison done in the wider type the
cast is always lossless, so a non-NULL value can no longer become NULL.

Add a unit test whose data file holds a value beyond the old INT range.
@hubgeter
hubgeter force-pushed the fix/iceberg-equality-delete-promotion branch from 757f0ff to 65a8453 Compare September 20, 2026 09:22
@hubgeter

Copy link
Copy Markdown
Contributor Author

run buildall

@hubgeter

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

Copy link
Copy Markdown
Contributor

Codex automated review failed and did not complete.

Error: You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 23rd, 2026 6:44 AM.
Workflow run: https://github.com/apache/doris/actions/runs/35502219117

The selected account is excluded until 2026-09-23T06:44:00Z. Please trigger /review again; another configured account may be available.

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 28116 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 65a8453bebf4944d93d5b9618001d4682b0646ef, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17727	3937	3905	3905
q2	2223	380	310	310
q3	10053	1394	832	832
q4	4685	482	354	354
q5	7464	838	560	560
q6	183	173	137	137
q7	770	790	602	602
q8	9293	1637	1557	1557
q9	5475	4275	4202	4202
q10	6827	1328	1017	1017
q11	438	267	247	247
q12	628	410	295	295
q13	18083	2604	1977	1977
q14	264	254	234	234
q15	q16	728	723	650	650
q17	1776	1196	1019	1019
q18	6462	5581	5580	5580
q19	1185	1218	1000	1000
q20	483	391	266	266
q21	5557	3437	3058	3058
q22	451	366	314	314
Total cold run time: 100755 ms
Total hot run time: 28116 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	4586	4708	4439	4439
q2	720	563	575	563
q3	4770	5194	4623	4623
q4	2197	2324	1468	1468
q5	4510	4383	4485	4383
q6	239	180	129	129
q7	1808	1658	1509	1509
q8	2392	1996	2009	1996
q9	7282	7310	7163	7163
q10	3662	3576	3096	3096
q11	507	371	340	340
q12	700	704	494	494
q13	2278	2585	2003	2003
q14	266	281	246	246
q15	q16	658	681	603	603
q17	7284	6727	6667	6667
q18	11834	11049	11766	11049
q19	1093	1003	1002	1002
q20	2203	2172	1904	1904
q21	4974	4070	4255	4070
q22	527	451	387	387
Total cold run time: 64490 ms
Total hot run time: 58134 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 152091 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 65a8453bebf4944d93d5b9618001d4682b0646ef, data reload: false

query5	4303	593	444	444
query6	418	205	183	183
query7	4824	556	287	287
query8	317	182	158	158
query9	8813	3954	3907	3907
query10	444	309	252	252
query11	5820	3562	3221	3221
query12	146	92	86	86
query13	1311	583	404	404
query14	6486	4532	4212	4212
query14_1	3969	3936	3916	3916
query15	206	193	179	179
query16	1019	497	447	447
query17	905	686	536	536
query18	2486	454	330	330
query19	202	180	139	139
query20	82	82	91	82
query21	220	134	113	113
query22	13031	12895	12846	12846
query23	14026	13035	12542	12542
query23_1	12639	12475	12539	12475
query24	7336	1181	661	661
query24_1	679	677	716	677
query25	525	406	341	341
query26	1248	302	160	160
query27	2740	551	333	333
query28	4592	1980	1956	1956
query29	1626	722	505	505
query30	301	217	178	178
query31	873	745	627	627
query32	139	93	95	93
query33	524	288	232	232
query34	1173	1140	638	638
query35	712	766	657	657
query36	807	786	714	714
query37	140	100	88	88
query38	1813	1762	1692	1692
query39	699	680	684	680
query39_1	661	639	633	633
query40	216	117	97	97
query41	65	63	63	63
query42	92	90	89	89
query43	330	347	297	297
query44	1353	693	697	693
query45	182	177	162	162
query46	1081	1210	723	723
query47	1491	1498	1436	1436
query48	409	399	289	289
query49	594	401	296	296
query50	953	374	250	250
query51	10855	10311	10375	10311
query52	86	86	75	75
query53	229	250	178	178
query54	240	250	220	220
query55	75	81	67	67
query56	219	221	200	200
query57	1472	1475	1315	1315
query58	284	253	248	248
query59	2002	2066	1867	1867
query60	271	238	219	219
query61	143	144	143	143
query62	388	314	264	264
query63	209	178	176	176
query64	2803	979	797	797
query65	3448	3406	3415	3406
query66	1779	420	291	291
query67	20189	20087	20167	20087
query68	3241	1500	853	853
query69	406	286	302	286
query70	882	815	802	802
query71	300	241	211	211
query72	2614	2499	2224	2224
query73	853	771	436	436
query74	4617	4474	4283	4283
query75	2296	2262	1913	1913
query76	2330	1109	738	738
query77	364	401	300	300
query78	8980	8899	8526	8526
query79	1203	1139	728	728
query80	503	435	365	365
query81	512	316	287	287
query82	279	170	127	127
query83	215	223	195	195
query84	291	146	108	108
query85	796	442	379	379
query86	271	238	224	224
query87	1959	1971	1844	1844
query88	3564	2699	2665	2665
query89	319	282	247	247
query90	2093	177	175	175
query91	164	151	121	121
query92	102	81	87	81
query93	1407	1416	815	815
query94	534	347	310	310
query95	662	464	324	324
query96	1033	781	327	327
query97	2454	2443	2314	2314
query98	156	148	146	146
query99	705	733	614	614
Total cold run time: 235324 ms
Total hot run time: 152091 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 23.96 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit 65a8453bebf4944d93d5b9618001d4682b0646ef, data reload: false

query1	0.01	0.01	0.01
query2	0.09	0.05	0.05
query3	0.26	0.13	0.13
query4	1.61	0.14	0.13
query5	0.24	0.22	0.22
query6	1.16	0.95	0.93
query7	0.04	0.01	0.01
query8	0.06	0.04	0.04
query9	0.39	0.33	0.34
query10	0.55	0.53	0.54
query11	0.20	0.14	0.14
query12	0.19	0.15	0.15
query13	0.47	0.46	0.48
query14	0.95	0.97	0.92
query15	0.60	0.58	0.58
query16	0.31	0.34	0.33
query17	1.08	1.05	1.09
query18	0.21	0.20	0.20
query19	2.03	1.94	1.93
query20	0.02	0.02	0.01
query21	15.42	0.20	0.14
query22	4.94	0.05	0.04
query23	16.14	0.32	0.12
query24	2.92	0.45	0.33
query25	0.10	0.06	0.04
query26	0.73	0.21	0.16
query27	0.05	0.03	0.04
query28	3.60	0.79	0.33
query29	12.48	4.05	3.25
query30	0.28	0.14	0.14
query31	2.78	0.54	0.31
query32	3.24	0.58	0.48
query33	3.23	3.19	3.20
query34	15.57	3.90	3.29
query35	3.21	3.26	3.21
query36	0.55	0.43	0.41
query37	0.09	0.07	0.06
query38	0.05	0.04	0.04
query39	0.03	0.03	0.03
query40	0.18	0.15	0.13
query41	0.09	0.03	0.03
query42	0.04	0.03	0.02
query43	0.05	0.03	0.04
Total cold run time: 96.24 s
Total hot run time: 23.96 s

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 100% (0/0) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 76.27% (34358/45050)
Line Coverage 61.13% (385487/630602)
Region Coverage 57.56% (324679/564087)
Branch Coverage 58.35% (147900/253449)

@Gabriel39

Copy link
Copy Markdown
Contributor

/review

@github-actions

Copy link
Copy Markdown
Contributor

Codex automated review failed and did not complete.

Error: Selected model is at capacity. Please try a different model.
Workflow run: https://github.com/apache/doris/actions/runs/35551337758

Please inspect the workflow logs and rerun the review after the underlying issue is resolved.

@hubgeter

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: the promoted comparison domain is the right fix for a versioned FileScannerV2 plan, and the added Parquet unit fixture captures the central overflow-to-NULL failure, but two supported compatibility paths can still return different or incorrect results.

Critical checkpoint conclusions:

  • Goal and proof: the v2 delete/data expressions converge on one widened key type, but the implementation does not accomplish the goal for marker-less old-FE plans or for the selectable legacy scanner. The test proves only a semantics-v2, direct FileScannerV2 Parquet case.
  • Scope and clarity: the two-file patch is focused and its widening comments are clear, but the parallel legacy implementation is left unchanged and the schema lookup lacks the required negotiation condition.
  • Concurrency and lifecycle: no new thread or lock-order hazard was found. The equality-filter cache is owned by one FileScanLocalState, creation is shard-serialized, and cached typed blocks/hash indexes are immutable for the operator lifetime.
  • Error, memory, null, and data correctness: v2 present/missing/nested branches and Parquet/ORC readers consistently target filter.key_types, and cast/prepare/open failures propagate. NULL-safe equality is exactly why narrowing overflow is dangerous; the two inline findings cover the remaining result-correctness gaps. No new ownership or memory-safety issue was found.
  • Configuration and compatibility: no configuration or protocol field is added. The existing iceberg_scan_semantics_version marker must gate the result-changing behavior during rolling upgrades, and enable_file_scanner_v2=false remains a supported result-parity path.
  • Parallel paths and conditions: current v2 Parquet/ORC, BY_NAME, missing/default, nested, dropped-field, strict/non-strict, and cache-key paths were traced. The unchanged scanner-v1 mixin is the uncovered parallel path.
  • Testing and results: add a no-marker legacy-plan regression and an enable_file_scanner_v2=false parity regression, covering Parquet/ORC as practical. The new expected v2 rows are correct by static trace. Per the review-task constraint, I did not run builds or tests; existing author/CI reports were not treated as independent validation.
  • Observability: existing cache hit/miss and memory counters remain intact; this local comparison change does not require new logging or metrics.
  • Persistence, transactions, writes, and FE-BE variables: not applicable; there are no persistence/data-write changes or new transmitted fields.
  • Performance: promotion occurs once while loading a delete file and the result remains cached; no new row-hot-path regression was found.
  • User focus: no additional focus was supplied. The full changed-file and candidate sweep found no issue beyond the two inline blockers.

@Gabriel39
Gabriel39 merged commit dc4f998 into apache:master Sep 22, 2026
37 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

PR approved by anyone and no changes requested.

@github-actions github-actions Bot added the approved Indicates a PR has been approved by one committer. label Sep 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR approved by at least one committer and no changes requested.

github-actions Bot pushed a commit that referenced this pull request Sep 22, 2026
…68267)

### What problem does this PR solve?

Issue Number: N/A

Related PR: N/A

Problem Summary:

Iceberg equality deletes are compared in the physical type of the delete
file.
When a column is legally promoted (for example INT to LONG), the data
file can
hold values outside the old type, but the data key was cast into that
old type.
An out-of-range value becomes NULL and then matches a NULL
equality-delete key
through NULL-safe equality, so a valid row is silently deleted. With
`enable_strict_cast` the same cast makes the whole scan fail with
`Value ... out of range for type int`.

Resolve the comparison type from the current snapshot schema instead of
the
delete file, and promote historical delete values into that domain. With
both
sides in the wider type the cast is always lossless for an Iceberg-legal
type
promotion, so a non-NULL key can no longer turn into NULL. Include the
current
schema id in the delete-file cache key because the loaded block now
depends on
the comparison type.

Covered by a new BE unit test: a data file holding LONG `NULL, 0, 1,
4294967296`
plus a NULL equality-delete key written under the old INT schema. Before
the fix
the scan returned `{0, 1}`; after the fix it returns `{0, 1,
4294967296}`.

### Release note

Fix Iceberg equality deletes being evaluated in the delete file's
historical
type, which could silently drop valid rows after a column type
promotion.

### Check List (For Author)

- Test
    - [ ] Regression test
    - [x] Unit Test
    - [ ] Manual test (add detailed scripts or steps below)
    - [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
        - [ ] Previous test can cover this change.
        - [ ] No code files have been changed.
        - [ ] Other reason

- Behavior changed:
    - [ ] No.
    - [x] Yes.

- Does this need documentation?
    - [x] No.
    - [ ] Yes.

### Check List (For Reviewer who merge this PR)

- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
yiguolei pushed a commit that referenced this pull request Sep 23, 2026
…hema type #68267 (#68383)

Cherry-picked from #68267

Co-authored-by: daidai <changyuwei@selectdb.com>
yiguolei pushed a commit that referenced this pull request Sep 23, 2026
…hema type #68267 (#68383)

Cherry-picked from #68267

Co-authored-by: daidai <changyuwei@selectdb.com>
morningman added a commit to morningman/doris that referenced this pull request Oct 28, 2026
…lete and data file types

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69536, apache#68267

Problem Summary:

**In short.** FileScannerV2 now compares an Iceberg equality-delete key in the wider of the delete file's and the data file's own key types, instead of in the one table type the FE sends for the whole scan. For some table histories that single type is wrong: the reader then deletes rows it must keep, keeps rows it must delete, or fails the scan. This follows the review of apache#69536, where these cases came up.

**Background.** An equality delete removes a data row whose key values equal a tombstone's. After a type promotion the two sides can be stored in different types: tombstones written before an INT -> LONG promotion hold INT values that must still match LONG data. apache#68267 made FileScannerV2 compare in the current schema's type, and for a key that the current schema no longer has, the FE sends a type recovered from the retained schemas (apache#69536). The reader casts both the tombstones and the data keys to that one type.

**The problem, and what it cost.** One type per scan cannot describe every delete file:

| History | Table type from the FE | Delete file | Data file | Before | After |
|---|---|---|---|---|---|
| `expireSnapshots().cleanExpiredMetadata(true)` removed the schema of a schema-only INT -> LONG promotion of a dropped key | INT | LONG 4294967296 | INT, one NULL key | the tombstone becomes NULL in INT and deletes the NULL-key row | row kept |
| A read pinned before a later DATE -> TIMESTAMP_NS promotion of the key | TIMESTAMP_NS | DATE 1000-01-01 | DATE 1000-01-01 | scan fails: `TIMESTAMP_NS overflow when casting row ...` | row deleted |
| No retained schema gives the key a concrete type, so the FE sends Iceberg `unknown` as STRING | STRING | TIMESTAMP_NS 2024-01-02 00:00 | DATE 2024-01-02 | `2024-01-02` != `2024-01-02 00:00:00.000000000`, the row stays visible | row deleted |
| The delete was prepared under a TIMESTAMP_NS schema that the table later abandoned for TIMESTAMP | TIMESTAMP | TIMESTAMP_NS 12:00:00.000000500 | TIMESTAMP 12:00:00.000000 | the tombstone is rounded to microseconds and deletes the row | row kept |

Three of the four return wrong rows without any error. The V1 reader is not changed by this PR.

**How this PR fixes it.**

- The delete block keeps the delete file's own key types. When the predicate for a data file is built, each key is compared in the wider of the delete file's type and the data file's type (`equality_delete_comparison_type`). The accepted widenings are the Iceberg promotions (INT -> LONG, FLOAT -> DOUBLE, decimal precision, DATE -> TIMESTAMP and DATE -> TIMESTAMP_NS), a wider fractional scale of the same date-time kind, wider integers, STRING -> VARBINARY (an Iceberg BINARY key decodes as either one, depending on `enable_mapping_varbinary`), and TIMESTAMP -> TIMESTAMP_NS. The last one is not an Iceberg promotion; it is accepted for the abandoned-schema case above, because scaling microseconds to nanoseconds is exact. Two types that convert in neither direction fail the scan instead of being compared in a third type.
- The data keys are cast to that type. When it is the data file's wider type, the tombstones are widened once per scanner, and the converted delete block and its hash index are cached next to the original (`_convert_equality_delete_filter`). A table without type changes is compared as before, with no cast and no copy.
- A key that the data file lacks is compared through its initial default, in the wider of the delete file's type and the table type. Without an initial default the key is NULL in that file whatever its table type, so it is compared in the delete file's type (an `unknown` key arrives as STRING).
- The comparison casts are strict whatever the session's cast mode, through a new `strict` flag on `EvolutionCast`: a non-strict overflow would turn a key into NULL and let a NULL tombstone delete it. DATE -> TIMESTAMP_NS was already strict; other `EvolutionCast` users are unchanged.
- The table types from the FE still resolve table fields and the defaults of missing keys; the delete-file cache key is unchanged.

**Results.**

- 9 new BE unit tests in `iceberg_reader_test.cpp`: the four histories above; INT tombstones against LONG, INT and LONG data files of one scan (the third split reuses the cached widened copy); a missing key's INT default against a LONG tombstone; a missing key without a default under an `unknown` table type; a microsecond key outside the nanosecond range, which must fail; and incomparable key types. With the reader before this PR, 6 of them fail; the other three pin behavior the old code shares. Removing the TIMESTAMP -> TIMESTAMP_NS widening fails 2, and making the casts non-strict fails 1.
- End to end on a local cluster: a v2 table in a `file://` Hadoop catalog, built with the Iceberg 1.12.0 Java API: `k INT` with rows (1, NULL), (2, 0), (3, 1); `k` promoted to LONG without a snapshot; a delete of `k = 4294967296` written under that schema; `k` dropped; the delete committed; then `expireSnapshots().retainLast(2).cleanExpiredMetadata(true)`, which leaves schema 0 (`k INT`) and schema 2 (no `k`). `SELECT id` through FileScannerV2 returned 2, 3 before this PR and returns 1, 2, 3 with it.

branch-4.1 and branch-4.2 have the same FileScannerV2 code.

### Release note

Fix FileScannerV2 comparing Iceberg equality-delete keys in a single scan-wide type, which could delete or keep the wrong rows, or fail the scan, after a key's type changed (for example a schema-only promotion removed by `cleanExpiredMetadata`, or a key whose retained schemas only have it as `unknown`).

### Check List (For Author)

- Test
    - [ ] Regression test
    - [x] Unit Test
        - `run-be-ut.sh --run --filter='IcebergV2ReaderTest.*:*EqualityDelete*:*ColumnMapper*:*EvolutionCast*'`: 273 of 273 pass on macOS arm64 (ASAN), including the 9 new cases.
    - [x] Manual test (add detailed scripts or steps below)
        - The local end-to-end check described under Results, with an ASAN BE built from this change.
    - [ ] No need to test or manual test. Explain why:
- Behavior changed:
    - [ ] No.
    - [x] Yes. FileScannerV2 compares equality-delete keys per delete file and data file; two key types that convert in neither direction now fail the scan; the comparison casts are strict.
- Does this need documentation?
    - [x] No.
    - [ ] Yes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
morningman added a commit that referenced this pull request Oct 29, 2026
#69691)

### What problem does this PR solve?

Issue Number: None

Related PR: #66538 (branch-4.1), #68267

Problem Summary:

**In short.** This brings the V1-reader part of branch-4.1 #66538
(Iceberg V3 default values) to master: Iceberg equality deletes keep
working after the type of their key column is promoted (INT to BIGINT,
FLOAT to DOUBLE, a wider DECIMAL precision). It does not copy
branch-4.1's approach, which compares a key in the delete file's older
type; it compares in the scan schema's type, as #68267 already does in
the V2 reader.

**Background.** Iceberg allows widening a column's type, and files keep
the type they were written with: an equality delete written while `id`
was `int` stores its key as INT32, and so does a data file written then.
The V1 reader (`enable_file_scanner_v2 = false`) loads each delete file
into a delete block and compares it with the data rows, which the
Parquet and ORC readers already return in the table's current type.

**The problem, and what it cost.** The V1 reader compared every key in
the type of the delete file it came from. After a promotion, any of
these fails:

| Case (V1 reader, `id` or `payload.existing` promoted from INT to
BIGINT) | master |
|---|---|
| `SELECT id`, delete written before the promotion | Query fails: `Bad
cast from type ColumnVector<BIGINT> to ColumnVector<INT>` |
| `SELECT id`, one delete written before the promotion and one after |
Query fails: the INT and BIGINT keys are merged into one delete block
(debug builds abort on the block's type check) |
| `SELECT v` (key not projected), same two deletes | Same |
| `SELECT id`, delete on `payload.existing` written after the promotion,
data file written before it | The INT values of the old file are copied
into a BIGINT column: debug builds abort with `Bad cast`, release builds
read the wrong bytes and keep or delete the wrong rows |

branch-4.1 / branch-4.2 handle these by casting the data key down to the
delete file's type. That narrowing is what #68267 fixed in the V2
reader: a data file written after the promotion can hold 4294967296,
which becomes NULL as INT and is then deleted by the NULL key of a
delete file written before the promotion (equality deletes use null-safe
equality); with `enable_strict_cast` the whole scan fails instead.

**How this PR fixes it.**
- The type a key is compared in is the field's type in the scan schema
(`equality_delete_key_type`, the same rule as V2's
`restore_current_primitive_type`). A plan without a scan schema keeps
the delete file's type, as before, and STRING / VARBINARY keys keep
theirs too, since they match by bytes.
- Delete keys, and nested keys extracted from the physical root of an
older data file, are widened to that type with
`converter::ColumnTypeConverter`, the converter the V1 Parquet and ORC
readers already apply to the data column of a promoted field, so both
sides hold the same value (`widen_equality_delete_key`).
`NestedEqualityDeleteColumn` records the leaf's source type for that.
- Delete blocks and hidden key carriers are built in the comparison
type, so deletes written before and after a promotion share one block.

**Results.**

| Case (V1 reader) | master | This PR |
|---|---|---|
| `SELECT id`, INT delete `id = 2`, data 1, 2, 3 | `Bad cast` | 1, 3 |
| `SELECT id`, INT delete `id = 2` + BIGINT delete `id = 3` | aborts
(debug) / fails | 1 |
| `SELECT v`, same two deletes | aborts (debug) / fails | 10 |
| `SELECT id`, BIGINT delete `payload.existing = 20` on an old INT file
| `Bad cast` (debug) / wrong rows (release) | 1, 3 |
| `SELECT id`, data `NULL, 0, 1, 4294967296`, INT delete holding NULL |
`Bad cast` (branch-4.x: 0, 1) | 0, 1, 4294967296 |
Gabriel39 pushed a commit that referenced this pull request Oct 29, 2026
…lete and data file types (#69711)

### What problem does this PR solve?

Issue Number: None

Related PR: #69536, #68267

Problem Summary:

**In short.** FileScannerV2 now compares an Iceberg equality-delete key
in the wider of the delete file's and the data file's own key types,
instead of in the one table type the FE sends for the whole scan. For
some table histories that single type is wrong: the reader then deletes
rows it must keep, keeps rows it must delete, or fails the scan. This
follows the review of #69536, where these cases came up.

**Background.** An equality delete removes a data row whose key values
equal a tombstone's. After a type promotion the two sides can be stored
in different types: tombstones written before an INT -> LONG promotion
hold INT values that must still match LONG data. #68267 made
FileScannerV2 compare in the current schema's type, and for a key that
the current schema no longer has, the FE sends a type recovered from the
retained schemas (#69536). The reader casts both the tombstones and the
data keys to that one type.

**The problem, and what it cost.** One type per scan cannot describe
every delete file:

| History | Table type from the FE | Delete file | Data file | Before |
After |
|---|---|---|---|---|---|
| `expireSnapshots().cleanExpiredMetadata(true)` removed the schema of a
schema-only INT -> LONG promotion of a dropped key | INT | LONG
4294967296 | INT, one NULL key | the tombstone becomes NULL in INT and
deletes the NULL-key row | row kept |
| A read pinned before a later DATE -> TIMESTAMP_NS promotion of the key
| TIMESTAMP_NS | DATE 1000-01-01 | DATE 1000-01-01 | scan fails:
`TIMESTAMP_NS overflow when casting row ...` | row deleted |
| No retained schema gives the key a concrete type, so the FE sends
Iceberg `unknown` as STRING | STRING | TIMESTAMP_NS 2024-01-02 00:00 |
DATE 2024-01-02 | `2024-01-02` != `2024-01-02 00:00:00.000000000`, the
row stays visible | row deleted |
| The delete was prepared under a TIMESTAMP_NS schema that the table
later abandoned for TIMESTAMP | TIMESTAMP | TIMESTAMP_NS
12:00:00.000000500 | TIMESTAMP 12:00:00.000000 | the tombstone is
rounded to microseconds and deletes the row | row kept |

Three of the four return wrong rows without any error. The V1 reader is
not changed by this PR.

**How this PR fixes it.**

- The delete block keeps the delete file's own key types. When the
predicate for a data file is built, each key is compared in the wider of
the delete file's type and the data file's type
(`equality_delete_comparison_type`). The accepted widenings are the
Iceberg promotions (INT -> LONG, FLOAT -> DOUBLE, decimal precision,
DATE -> TIMESTAMP and DATE -> TIMESTAMP_NS), a wider fractional scale of
the same date-time kind, wider integers, STRING -> VARBINARY (an Iceberg
BINARY key decodes as either one, depending on
`enable_mapping_varbinary`), and TIMESTAMP -> TIMESTAMP_NS. The last one
is not an Iceberg promotion; it is accepted for the abandoned-schema
case above, because scaling microseconds to nanoseconds is exact. When
the table field is `timestamptz`, a `DATETIMEV2` key (Parquet INT96,
which carries no instant annotation, as Spark and Hive write it) and a
`TIMESTAMPTZ` key compare as `TIMESTAMPTZ` at the larger scale, the
`DATETIMEV2` side converted with the session time zone as the projection
converts that column. When the table field is the STRING placeholder the
FE sends for an Iceberg TIME, which Doris cannot represent, an ORC TIME
key (a LONG annotated `iceberg.long-type=TIME`, read as `BIGINT`) and a
Parquet one (`TIME(MICROS)`, read as `TIMEV2(6)`) compare as
`TIMEV2(6)`, the `BIGINT` side converted exactly as microseconds from
midnight by a dedicated expression (`CAST(BIGINT AS TIME)` reads the
integer's digits as HHMMSS), and a value outside
00:00:00..23:59:59.999999 fails the scan. Two types that convert in
neither direction fail the scan instead of being compared in a third
type.
- The data keys are cast to that type. When it is the data file's wider
type, the tombstones are widened once per scanner, and the converted
delete block and its hash index are cached next to the original
(`_convert_equality_delete_filter`). A table without type changes is
compared as before, with no cast and no copy.
- A key that the data file lacks is compared through its initial
default, in the wider of the delete file's type and the table type. When
that default is NULL (no initial default, or a struct default `{}` whose
leaf has none) the key is NULL in that file whatever its table type, so
it is compared in the delete file's type (an `unknown` key arrives as
STRING). A non-NULL default behind the STRING placeholder of an Iceberg
TIME is Iceberg's text of the value (`00:00`), with at most six
fractional digits, so it is parsed exactly in the tombstones' type,
`TIMEV2` (`TIMEV2(6)` against an ORC TIME tombstone), by the strict
comparison cast. A non-NULL default of a `timestamptz_ns` key fails the
scan with `NotSupported`: V2 reads its tombstones as `TIMESTAMPTZ(6)`
(Parquet floors the nanoseconds, ORC rounds them), so no parse of the
nanosecond default compares exactly; the default epoch would match a
tombstone 100 ns later and delete every row of the file.
- `AncestorNullDefaultExpr`, which gives a missing child the NULL of its
NULL parent and its default otherwise, now opens its children. Its
`open()` returned before opening them, because `prepare()` had already
set the flag its guard checks, so a default cast to the comparison type
below it was never opened and aborted the BE with `Check failed:
_open_finished` in `Cast`.
- The comparison casts are strict whatever the session's cast mode,
through a new `strict` flag on `EvolutionCast`: a non-strict overflow
would turn a key into NULL and let a NULL tombstone delete it. DATE ->
TIMESTAMP_NS was already strict; other `EvolutionCast` users are
unchanged.
- A delete file without rows is skipped before its keys are resolved: it
deletes nothing, but the predicate evaluates its keys first, so its
strict casts could fail the scan (a DATE key 1000-01-01 against an empty
TIMESTAMP_NS delete file).
- The table types from the FE still resolve table fields and the
defaults of missing keys; the delete-file cache key is unchanged.

**Results.**

- 14 new BE unit tests in `iceberg_reader_test.cpp`: the four histories
above; INT tombstones against LONG, INT and LONG data files of one scan
(the third split reuses the cached widened copy); a missing key's INT
default against a LONG tombstone; a missing key without a default under
an `unknown` table type; a microsecond key outside the nanosecond range,
which must fail; and incomparable key types. With the reader before this
PR, 6 of them fail; the other three pin behavior the old code shares.
Removing the TIMESTAMP -> TIMESTAMP_NS widening fails 2, and making the
casts non-strict fails 1. Three more came from review: an INT96 data key
against an adjusted-UTC tombstone in Asia/Shanghai, a NULL leaf under a
struct default `{}`, and an empty TIMESTAMP_NS delete file against a
DATE key; removing the INT96 rule, the NULL-leaf walk or the empty-file
skip fails its test. Two more came from the third round: Iceberg TIME
keys across all four ORC / Parquet data and delete file pairs, with NULL
and non-NULL tombstones, and a nested Iceberg TIME key added with a text
default, against Parquet TIME and ORC TIME tombstones, under NULL and
non-NULL parents. Removing the TIME pair rule or the default parsing, or
converting the ORC TIME with a SQL cast, fails them; leaving
`AncestorNullDefaultExpr`'s children unopened aborts. One more came from
the fourth round: a nested `timestamptz_ns` key with the defaults
`...0000001`, the epoch and `...0000005` fails against an epoch, a 100
ns and a 500 ns tombstone, while a key without a default still matches
NULL tombstones only; parsing those defaults again fails it.
- End to end on a local cluster: a v2 table in a `file://` Hadoop
catalog, built with the Iceberg 1.12.0 Java API: `k INT` with rows (1,
NULL), (2, 0), (3, 1); `k` promoted to LONG without a snapshot; a delete
of `k = 4294967296` written under that schema; `k` dropped; the delete
committed; then
`expireSnapshots().retainLast(2).cleanExpiredMetadata(true)`, which
leaves schema 0 (`k INT`) and schema 2 (no `k`). `SELECT id` through
FileScannerV2 returned 2, 3 before this PR and returns 1, 2, 3 with it.

branch-4.1 and branch-4.2 have the same FileScannerV2 code.

### Release note

Fix FileScannerV2 comparing Iceberg equality-delete keys in a single
scan-wide type, which could delete or keep the wrong rows, or fail the
scan, after a key's type changed (for example a schema-only promotion
removed by `cleanExpiredMetadata`, or a key whose retained schemas only
have it as `unknown`).

### Check List (For Author)

- Test <!-- At least one of them must be included. -->
    - [ ] Regression test
    - [x] Unit Test
- `run-be-ut.sh --run
--filter='IcebergV2ReaderTest.*:*EqualityDelete*:*ColumnMapper*:*EvolutionCast*:*TableReader*'`:
526 of 526 pass on macOS arm64 (ASAN), including the 14 new cases.
- Rebased onto master `43f9ceb48d7`: #69639 moved the strict-mode
override of `EvolutionCast` from `prepare()` into
`configure_cast_options()`, so the new `strict` flag now sets
`CastOptions::strict_mode` there, next to the existing DATE ->
TIMESTAMP_NS rule. The run above is on the content before the rebase
(the third-round commit applies to both unchanged); the 67 TUs that this
PR changes or that master changed since `5caaeb3e3d4` were
syntax-checked against the rebased tree with the UT build flags, and CI
runs the suites again.
    - [x] Manual test (add detailed scripts or steps below)
- The local end-to-end check described under Results, with an ASAN BE
built from this change.
    - [ ] No need to test or manual test. Explain why:
- Behavior changed:
    - [ ] No.
- [x] Yes. FileScannerV2 compares equality-delete keys per delete file
and data file; two key types that convert in neither direction now fail
the scan; the comparison casts are strict.
- Does this need documentation?
    - [x] No.
- [ ] Yes. <!-- Add document PR link here. eg:
apache/doris-website#1214 -->

### Check List (For Reviewer who merge this PR)

- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR
should merge into -->

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by one committer. dev/4.1.x dev/4.2.0-merged reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants