This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new aae599269eb [fix](iceberg) Align scan schema carrier names by field id
(#69382)
aae599269eb is described below
commit aae599269eb740e9234a1586e78b363c6b3da78c
Author: daidai <[email protected]>
AuthorDate: Thu Oct 22 11:23:59 2026 +0800
[fix](iceberg) Align scan schema carrier names by field id (#69382)
### What problem does this PR solve?
Issue Number: N/A
Problem Summary:
Iceberg scan carriers could use inconsistent top-level name casing or
schema generations, especially for full-schema, Top-N, and
snapshot-pinned partition scans. Build all carriers from one
authoritative scan schema and resolve partition columns by Iceberg field
id.
### Release note
Fix mixed-case and time-travel Iceberg reads by keeping scan-schema
carrier names consistent.
### Check List (For Author)
- Test
- [x] Regression test
- [x] Unit Test
- [x] 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
Regression test: `test_iceberg_scan_schema_carrier_names` passed twice,
including normal output comparison.
Unit tests: Iceberg connector suite passed (1478 tests, 0 failures, 1
skipped).
Manual test: verified mixed-case ORC, Top-N, pinned snapshot partition
rename/filter, and count pushdown with Spark, Iceberg REST, and Doris.
- 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
---
.../connector/iceberg/IcebergColumnHandle.java | 4 +-
.../connector/iceberg/IcebergPartitionUtils.java | 32 ++--
.../connector/iceberg/IcebergScanPlanProvider.java | 163 +++++++++++---------
.../doris/connector/iceberg/IcebergScanRange.java | 2 +-
.../connector/iceberg/IcebergSchemaUtils.java | 46 +++---
.../iceberg/IcebergPartitionUtilsTest.java | 57 ++++++-
.../iceberg/IcebergScanPlanProviderTest.java | 71 +++++++++
.../connector/iceberg/IcebergSchemaUtilsTest.java | 44 +++++-
.../test_iceberg_scan_schema_carrier_names.out | 37 +++++
.../test_iceberg_scan_schema_carrier_names.groovy | 166 +++++++++++++++++++++
10 files changed, 503 insertions(+), 119 deletions(-)
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergColumnHandle.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergColumnHandle.java
index 8bd00b87fb0..6c780ccdfe5 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergColumnHandle.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergColumnHandle.java
@@ -26,13 +26,13 @@ import java.util.Set;
/**
* Iceberg {@link ConnectorColumnHandle}, mirroring the paimon connector's
{@code PaimonColumnHandle}. Carries
- * the (lowercased) column name and the iceberg field id. {@code
IcebergConnectorMetadata.getColumnHandles}
+ * the case-preserved column name and the iceberg field id. {@code
IcebergConnectorMetadata.getColumnHandles}
* produces these so {@code PluginDrivenScanNode.buildColumnHandles} can hand
the provider the pruned set of
* requested columns — which the field-id schema dictionary (T06) keys the
{@code current_schema_id = -1}
* entry off (the CI #969249 fix: the dict's top-level names == the BE
scan-slot names BY CONSTRUCTION).
*
* <p>Equality/hashCode are by name only (mirrors {@code PaimonColumnHandle}):
a handle identifies a column
- * by its (lowercased) name, the same key {@code
allHandles.get(slot.getColumn().getName())} looks it up by.
+ * by its case-preserved name, the same key {@code
allHandles.get(slot.getColumn().getName())} looks it up by.
*/
public class IcebergColumnHandle implements ConnectorColumnHandle {
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java
index d9035efb111..7c6d929d603 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java
@@ -118,19 +118,21 @@ final class IcebergPartitionUtils {
/**
* Ordered, case-preserved, de-duplicated list of the identity partition
column names across <b>all</b>
- * partition specs of the table (mirrors legacy {@code
IcebergUtils.getIdentityPartitionColumns}). This
- * is the {@code path_partition_keys} payload: it tells FE which slots are
partition columns so they are
- * excluded from the file-decode set (the CI #968880 double-fill guard).
Non-identity transforms
- * (bucket/truncate/year/month/...) are excluded.
+ * partition specs of the table (mirrors legacy {@code
IcebergUtils.getIdentityPartitionColumns}). Names
+ * are resolved by field id against the query's authoritative {@code
scanSchema}, so current and pinned
+ * reads cannot mix schema generations. This is the {@code
path_partition_keys} payload: it tells FE which
+ * slots are partition columns so they are excluded from the file-decode
set (the CI #968880 double-fill
+ * guard). Non-identity transforms (bucket/truncate/year/month/...) are
excluded.
*/
- static List<String> getIdentityPartitionColumns(Table table) {
+ static List<String> getIdentityPartitionColumns(Table table, Schema
scanSchema) {
LinkedHashSet<String> partitionColumns = new LinkedHashSet<>();
for (PartitionSpec spec : table.specs().values()) {
for (PartitionField partitionField : spec.fields()) {
if (!partitionField.transform().isIdentity()) {
continue;
}
- String columnName =
table.schema().findColumnName(partitionField.sourceId());
+ String columnName =
+ topLevelSourceColumnName(scanSchema,
partitionField.sourceId());
if (columnName != null) {
partitionColumns.add(columnName);
}
@@ -189,12 +191,8 @@ final class IcebergPartitionUtils {
* mistaken for the top-level column {@code id}: the engine can only
materialize a top-level column.
*/
static String topLevelSourceColumnName(Schema schema, int sourceId) {
- for (NestedField column : schema.columns()) {
- if (column.fieldId() == sourceId) {
- return column.name();
- }
- }
- return null;
+ NestedField column = schema.asStruct().field(sourceId);
+ return column == null ? null : column.name();
}
/** The spec's <em>identity</em> partition over the top-level column
{@code name}, or null. */
@@ -253,11 +251,12 @@ final class IcebergPartitionUtils {
/**
* Per-file map of identity partition column (case-preserved) to
serialized value, skipping non-identity
- * transforms and BINARY/FIXED columns (utf8 round-trip would corrupt
those). Order-preserving
- * (LinkedHashMap, spec field order). Mirrors legacy {@code
IcebergUtils.getIdentityPartitionInfoMap}.
+ * transforms and BINARY/FIXED columns (utf8 round-trip would corrupt
those). Keys are resolved by field id
+ * against {@code scanSchema}, matching {@code path_partition_keys}.
Order-preserving (LinkedHashMap, spec
+ * field order). Mirrors legacy {@code
IcebergUtils.getIdentityPartitionInfoMap}.
*/
static Map<String, String> getIdentityPartitionInfoMap(PartitionData
partitionData,
- PartitionSpec partitionSpec, Table table, ZoneId zone) {
+ PartitionSpec partitionSpec, Schema scanSchema, ZoneId zone) {
Map<String, String> partitionInfoMap = new LinkedHashMap<>();
List<NestedField> fields =
partitionData.getPartitionType().asNestedType().fields();
List<PartitionField> partitionFields = partitionSpec.fields();
@@ -275,7 +274,8 @@ final class IcebergPartitionUtils {
continue;
}
- String columnName =
table.schema().findColumnName(partitionField.sourceId());
+ String columnName =
+ topLevelSourceColumnName(scanSchema,
partitionField.sourceId());
if (columnName == null) {
continue;
}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
index 2f07b5ac0a7..d3245f604d1 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
@@ -583,9 +583,11 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
return emptySplitSource();
}
Table table = resolveTable(session, iceHandle);
+ Schema scanSchema = pinnedSchema(table, iceHandle);
TableScan scan = buildScan(table, iceHandle, filter, session);
int formatVersion = getFormatVersion(table);
- List<String> orderedPartitionKeys =
IcebergPartitionUtils.getIdentityPartitionColumns(table);
+ List<String> orderedPartitionKeys =
+ IcebergPartitionUtils.getIdentityPartitionColumns(table,
scanSchema);
ZoneId zone = resolveSessionZone(session);
boolean partitioned = IcebergPartitionUtils.hasPartitionedSpec(table);
Map<String, String> vendedToken = context != null
@@ -597,7 +599,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
try {
CloseableIterable<FileScanTask> tasks = streamingFileScanTasks(
scan, session, table, filter, sliceSize);
- return new IcebergStreamingSplitSource(tasks, table,
formatVersion, partitioned,
+ return new IcebergStreamingSplitSource(tasks, table, scanSchema,
formatVersion, partitioned,
orderedPartitionKeys, zone, uriNormalizer, sliceSize,
iceHandle.getRewriteFileScope(), iceHandle,
requiresRowId(columns));
} catch (RuntimeException e) {
@@ -656,6 +658,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
private final class IcebergStreamingSplitSource implements
ConnectorSplitSource {
private final CloseableIterable<FileScanTask> tasks;
private final Table table;
+ private final Schema scanSchema;
private final int formatVersion;
private final boolean partitioned;
private final List<String> orderedPartitionKeys;
@@ -677,12 +680,14 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
// 1-entry cache stays O(1) memory (never accumulates), preserving the
streaming path's OOM safety.
private final PerFileScratch scratch = new PerFileScratch();
- IcebergStreamingSplitSource(CloseableIterable<FileScanTask> tasks,
Table table, int formatVersion,
+ IcebergStreamingSplitSource(CloseableIterable<FileScanTask> tasks,
Table table, Schema scanSchema,
+ int formatVersion,
boolean partitioned, List<String> orderedPartitionKeys, ZoneId
zone,
UnaryOperator<String> uriNormalizer, long sliceSize,
Set<String> rewriteScope,
IcebergTableHandle handle, boolean requiresRowId) {
this.tasks = tasks;
this.table = table;
+ this.scanSchema = scanSchema;
this.formatVersion = formatVersion;
this.partitioned = partitioned;
this.orderedPartitionKeys = orderedPartitionKeys;
@@ -704,8 +709,10 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
iterator = tasks.iterator();
}
while (iterator.hasNext()) {
- IcebergScanRange range =
buildRangeForTask(iterator.next(), table, formatVersion, partitioned,
- orderedPartitionKeys, zone, uriNormalizer,
sliceSize, rewriteScope, null, scratch);
+ IcebergScanRange range = buildRangeForTask(
+ iterator.next(), table, scanSchema, formatVersion,
partitioned,
+ orderedPartitionKeys, zone, uriNormalizer,
sliceSize, rewriteScope, null,
+ scratch);
if (range != null) {
if (requiresRowId) {
rejectRowIdRangeWithoutPartitionData(range);
@@ -764,6 +771,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
return planSystemTableScan(iceHandle, columns, filter, session);
}
Table table = resolveTable(session, iceHandle);
+ Schema scanSchema = pinnedSchema(table, iceHandle);
TableScan scan = buildScan(table, iceHandle, filter, session);
// FIX-SCAN-METRICS: attach a per-scan metrics reporter so the iceberg
SDK's ScanReport (planning time,
// data/delete files, scanned vs skipped manifests) is captured into
the query profile — restores the
@@ -777,7 +785,8 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
}
int formatVersion = getFormatVersion(table);
- List<String> orderedPartitionKeys =
IcebergPartitionUtils.getIdentityPartitionColumns(table);
+ List<String> orderedPartitionKeys =
+ IcebergPartitionUtils.getIdentityPartitionColumns(table,
scanSchema);
ZoneId zone = resolveSessionZone(session);
boolean partitioned = IcebergPartitionUtils.hasPartitionedSpec(table);
@@ -800,8 +809,9 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
// A data-row predicate can leave partially matching files, whose
file-level recordCount is only an
// upper bound. Keep those scans on the normal path even if the engine
supplies the count signal.
if (countPushdown && filter.isEmpty()) {
- Optional<List<ConnectorScanRange>> countRanges =
planCountPushdown(table, scan, formatVersion,
- partitioned, orderedPartitionKeys, zone, uriNormalizer,
session, filter);
+ Optional<List<ConnectorScanRange>> countRanges = planCountPushdown(
+ table, scan, scanSchema, formatVersion, partitioned,
orderedPartitionKeys, zone,
+ uriNormalizer, session, filter);
if (countRanges.isPresent()) {
return countRanges.get();
}
@@ -840,9 +850,10 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
for (FileScanTask task : plan.tasks) {
// Shared per-task mapping (rewrite-scope skip + M-2 weight
denominator + v3 stash side-effect),
// identical to the streaming path's
IcebergStreamingSplitSource so both produce the same ranges.
- IcebergScanRange range = buildRangeForTask(task, table,
formatVersion, partitioned,
- orderedPartitionKeys, zone, uriNormalizer,
plan.targetSplitSize, rewriteScope,
- rewritableDeleteSupply, scratch);
+ IcebergScanRange range = buildRangeForTask(
+ task, table, scanSchema, formatVersion, partitioned,
orderedPartitionKeys, zone,
+ uriNormalizer, plan.targetSplitSize, rewriteScope,
rewritableDeleteSupply,
+ scratch);
if (range != null) {
ranges.add(range);
}
@@ -898,8 +909,8 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
* side-effect. The streaming path passes {@code
rewritableDeleteSupply=null} (v3 is gated onto the eager
* path — see {@link #streamingSplitEstimate}), so the accumulation is
inert there.
*/
- private IcebergScanRange buildRangeForTask(FileScanTask task, Table table,
int formatVersion,
- boolean partitioned, List<String> orderedPartitionKeys, ZoneId
zone,
+ private IcebergScanRange buildRangeForTask(FileScanTask task, Table table,
Schema scanSchema,
+ int formatVersion, boolean partitioned, List<String>
orderedPartitionKeys, ZoneId zone,
UnaryOperator<String> uriNormalizer, long targetSplitSize,
Set<String> rewriteScope,
Map<String, List<TIcebergDeleteFileDesc>> rewritableDeleteSupply,
PerFileScratch scratch) {
DataFile dataFile = task.file();
@@ -913,8 +924,9 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
boolean firstSliceOfFile = scratch.file != dataFile;
// targetSplitSize is the scan-level weight denominator (M-2): each
data-file range carries a
// size-proportional BE scheduling weight (selfSplitWeight computed
inside buildRange).
- IcebergScanRange range = buildRange(table, dataFile, task,
formatVersion, partitioned,
- orderedPartitionKeys, zone, uriNormalizer, -1,
targetSplitSize, scratch);
+ IcebergScanRange range = buildRange(
+ table, scanSchema, dataFile, task, formatVersion, partitioned,
orderedPartitionKeys,
+ zone, uriNormalizer, -1, targetSplitSize, scratch);
if (rewritableDeleteSupply != null && firstSliceOfFile) {
// Record this data file's non-equality delete supply keyed on its
RAW path (the exact string the BE
// matches a rewritable set against). An empty list (no old non-eq
deletes) contributes nothing.
@@ -982,7 +994,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
// the ids through a Set and re-emit them in TABLE-schema order
(TypeUtil.project), breaking that
// contract; scan.project(orderedSchema) preserves the requested
order, so we build the schema by
// hand from the requested columns.
- List<String> projectedColumns = requestedLowerNames(columns);
+ List<String> projectedColumns = requestedColumnNames(columns);
if (!projectedColumns.isEmpty()) {
Schema metadataSchema = metadataTable.schema();
List<NestedField> projectedFields = new
ArrayList<>(projectedColumns.size());
@@ -1231,7 +1243,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
/** Whether the query projects the {@code partition} column (legacy read
the tuple's slots). */
private static boolean
isPositionDeletesPartitionColumnRequested(List<ConnectorColumnHandle> columns) {
- return
requestedLowerNames(columns).stream().anyMatch("partition"::equalsIgnoreCase);
+ return
requestedColumnNames(columns).stream().anyMatch("partition"::equalsIgnoreCase);
}
/**
@@ -1246,8 +1258,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
*/
private TableScan buildScan(Table table, IcebergTableHandle handle,
Optional<ConnectorExpression> filter,
ConnectorSession session) {
- Schema selectedSchema = !handle.isSystemTable() &&
handle.hasSnapshotPin()
- ? pinnedSchema(table, handle) : table.schema();
+ Schema selectedSchema = handle.isSystemTable() ? table.schema() :
pinnedSchema(table, handle);
// Keep the SDK's native Table.newScan implementation unless an actual
historical schema needs the
// metadata-only snapshot fix; catalog-specific Table wrappers may
provide their own scan behavior.
TableScan scan = !handle.isSystemTable() &&
!selectedSchema.sameSchema(table.schema())
@@ -1348,7 +1359,8 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
* perform a normal scan.
*/
private Optional<List<ConnectorScanRange>> planCountPushdown(Table table,
TableScan scan,
- int formatVersion, boolean partitioned, List<String>
orderedPartitionKeys, ZoneId zone,
+ Schema scanSchema, int formatVersion, boolean partitioned,
List<String> orderedPartitionKeys,
+ ZoneId zone,
UnaryOperator<String> uriNormalizer, ConnectorSession session,
Optional<ConnectorExpression> filter) {
Snapshot snapshot = scan.snapshot();
if (snapshot == null) {
@@ -1377,15 +1389,16 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
if (!visibleRows.isPresent()) {
return Optional.empty();
}
- return planManifestCountRange(table, scan,
visibleRows.getAsLong(), formatVersion,
- partitioned, orderedPartitionKeys, zone, uriNormalizer,
session, filter,
- netPositionDeletes);
+ return planManifestCountRange(
+ table, scan, scanSchema, visibleRows.getAsLong(),
formatVersion, partitioned,
+ orderedPartitionKeys, zone, uriNormalizer, session,
filter, netPositionDeletes);
}
// Older manifest lists may omit aggregate counters. Preserve
correctness by falling back to the
// bounded per-file enumeration instead of trusting snapshot summary
metadata.
- return planCountPushdownFromFileTasks(table, scan, formatVersion,
partitioned,
- orderedPartitionKeys, zone, uriNormalizer, session, filter,
netPositionDeletes,
+ return planCountPushdownFromFileTasks(
+ table, scan, scanSchema, formatVersion, partitioned,
orderedPartitionKeys, zone,
+ uriNormalizer, session, filter, netPositionDeletes,
netPositionDeletes ? positionDeleteRows.getAsLong() : 0);
}
@@ -1421,33 +1434,37 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
netPositionDeletes ? positionDeleteRows.getAsLong() :
0).isPresent();
}
- private Optional<List<ConnectorScanRange>> planManifestCountRange(Table
table, TableScan scan, long exactCount,
- int formatVersion, boolean partitioned, List<String>
orderedPartitionKeys, ZoneId zone,
- UnaryOperator<String> uriNormalizer, ConnectorSession session,
Optional<ConnectorExpression> filter,
- boolean netPositionDeletes) {
+ private Optional<List<ConnectorScanRange>> planManifestCountRange(
+ Table table, TableScan scan, Schema scanSchema, long exactCount,
int formatVersion,
+ boolean partitioned, List<String> orderedPartitionKeys, ZoneId
zone,
+ UnaryOperator<String> uriNormalizer, ConnectorSession session,
+ Optional<ConnectorExpression> filter, boolean netPositionDeletes) {
if (!isManifestCacheEnabled()) {
- return buildManifestCountRange(table, scan.planFiles(),
exactCount, formatVersion, partitioned,
+ return buildManifestCountRange(
+ table, scanSchema, scan.planFiles(), exactCount,
formatVersion, partitioned,
orderedPartitionKeys, zone, uriNormalizer,
netPositionDeletes);
}
String statsQueryId = session != null ? session.getQueryId() : null;
try {
- return buildManifestCountRange(table,
- cacheBackedFileScanTasks(scan, session, table, filter,
statsQueryId), exactCount,
- formatVersion, partitioned, orderedPartitionKeys, zone,
uriNormalizer, netPositionDeletes);
+ return buildManifestCountRange(
+ table, scanSchema, cacheBackedFileScanTasks(scan, session,
table, filter, statsQueryId),
+ exactCount, formatVersion, partitioned,
orderedPartitionKeys, zone, uriNormalizer,
+ netPositionDeletes);
} catch (Exception e) {
LOG.warn("Iceberg count-pushdown representative plan with manifest
cache failed, "
+ "falling back to SDK scan: {}", e.getMessage(), e);
manifestCache.recordFailure(statsQueryId);
// The SDK retry must own a new iterable because a lazy cache
failure may leave the first one partial.
- return buildManifestCountRange(table, scan.planFiles(),
exactCount, formatVersion, partitioned,
+ return buildManifestCountRange(
+ table, scanSchema, scan.planFiles(), exactCount,
formatVersion, partitioned,
orderedPartitionKeys, zone, uriNormalizer,
netPositionDeletes);
}
}
- private Optional<List<ConnectorScanRange>> buildManifestCountRange(Table
table,
- CloseableIterable<FileScanTask> tasks, long exactCount, int
formatVersion, boolean partitioned,
- List<String> orderedPartitionKeys, ZoneId zone,
UnaryOperator<String> uriNormalizer,
- boolean netPositionDeletes) {
+ private Optional<List<ConnectorScanRange>> buildManifestCountRange(
+ Table table, Schema scanSchema, CloseableIterable<FileScanTask>
tasks, long exactCount,
+ int formatVersion, boolean partitioned, List<String>
orderedPartitionKeys, ZoneId zone,
+ UnaryOperator<String> uriNormalizer, boolean netPositionDeletes) {
try (CloseableIterable<FileScanTask> closeableTasks = tasks) {
for (FileScanTask task : closeableTasks) {
// Data-manifest rows and the separately netted live delete
files are authoritative, but retain
@@ -1455,8 +1472,9 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
if (hasNonIgnorableTaskDeletes(task, netPositionDeletes) ||
task.file().recordCount() < 0) {
return Optional.empty();
}
- return Optional.of(Collections.singletonList(buildRange(table,
task.file(), task, formatVersion,
- partitioned, orderedPartitionKeys, zone,
uriNormalizer, exactCount, -1, null)));
+ return Optional.of(Collections.singletonList(buildRange(
+ table, scanSchema, task.file(), task, formatVersion,
partitioned,
+ orderedPartitionKeys, zone, uriNormalizer, exactCount,
-1, null)));
}
} catch (IOException e) {
throw new RuntimeException("Failed to plan iceberg count-pushdown
file, error message is:"
@@ -1465,17 +1483,20 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
return exactCount == 0 ? Optional.of(Collections.emptyList()) :
Optional.empty();
}
- private Optional<List<ConnectorScanRange>>
planCountPushdownFromFileTasks(Table table, TableScan scan,
- int formatVersion, boolean partitioned, List<String>
orderedPartitionKeys, ZoneId zone,
- UnaryOperator<String> uriNormalizer, ConnectorSession session,
Optional<ConnectorExpression> filter,
- boolean netPositionDeletes, long positionDeleteRows) {
+ private Optional<List<ConnectorScanRange>> planCountPushdownFromFileTasks(
+ Table table, TableScan scan, Schema scanSchema, int formatVersion,
boolean partitioned,
+ List<String> orderedPartitionKeys, ZoneId zone,
UnaryOperator<String> uriNormalizer,
+ ConnectorSession session, Optional<ConnectorExpression> filter,
boolean netPositionDeletes,
+ long positionDeleteRows) {
if (!isManifestCacheEnabled()) {
- return accumulateCountPushdownFileTasks(table, scan.planFiles(),
formatVersion, partitioned,
+ return accumulateCountPushdownFileTasks(
+ table, scanSchema, scan.planFiles(), formatVersion,
partitioned,
orderedPartitionKeys, zone, uriNormalizer,
netPositionDeletes, positionDeleteRows);
}
String statsQueryId = session != null ? session.getQueryId() : null;
try {
- return accumulateCountPushdownFileTasks(table,
+ return accumulateCountPushdownFileTasks(
+ table, scanSchema,
cacheBackedFileScanTasks(scan, session, table, filter,
statsQueryId), formatVersion,
partitioned, orderedPartitionKeys, zone, uriNormalizer,
netPositionDeletes,
positionDeleteRows);
@@ -1485,15 +1506,16 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
manifestCache.recordFailure(statsQueryId);
// The retry owns a fresh accumulator so rows consumed before a
lazy cache failure are never counted
// twice and the SDK fallback remains an exact restart.
- return accumulateCountPushdownFileTasks(table, scan.planFiles(),
formatVersion, partitioned,
+ return accumulateCountPushdownFileTasks(
+ table, scanSchema, scan.planFiles(), formatVersion,
partitioned,
orderedPartitionKeys, zone, uriNormalizer,
netPositionDeletes, positionDeleteRows);
}
}
- private Optional<List<ConnectorScanRange>>
accumulateCountPushdownFileTasks(Table table,
- CloseableIterable<FileScanTask> tasks, int formatVersion, boolean
partitioned,
- List<String> orderedPartitionKeys, ZoneId zone,
UnaryOperator<String> uriNormalizer,
- boolean netPositionDeletes, long positionDeleteRows) {
+ private Optional<List<ConnectorScanRange>>
accumulateCountPushdownFileTasks(
+ Table table, Schema scanSchema, CloseableIterable<FileScanTask>
tasks, int formatVersion,
+ boolean partitioned, List<String> orderedPartitionKeys, ZoneId
zone,
+ UnaryOperator<String> uriNormalizer, boolean netPositionDeletes,
long positionDeleteRows) {
FileScanTask representative = null;
long exactCount = 0;
try (CloseableIterable<FileScanTask> closeableTasks = tasks) {
@@ -1525,9 +1547,9 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
}
// targetSplitSize = -1: the count-pushdown collapse emits a single
range, so its scheduling weight is
// irrelevant and PluginDrivenSplit keeps SplitWeight.standard().
- return Optional.of(Collections.singletonList(buildRange(table,
representative.file(), representative,
- formatVersion, partitioned, orderedPartitionKeys, zone,
uriNormalizer,
- visibleRows.getAsLong(), -1, null)));
+ return Optional.of(Collections.singletonList(buildRange(
+ table, scanSchema, representative.file(), representative,
formatVersion, partitioned,
+ orderedPartitionKeys, zone, uriNormalizer,
visibleRows.getAsLong(), -1, null)));
}
private static boolean hasNonIgnorableTaskDeletes(FileScanTask task,
boolean netPositionDeletes) {
@@ -1652,13 +1674,15 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
* size-proportional {@code selfSplitWeight} are per-slice. A {@code null}
scratch (the single
* count-pushdown range) computes without caching. Byte-identical to the
former per-slice recompute.</p>
*/
- private IcebergScanRange buildRange(Table table, DataFile dataFile,
FileScanTask task, int formatVersion,
- boolean partitioned, List<String> orderedPartitionKeys, ZoneId
zone,
+ private IcebergScanRange buildRange(Table table, Schema scanSchema,
DataFile dataFile,
+ FileScanTask task, int formatVersion, boolean partitioned,
+ List<String> orderedPartitionKeys, ZoneId zone,
UnaryOperator<String> uriNormalizer, long pushDownRowCount, long
targetSplitSize,
PerFileScratch scratch) {
PerFileScratch file = (scratch != null && scratch.file == dataFile)
? scratch
- : computePerFileInvariants(table, dataFile, task,
formatVersion, partitioned,
+ : computePerFileInvariants(
+ table, scanSchema, dataFile, task, formatVersion,
partitioned,
orderedPartitionKeys, zone, uriNormalizer, scratch);
// M-2 size-proportional weight numerator = this split's byte length +
the byte size of every
// merge-on-read delete file applying to it, mirroring legacy
IcebergSplit.selfSplitWeight (ctor sets
@@ -1702,8 +1726,9 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
* so the memoized range is byte-identical to the per-slice recompute. The
scratch fields are assigned only
* after the fail-loud check, so a rejected file never leaves a
half-populated scratch.
*/
- private PerFileScratch computePerFileInvariants(Table table, DataFile
dataFile, FileScanTask task,
- int formatVersion, boolean partitioned, List<String>
orderedPartitionKeys, ZoneId zone,
+ private PerFileScratch computePerFileInvariants(Table table, Schema
scanSchema, DataFile dataFile,
+ FileScanTask task, int formatVersion, boolean partitioned,
+ List<String> orderedPartitionKeys, ZoneId zone,
UnaryOperator<String> uriNormalizer, PerFileScratch scratch) {
perFileInvariantComputeCount++;
Integer partitionSpecId = null;
@@ -1726,7 +1751,8 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
// Order the identity values as the path_partition_keys list
(legacy getOrderedPathPartitionKeys),
// filtered to keys this file carries — so columns-from-path
matches legacy ordering exactly.
Map<String, String> identityMap =
-
IcebergPartitionUtils.getIdentityPartitionInfoMap(partitionData, spec, table,
zone);
+ IcebergPartitionUtils.getIdentityPartitionInfoMap(
+ partitionData, spec, scanSchema, zone);
Map<String, String> ordered = new LinkedHashMap<>();
for (String key : orderedPartitionKeys) {
if (identityMap.containsKey(key)) {
@@ -1941,7 +1967,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
* <ul>
* <li>{@code file_format_type=jni} — makes the parent default the
per-range format to {@code FORMAT_JNI},
* which each native range overrides to parquet/orc in {@code
populateRangeParams} (mirrors paimon).</li>
- * <li>{@code path_partition_keys} — the lowercased, comma-joined
identity partition columns, so FE marks
+ * <li>{@code path_partition_keys} — the case-preserved, comma-joined
identity partition columns, so FE marks
* those slots as partition columns and excludes them from the
file-decode set; without it BE
* double-fills the partition columns (decode-from-file AND
append-from-path) and DCHECKs (CI #968880).
* Emitted only when the table is partitioned (an empty value would
split into a single "" key).</li>
@@ -2018,7 +2044,8 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
// (empty partitionKeys + null schema-dict table). resolveTable still
loads the base table here for
// the credential overlay below (the metadata table shares the base
table's FileIO).
if (!systemTable) {
- List<String> partitionKeys =
IcebergPartitionUtils.getIdentityPartitionColumns(table);
+ List<String> partitionKeys =
+ IcebergPartitionUtils.getIdentityPartitionColumns(table,
scanSchema);
if (!partitionKeys.isEmpty()) {
props.put(ScanNodePropertyKeys.PATH_PARTITION_KEYS,
String.join(",", partitionKeys));
}
@@ -2059,7 +2086,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
// pruned-by-requested-columns dict (CI #969249).
//
// Row-lineage (format-version >= 3): _row_id /
_last_updated_sequence_number are GENERATED BE scan slots
- // (they reach BE column_names) but are NOT in schema.columns(), so
requestedLowerNames — keyed off the
+ // (they reach BE column_names) but are NOT in schema.columns(), so
requestedColumnNames — keyed off the
// iceberg column handles — never carries them.
encodeSchemaEvolutionProp(appendRowLineage=true) appends
// them to the dict root so BE's StructNode children map contains
them; else the ParquetReader's
// unconditional children.at("_row_id") std::out_of_range-SIGABRTs the
whole BE.
@@ -2100,7 +2127,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
enableVarbinary, enableTimestampTz);
} else {
dict = IcebergSchemaUtils.encodeSchemaEvolutionProp(
- table, table.schema(), requestedLowerNames(columns),
+ table, table.schema(), requestedColumnNames(columns),
appendRowLineage, enableVarbinary, enableTimestampTz);
}
props.put(SCHEMA_EVOLUTION_PROP, dict);
@@ -2138,7 +2165,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
Table metadataTable =
MetadataTableUtils.createMetadataTableInstance(
table, MetadataTableType.POSITION_DELETES);
props.put(SCHEMA_EVOLUTION_PROP,
IcebergSchemaUtils.encodeSchemaEvolutionProp(
- metadataTable, metadataTable.schema(),
requestedLowerNames(columns), false,
+ metadataTable, metadataTable.schema(),
requestedColumnNames(columns), false,
enableVarbinary, enableTimestampTz));
}
// Pushed-predicate EXPLAIN prop (explain gap): serialize the iceberg
Expression form of each pushed
@@ -2173,14 +2200,14 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
}
/**
- * The lowercased names of the requested (pruned) columns — the
authoritative Doris scan slots the field-id
+ * The case-preserved names of the requested (pruned) columns — the
authoritative Doris scan slots the field-id
* dictionary keys its {@code -1} entry off (so its top-level names == the
BE scan-slot names BY
* CONSTRUCTION; CI #969249). The names come straight from the {@link
IcebergColumnHandle}s
- * {@code IcebergConnectorMetadata.getColumnHandles} produced (already
lowercased), minus the synthesized
+ * {@code IcebergConnectorMetadata.getColumnHandles} produced, minus the
synthesized
* {@code _file} / {@code _pos} / hidden row-id columns, which are not in
the table schema. An empty list
* (count-only scan / no column handles) makes the dictionary fall back to
all top-level columns.
*/
- private static List<String>
requestedLowerNames(List<ConnectorColumnHandle> columns) {
+ private static List<String>
requestedColumnNames(List<ConnectorColumnHandle> columns) {
if (columns == null || columns.isEmpty()) {
return Collections.emptyList();
}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java
index ef48c73ed2d..286a31fcfb5 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java
@@ -69,7 +69,7 @@ public class IcebergScanRange implements ConnectorScanRange {
private final String rawPartitionKey;
private final Long firstRowId;
private final Long lastUpdatedSequenceNumber;
- // Identity partition column (lowercased) -> serialized value, already
ordered as the path_partition_keys
+ // Identity partition column (case-preserved) -> serialized value, already
ordered as the path_partition_keys
// list, filtered to keys this file carries. Drives columns-from-path.
Never null (empty when unpartitioned).
private final Map<String, String> partitionValues;
// Whether this range counts toward the EXPLAIN `partition=N/M` /
sql_block_rule partition_num display
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaUtils.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaUtils.java
index 0ea68a006c7..1be990d5554 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaUtils.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaUtils.java
@@ -109,19 +109,19 @@ public final class IcebergSchemaUtils {
}
/**
- * Orchestrator: build the schema dictionary for {@code table} keyed off
the requested (lowercased) column
- * names and serialize it for transport via the scan-node props. {@code
requestedLowerNames} is the pruned
+ * Orchestrator: build the schema dictionary for {@code table} keyed off
the requested column names and
+ * serialize it for transport via the scan-node props. {@code
requestedColumnNames} is the pruned
* scan-slot list ({@code PluginDrivenScanNode} hands the provider the
requested columns); an empty list
* (count-only scan / no column handles) falls back to all top-level
schema columns.
*/
- static String encodeSchemaEvolutionProp(Table table, List<String>
requestedLowerNames) {
- return encodeSchemaEvolutionProp(table, table.schema(),
requestedLowerNames, false, false, false);
+ static String encodeSchemaEvolutionProp(Table table, List<String>
requestedColumnNames) {
+ return encodeSchemaEvolutionProp(table, table.schema(),
requestedColumnNames, false, false, false);
}
/**
* Like {@link #encodeSchemaEvolutionProp(Table, List)} but builds the
dictionary from an explicit
* {@code dictSchema} (the latest schema for a normal read, or a
historical schema for a time-travel read —
- * T07 Option A passes the PINNED schema with an empty {@code
requestedLowerNames} so the dict covers every
+ * T07 Option A passes the PINNED schema with an empty {@code
requestedColumnNames} so the dict covers every
* BE scan slot). The name mapping is still read from {@code table} (it is
table-level, not schema-versioned).
*
* <p>{@code appendRowLineage} (set by the caller when the table
format-version >= 3) appends the iceberg
@@ -131,12 +131,12 @@ public final class IcebergSchemaUtils {
* {@code children.at("_row_id")} {@code std::out_of_range}-SIGABRTs the
whole BE. See
* {@link #appendRowLineageFields}.</p>
*/
- static String encodeSchemaEvolutionProp(Table table, Schema dictSchema,
List<String> requestedLowerNames,
+ static String encodeSchemaEvolutionProp(Table table, Schema dictSchema,
List<String> requestedColumnNames,
boolean appendRowLineage) {
// Thin overload: keep both optional type mappings disabled for
callers that do not thread catalog
// properties.
return encodeSchemaEvolutionProp(
- table, dictSchema, requestedLowerNames, appendRowLineage,
false, false);
+ table, dictSchema, requestedColumnNames, appendRowLineage,
false, false);
}
/**
@@ -145,19 +145,19 @@ public final class IcebergSchemaUtils {
* TIMESTAMPTZ column's iceberg initial default is serialized consistently
with how BE will read the
* column (keep the trailing offset when tz-mapping is on, drop it —
DATETIMEV2 UTC wall time — when off).
*/
- static String encodeSchemaEvolutionProp(Table table, Schema dictSchema,
List<String> requestedLowerNames,
+ static String encodeSchemaEvolutionProp(Table table, Schema dictSchema,
List<String> requestedColumnNames,
boolean appendRowLineage, boolean enableTimestampTz) {
return encodeSchemaEvolutionProp(
- table, dictSchema, requestedLowerNames, appendRowLineage,
false, enableTimestampTz);
+ table, dictSchema, requestedColumnNames, appendRowLineage,
false, enableTimestampTz);
}
/** Build and encode a schema dictionary with the catalog's Doris
type-mapping options. */
- static String encodeSchemaEvolutionProp(Table table, Schema dictSchema,
List<String> requestedLowerNames,
+ static String encodeSchemaEvolutionProp(Table table, Schema dictSchema,
List<String> requestedColumnNames,
boolean appendRowLineage, boolean enableVarbinary, boolean
enableTimestampTz) {
Optional<Map<Integer, List<String>>> nameMapping =
extractNameMapping(table);
// #65784: it is the PRESENCE of the table-level mapping (not its
non-emptiness) that makes it
// authoritative for BE, so thread isPresent() through as
hasNameMapping.
- TSchema current = buildCurrentSchema(dictSchema, requestedLowerNames,
+ TSchema current = buildCurrentSchema(dictSchema, requestedColumnNames,
nameMapping.orElse(Collections.emptyMap()),
nameMapping.isPresent(), enableVarbinary,
enableTimestampTz);
if (appendRowLineage) {
@@ -267,16 +267,16 @@ public final class IcebergSchemaUtils {
* fix: the top-level names == the BE scan slots so the {@code StructNode}
DCHECK can never miss). Each
* requested name is matched case-insensitively to the iceberg schema; its
top-level {@code TField} name is
* the requested name VERBATIM (byte-matching the Doris slot name;
case-preserved post-#65094). An
- * empty/{@code null} {@code requestedLowerNames} falls back to all
top-level columns (lowercased). Fail
- * loud if a requested column is absent from the schema (a genuine
FE/connector inconsistency — not a
- * silent drop).
+ * empty/{@code null} {@code requestedColumnNames} falls back to all
top-level columns using the
+ * authoritative Iceberg field names. Fail loud if a requested column is
absent from the schema (a genuine
+ * FE/connector inconsistency — not a silent drop).
*/
- static TSchema buildCurrentSchema(Schema schema, List<String>
requestedLowerNames,
+ static TSchema buildCurrentSchema(Schema schema, List<String>
requestedColumnNames,
Map<Integer, List<String>> nameMapping) {
// Thin overload: default enableTimestampTz=false (pre-#65502
timestamp-default behaviour) and derive
// hasNameMapping from the map (a non-empty map ⇒ the table carried a
mapping — the #65784 default;
// the production path threads the precise isPresent() instead).
- return buildCurrentSchema(schema, requestedLowerNames, nameMapping,
+ return buildCurrentSchema(schema, requestedColumnNames, nameMapping,
nameMapping != null && !nameMapping.isEmpty(), false, false);
}
@@ -287,26 +287,26 @@ public final class IcebergSchemaUtils {
* AUTHORITATIVE, so every field carries an explicit (possibly empty)
per-field mapping (see
* {@link #buildField}).
*/
- static TSchema buildCurrentSchema(Schema schema, List<String>
requestedLowerNames,
+ static TSchema buildCurrentSchema(Schema schema, List<String>
requestedColumnNames,
Map<Integer, List<String>> nameMapping, boolean hasNameMapping,
boolean enableTimestampTz) {
- return buildCurrentSchema(schema, requestedLowerNames, nameMapping,
hasNameMapping, false,
+ return buildCurrentSchema(schema, requestedColumnNames, nameMapping,
hasNameMapping, false,
enableTimestampTz);
}
/** Build the dictionary using the same Doris scalar mappings as the
connector's catalog columns. */
- static TSchema buildCurrentSchema(Schema schema, List<String>
requestedLowerNames,
+ static TSchema buildCurrentSchema(Schema schema, List<String>
requestedColumnNames,
Map<Integer, List<String>> nameMapping, boolean hasNameMapping,
boolean enableVarbinary,
boolean enableTimestampTz) {
TSchema tSchema = new TSchema();
tSchema.setSchemaId(CURRENT_SCHEMA_ID);
TStructField root = new TStructField();
- if (requestedLowerNames == null || requestedLowerNames.isEmpty()) {
+ if (requestedColumnNames == null || requestedColumnNames.isEmpty()) {
for (Types.NestedField field : schema.columns()) {
- addField(root, buildField(field,
field.name().toLowerCase(Locale.ROOT), nameMapping,
+ addField(root, buildField(field, field.name(), nameMapping,
hasNameMapping, enableVarbinary, enableTimestampTz));
}
} else {
- for (String name : requestedLowerNames) {
+ for (String name : requestedColumnNames) {
Types.NestedField field =
schema.caseInsensitiveFindField(name);
if (field == null) {
throw new RuntimeException("iceberg schema-evolution:
requested column '" + name
@@ -327,7 +327,7 @@ public final class IcebergSchemaUtils {
tSchema.setSchemaId(CURRENT_SCHEMA_ID);
TStructField root = new TStructField();
for (Types.NestedField field : fields) {
- addField(root, buildField(field,
field.name().toLowerCase(Locale.ROOT), nameMapping,
+ addField(root, buildField(field, field.name(), nameMapping,
hasNameMapping, enableVarbinary, enableTimestampTz));
}
tSchema.setRootField(root);
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
index a4cba8bd550..a33efc5ea2d 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java
@@ -164,12 +164,13 @@ public class IcebergPartitionUtilsTest {
UUID value = UUID.fromString("00112233-4455-6677-8899-aabbccddeeff");
data.set(0, value);
Assertions.assertEquals("00112233-4455-6677-8899-aabbccddeeff",
- IcebergPartitionUtils.getIdentityPartitionInfoMap(data, spec,
table, ZoneOffset.UTC).get("key"));
+ IcebergPartitionUtils.getIdentityPartitionInfoMap(
+ data, spec, table.schema(),
ZoneOffset.UTC).get("key"));
Assertions.assertEquals(value.toString(),
IcebergPartitionUtils.serializePartitionValue(Types.UUIDType.get(), value,
ZoneOffset.UTC));
data.set(0, null);
- Assertions.assertNull(
- IcebergPartitionUtils.getIdentityPartitionInfoMap(data, spec,
table, ZoneOffset.UTC).get("key"));
+
Assertions.assertNull(IcebergPartitionUtils.getIdentityPartitionInfoMap(
+ data, spec, table.schema(), ZoneOffset.UTC).get("key"));
}
@Test
@@ -258,7 +259,8 @@ public class IcebergPartitionUtilsTest {
.build();
Table table = tableWith(schema, spec);
- List<String> cols =
IcebergPartitionUtils.getIdentityPartitionColumns(table);
+ List<String> cols =
+ IcebergPartitionUtils.getIdentityPartitionColumns(table,
table.schema());
// Only the two identity columns, CASE-PRESERVED (#65094 read-path
alignment), in spec order; the
// bucket(id) transform is excluded. MUTATION: including non-identity
transforms -> "id_bucket"/"id"
@@ -284,13 +286,31 @@ public class IcebergPartitionUtilsTest {
table.updateSpec().removeField("payload").addField("int_col").commit();
Assertions.assertEquals(2, table.specs().size());
- List<String> cols =
IcebergPartitionUtils.getIdentityPartitionColumns(table);
+ List<String> cols =
+ IcebergPartitionUtils.getIdentityPartitionColumns(table,
table.schema());
// Both specs contribute, in first-seen (spec id) order. MUTATION:
iterating table.spec() instead of
// table.specs() -> ["int_col"] -> red. MUTATION: dropping the
LinkedHashSet dedup -> duplicates -> red.
Assertions.assertEquals(Arrays.asList("payload", "int_col"), cols);
}
+ @Test
+ public void
identityPartitionColumnsUseAuthoritativeScanSchemaAfterCaseRename() {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.required(2, "P", Types.IntegerType.get()));
+ PartitionSpec spec =
PartitionSpec.builderFor(schema).identity("P").build();
+ Table table = tableWith(schema, spec);
+ Schema pinnedSchema = table.schema();
+
+ table.updateSchema().renameColumn("P", "p").commit();
+
+ Assertions.assertEquals(Collections.singletonList("P"),
+ IcebergPartitionUtils.getIdentityPartitionColumns(table,
pinnedSchema));
+ Assertions.assertEquals(Collections.singletonList("p"),
+ IcebergPartitionUtils.getIdentityPartitionColumns(table,
table.schema()));
+ }
+
// ---- toPartitionFieldKeys ----
@Test
@@ -378,7 +398,8 @@ public class IcebergPartitionUtilsTest {
pd.set(1, 2); // id_bucket = 2 (non-identity -> skipped)
Map<String, String> info =
- IcebergPartitionUtils.getIdentityPartitionInfoMap(pd, spec,
table, ZoneOffset.UTC);
+ IcebergPartitionUtils.getIdentityPartitionInfoMap(
+ pd, spec, table.schema(), ZoneOffset.UTC);
// Only the identity column survives, key CASE-PRESERVED (#65094
read-path alignment). MUTATION:
// emitting id_bucket -> size 2 -> red. MUTATION: re-lowercasing the
key -> "p" != "P" -> red.
@@ -396,12 +417,34 @@ public class IcebergPartitionUtilsTest {
pd.set(0, null); // genuine null partition value
Map<String, String> info =
- IcebergPartitionUtils.getIdentityPartitionInfoMap(pd, spec,
table, ZoneOffset.UTC);
+ IcebergPartitionUtils.getIdentityPartitionInfoMap(
+ pd, spec, table.schema(), ZoneOffset.UTC);
Assertions.assertTrue(info.containsKey("p"));
Assertions.assertNull(info.get("p"));
}
+ @Test
+ public void
identityPartitionInfoMapUsesAuthoritativeScanSchemaAfterCaseRename() {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.required(2, "P", Types.IntegerType.get()));
+ PartitionSpec spec =
PartitionSpec.builderFor(schema).identity("P").build();
+ Table table = tableWith(schema, spec);
+ Schema pinnedSchema = table.schema();
+
+ table.updateSchema().renameColumn("P", "p").commit();
+
+ PartitionData pd = new PartitionData(spec.partitionType());
+ pd.set(0, 7);
+ Assertions.assertEquals(Collections.singletonMap("P", "7"),
+ IcebergPartitionUtils.getIdentityPartitionInfoMap(
+ pd, spec, pinnedSchema, ZoneOffset.UTC));
+ Assertions.assertEquals(Collections.singletonMap("p", "7"),
+ IcebergPartitionUtils.getIdentityPartitionInfoMap(
+ pd, spec, table.schema(), ZoneOffset.UTC));
+ }
+
// ---- getPartitionDataJson ----
@Test
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
index 633595a680a..5cbea55cfd4 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
@@ -3020,6 +3020,77 @@ public class IcebergScanPlanProviderTest {
.getFields().get(1).getFieldPtr().getName());
}
+ @Test
+ public void
getScanNodePropertiesUnderTopnPreservesMixedCaseFullSchemaNames() throws
Exception {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "ID", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "Name", Types.StringType.get()));
+ Table table = createTable("mixed_topn", schema,
PartitionSpec.unpartitioned());
+ IcebergScanPlanProvider provider = new IcebergScanPlanProvider(
+ IcebergCatalogProperties.of(Collections.emptyMap()),
opsReturning(table));
+
+ Map<String, String> props = provider.getScanNodeProperties(
+ null, new IcebergTableHandle("db1",
"mixed_topn").withTopnLazyMaterialize(true),
+ Collections.singletonList(new IcebergColumnHandle("ID", 1)),
Optional.empty());
+ TFileScanRangeParams params = new TFileScanRangeParams();
+ provider.populateScanLevelParams(params, props);
+
+ List<TFieldPtr> fields =
params.getHistorySchemaInfo().get(0).getRootField().getFields();
+ Assertions.assertEquals("ID", fields.get(0).getFieldPtr().getName());
+ Assertions.assertEquals("Name", fields.get(1).getFieldPtr().getName());
+ }
+
+ @Test
+ public void pinnedMixedCasePartitionUsesOneNameAcrossAllCarriers() throws
Exception {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.required(2, "City", Types.StringType.get()));
+ PartitionSpec spec =
PartitionSpec.builderFor(schema).identity("City").build();
+ Table table = createTable("mixed_pin", schema, spec,
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
+ table.newAppend()
+ .appendFile(partitionedFile(spec,
"s3://b/db/mixed_pin/City=Beijing/a.parquet",
+ "Beijing"))
+ .commit();
+ long snapshotId = table.currentSnapshot().snapshotId();
+ long schemaId = table.currentSnapshot().schemaId();
+ table.updateSchema().renameColumn("City", "city").commit();
+
+ IcebergTableHandle handle = new IcebergTableHandle("db1", "mixed_pin")
+ .withSnapshot(snapshotId, null, schemaId);
+ List<ConnectorColumnHandle> columns =
+ Collections.singletonList(new IcebergColumnHandle("City", 2));
+ IcebergScanPlanProvider provider = new IcebergScanPlanProvider(
+ IcebergCatalogProperties.of(Collections.emptyMap()),
opsReturning(table));
+
+ Map<String, String> props = provider.getScanNodeProperties(
+ null, handle, columns, Optional.empty());
+ Assertions.assertEquals("City",
props.get(ScanNodePropertyKeys.PATH_PARTITION_KEYS));
+ TFileScanRangeParams params = new TFileScanRangeParams();
+ provider.populateScanLevelParams(params, props);
+ Assertions.assertEquals("City",
params.getHistorySchemaInfo().get(0).getRootField()
+ .getFields().get(1).getFieldPtr().getName());
+
+ List<ConnectorScanRange> ranges = provider.planScan(
+ new FakeScanSession("UTC", Collections.emptyMap()),
+ ConnectorScanRequest.builder(handle, columns).build());
+ Assertions.assertEquals(1, ranges.size());
+ TFileRangeDesc desc = populate(ranges.get(0));
+ Assertions.assertEquals(Collections.singletonList("City"),
desc.getColumnsFromPathKeys());
+ Assertions.assertEquals(Collections.singletonList("Beijing"),
desc.getColumnsFromPath());
+
+ ConnectorSplitSource splits = provider.streamSplits(
+ new FakeScanSession("UTC", Collections.emptyMap()),
+ ConnectorScanRequest.builder(handle, columns).build());
+ Assertions.assertTrue(splits.hasNext());
+ TFileRangeDesc streamingDesc = populate(splits.next());
+ splits.close();
+ Assertions.assertEquals(Collections.singletonList("City"),
+ streamingDesc.getColumnsFromPathKeys());
+ Assertions.assertEquals(Collections.singletonList("Beijing"),
+ streamingDesc.getColumnsFromPath());
+ }
+
@Test
public void getScanNodePropertiesPinTakesPrecedenceOverTopnLazyMat()
throws Exception {
// A time-travel pin + Top-N lazy mat must take the PIN branch (pinned
schema, full columns), not the
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
index 323ac026b8a..0e9278ab8bb 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
@@ -79,11 +79,11 @@ public class IcebergSchemaUtilsTest {
}
/** Build the dictionary for the given requested column names and return
the single (-1) entry. */
- private static TSchema dict(Table table, String... requestedLowerNames) {
+ private static TSchema dict(Table table, String... requestedColumnNames) {
// Mirror the production path (encodeSchemaEvolutionProp): thread the
PRECISE isPresent() as
// hasNameMapping (#65784), so a present-but-empty mapping is still
authoritative.
Optional<Map<Integer, List<String>>> nameMapping =
IcebergSchemaUtils.extractNameMapping(table);
- return IcebergSchemaUtils.buildCurrentSchema(table.schema(),
Arrays.asList(requestedLowerNames),
+ return IcebergSchemaUtils.buildCurrentSchema(table.schema(),
Arrays.asList(requestedColumnNames),
nameMapping.orElse(Collections.emptyMap()),
nameMapping.isPresent(), false);
}
@@ -182,6 +182,46 @@ public class IcebergSchemaUtilsTest {
Assertions.assertFalse(fields.containsKey("name"));
}
+ @Test
+ public void fullSchemaFallbackPreservesMixedCaseTopLevelNamesOnly() {
+ Schema schema = new Schema(
+ Types.NestedField.required(7, "ID", Types.IntegerType.get()),
+ Types.NestedField.optional(9, "Name", Types.StringType.get()),
+ Types.NestedField.optional(11, "Payload", Types.StructType.of(
+ Types.NestedField.optional(12, "Child",
Types.IntegerType.get()))));
+
+ Map<String, TField> fields =
topFields(IcebergSchemaUtils.buildCurrentSchema(
+ schema, Collections.emptyList(), Collections.emptyMap()));
+
+ // Snapshot-pin, Top-N lazy materialization and count-only scans have
no pruned requested list and use
+ // this fallback. Its top-level keys must equal the case-preserving
Doris scan slots.
+ Assertions.assertTrue(fields.containsKey("ID"));
+ Assertions.assertTrue(fields.containsKey("Name"));
+ Assertions.assertTrue(fields.containsKey("Payload"));
+ Assertions.assertFalse(fields.containsKey("id"));
+ Assertions.assertFalse(fields.containsKey("name"));
+ Assertions.assertFalse(fields.containsKey("payload"));
+
+ // Nested struct lookup remains lowercased by the Doris StructField
contract. The top-level fix must
+ // not leak into nested fields.
+ Assertions.assertEquals(12, childByName(fields.get("Payload"),
"child").getId());
+ }
+
+ @Test
+ public void equalityDeleteCarrierPreservesMixedCaseTopLevelNames() throws
Exception {
+ Schema schema = new Schema(
+ Types.NestedField.required(7, "ID", Types.IntegerType.get()),
+ Types.NestedField.optional(9, "Name", Types.StringType.get()));
+ Table table = createTable("mixed_equality", schema);
+
+ TFileScanRangeParams params =
decode(IcebergSchemaUtils.encodeEqualitySchemaEvolutionProp(
+ table, schema.columns(), false, false, false));
+ List<TFieldPtr> fields =
params.getHistorySchemaInfo().get(0).getRootField().getFields();
+
+ Assertions.assertEquals("ID", fields.get(0).getFieldPtr().getName());
+ Assertions.assertEquals("Name", fields.get(1).getFieldPtr().getName());
+ }
+
@Test
public void keyedOffRequestedColumnsOnlyIncludesRequested() {
// CI #969249 invariant: the -1 entry's top-level names must equal the
BE scan slots BY CONSTRUCTION.
diff --git
a/regression-test/data/external_table_p0/iceberg/test_iceberg_scan_schema_carrier_names.out
b/regression-test/data/external_table_p0/iceberg/test_iceberg_scan_schema_carrier_names.out
new file mode 100644
index 00000000000..67da718691b
--- /dev/null
+++
b/regression-test/data/external_table_p0/iceberg/test_iceberg_scan_schema_carrier_names.out
@@ -0,0 +1,37 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !mixed_case_scan --
+1 one
+2 two
+3 three
+
+-- !mixed_case_topn --
+one
+two
+
+-- !mixed_case_nested --
+1 11 alpha
+2 22 beta
+3 33 gamma
+
+-- !pinned_partition_schema --
+1 Beijing
+2 Beijing
+
+-- !pinned_partition_filter --
+1 Beijing
+2 Beijing
+
+-- !pinned_partition_topn --
+1 Beijing
+
+-- !current_partition_schema --
+1 Beijing
+2 Beijing
+3 Shanghai
+
+-- !pinned_count --
+2
+
+-- !current_count --
+3
+
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_scan_schema_carrier_names.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_scan_schema_carrier_names.groovy
new file mode 100644
index 00000000000..710fa23e590
--- /dev/null
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_scan_schema_carrier_names.groovy
@@ -0,0 +1,166 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("test_iceberg_scan_schema_carrier_names",
+ "p0,external,iceberg,external_docker,external_docker_iceberg") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable iceberg test")
+ return
+ }
+
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+
+ sql """drop catalog if exists test_iceberg_scan_schema_carrier_names"""
+
+ spark_iceberg_multi """
+ SET spark.sql.shuffle.partitions=1;
+ CREATE DATABASE IF NOT EXISTS
demo.iceberg_scan_schema_carrier_names_db;
+ DROP TABLE IF EXISTS
demo.iceberg_scan_schema_carrier_names_db.mixed_case_orc;
+ CREATE TABLE demo.iceberg_scan_schema_carrier_names_db.mixed_case_orc (
+ ID INT,
+ Name STRING,
+ Payload STRUCT<Child: INT, Other: STRING>
+ ) USING iceberg
+ TBLPROPERTIES (
+ 'format-version'='2',
+ 'write.format.default'='orc'
+ );
+ INSERT INTO demo.iceberg_scan_schema_carrier_names_db.mixed_case_orc
+ SELECT /*+ COALESCE(1) */ id, name, payload
+ FROM VALUES
+ (1, 'one', named_struct('Child', 11, 'Other', 'alpha')),
+ (2, 'two', named_struct('Child', 22, 'Other', 'beta')),
+ (3, 'three', named_struct('Child', 33, 'Other', 'gamma'))
+ AS t(id, name, payload);
+
+ DROP TABLE IF EXISTS
demo.iceberg_scan_schema_carrier_names_db.mixed_case_partition_orc;
+ CREATE TABLE
demo.iceberg_scan_schema_carrier_names_db.mixed_case_partition_orc (
+ id INT,
+ City STRING
+ ) USING iceberg
+ PARTITIONED BY (City)
+ TBLPROPERTIES (
+ 'format-version'='2',
+ 'write.format.default'='orc'
+ );
+ INSERT INTO
demo.iceberg_scan_schema_carrier_names_db.mixed_case_partition_orc
+ VALUES (1, 'Beijing'), (2, 'Beijing');
+ """
+
+ sql """
+ CREATE CATALOG test_iceberg_scan_schema_carrier_names PROPERTIES (
+ 'type'='iceberg',
+ 'iceberg.catalog.type'='rest',
+ 'uri'='http://${externalEnvIp}:${restPort}',
+ 's3.access_key'='admin',
+ 's3.secret_key'='password',
+ 's3.endpoint'='http://${externalEnvIp}:${minioPort}',
+ 's3.region'='us-east-1',
+ 'meta.cache.iceberg.table.ttl-second'='0',
+ 'meta.cache.iceberg.schema.ttl-second'='0'
+ )
+ """
+
+ try {
+ sql """
+ ALTER TABLE
+
`test_iceberg_scan_schema_carrier_names`.`iceberg_scan_schema_carrier_names_db`.`mixed_case_partition_orc`
+ CREATE TAG mixed_case_initial
+ """
+
+ spark_iceberg_multi """
+ ALTER TABLE
demo.iceberg_scan_schema_carrier_names_db.mixed_case_partition_orc
+ RENAME COLUMN City TO city_name;
+ INSERT INTO
demo.iceberg_scan_schema_carrier_names_db.mixed_case_partition_orc
+ VALUES (3, 'Shanghai');
+ """
+ sql """refresh catalog test_iceberg_scan_schema_carrier_names"""
+
+ qt_mixed_case_scan """
+ SELECT ID, Name
+ FROM
+
`test_iceberg_scan_schema_carrier_names`.`iceberg_scan_schema_carrier_names_db`.`mixed_case_orc`
+ ORDER BY ID
+ """
+
+ qt_mixed_case_topn """
+ SELECT Name
+ FROM
+
`test_iceberg_scan_schema_carrier_names`.`iceberg_scan_schema_carrier_names_db`.`mixed_case_orc`
+ ORDER BY ID
+ LIMIT 2
+ """
+
+ qt_mixed_case_nested """
+ SELECT ID, Payload.Child, Payload.Other
+ FROM
+
`test_iceberg_scan_schema_carrier_names`.`iceberg_scan_schema_carrier_names_db`.`mixed_case_orc`
+ ORDER BY ID
+ """
+
+ qt_pinned_partition_schema """
+ SELECT id, City
+ FROM
+
`test_iceberg_scan_schema_carrier_names`.`iceberg_scan_schema_carrier_names_db`.`mixed_case_partition_orc`
+ FOR VERSION AS OF 'mixed_case_initial'
+ ORDER BY id
+ """
+
+ qt_pinned_partition_filter """
+ SELECT id, City
+ FROM
+
`test_iceberg_scan_schema_carrier_names`.`iceberg_scan_schema_carrier_names_db`.`mixed_case_partition_orc`
+ FOR VERSION AS OF 'mixed_case_initial'
+ WHERE City = 'Beijing'
+ ORDER BY id
+ """
+
+ qt_pinned_partition_topn """
+ SELECT id, City
+ FROM
+
`test_iceberg_scan_schema_carrier_names`.`iceberg_scan_schema_carrier_names_db`.`mixed_case_partition_orc`
+ FOR VERSION AS OF 'mixed_case_initial'
+ ORDER BY id
+ LIMIT 1
+ """
+
+ qt_current_partition_schema """
+ SELECT id, city_name
+ FROM
+
`test_iceberg_scan_schema_carrier_names`.`iceberg_scan_schema_carrier_names_db`.`mixed_case_partition_orc`
+ ORDER BY id
+ """
+
+ qt_pinned_count """
+ SELECT COUNT(*)
+ FROM
+
`test_iceberg_scan_schema_carrier_names`.`iceberg_scan_schema_carrier_names_db`.`mixed_case_partition_orc`
+ FOR VERSION AS OF 'mixed_case_initial'
+ """
+
+ qt_current_count """
+ SELECT COUNT(*)
+ FROM
+
`test_iceberg_scan_schema_carrier_names`.`iceberg_scan_schema_carrier_names_db`.`mixed_case_partition_orc`
+ """
+ } finally {
+ sql """drop catalog if exists test_iceberg_scan_schema_carrier_names"""
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]