924060929 commented on code in PR #66007:
URL: https://github.com/apache/doris/pull/66007#discussion_r3681560806
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergTableSink.java:
##########
@@ -121,13 +136,19 @@ public PhysicalProperties getRequirePhysicalProperties() {
return PhysicalProperties.GATHER;
}
- Set<String> partitionNames = targetTable.getPartitionNames();
+ Set<String> partitionNames = new
java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+ for (PartitionField field : targetIcebergTable.spec().fields()) {
+ Types.NestedField sourceField =
targetIcebergTable.schema().findField(field.sourceId());
Review Comment:
**Blocking — this silently flips Iceberg write distribution, and the new key
does not match what the spec means.**
The old code read `targetTable.getPartitionNames()`. `targetTable` is
statically an `ExternalTable`, `TableIf#getPartitionNames()` defaults to
`Collections.emptySet()`, and only `HMSExternalTable` overrides it —
`IcebergExternalTable` does not. So on master this whole branch is **dead**:
every Iceberg write gets `SINK_RANDOM_PARTITIONED`.
Deriving the names from `spec()` makes it live, and `PhysicalPlanTranslator`
turns it into `DataPartition(TPartitionType.HIVE_TABLE_SINK_HASH_PARTITIONED,
...)`. That is a new exchange in front of every partitioned-Iceberg INSERT plus
a change in output file count and write parallelism — a behavior change
unrelated to metadata-generation consistency, and not mentioned in the
description.
It also does not match the semantics of that distribution spec.
`PhysicalHiveTableSink` uses `getPartitionColumnNames()`, i.e. **identity**
partition columns, so hashing on them co-locates one partition on one instance.
Here the hash key is the partition field's *source* column, so for `days(ts)` /
`bucket(n, id)` / `truncate(n, s)` rows of the same partition still scatter
across instances — you pay for a shuffle without reducing files per partition.
Minor extra: `spec().fields()` retains dropped partition fields as `void`
transforms, and their source columns end up in the hash key too.
Suggestion: take this out of this PR, and when it lands, restrict it to
`Transforms.identity()` fields (or hash the transformed value).
Separately, the index change on the loop below is a genuine improvement
worth keeping — walking `child().getOutput()` directly instead of indexing it
with a position from `getFullSchema()` removes a latent mismatch whenever the
full schema contains hidden columns.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java:
##########
@@ -989,15 +990,23 @@ public void addPlannerHook(PlannerHook plannerHook) {
* @param tableSnapshot table snapshot info
* @param scanParams table scan params (e.g., branch/tag for Iceberg
tables)
*/
- public void loadSnapshots(TableIf specificTable, Optional<TableSnapshot>
tableSnapshot,
+ public Optional<MvccSnapshot> loadSnapshots(TableIf specificTable,
Optional<TableSnapshot> tableSnapshot,
Optional<TableScanParams> scanParams) {
- if (specificTable instanceof MvccTable) {
- MvccTableInfo mvccTableInfo = new MvccTableInfo(specificTable);
- if (!snapshots.containsKey(mvccTableInfo)) {
- snapshots.put(mvccTableInfo,
- ((MvccTable)
specificTable).loadSnapshot(tableSnapshot, scanParams));
- }
+ if (!(specificTable instanceof MvccTable)) {
+ return Optional.empty();
}
+ MvccTableInfo mvccTableInfo = new MvccTableInfo(specificTable);
+ MvccSnapshot snapshot;
+ if (tableSnapshot.isPresent() || scanParams.isPresent()) {
Review Comment:
**Blocking — repeated qualified references to the same table still resolve
independently.**
Only the unqualified path is memoized through `latestSnapshots`; this arm
calls `loadSnapshot` every time. And the branch/tag arm of
`IcebergUtils.getSnapshotCacheValue` does not go through the snapshot cache at
all — it does `retainTableGeneration(getIcebergTable(dorisTable))` fresh on
each call.
So `SELECT ... FROM t@branch(b) a JOIN t@branch(b) c` resolves twice, and if
the branch head advances in between, the two relations end up on different
generations — exactly the bug class this PR is fixing. Before the patch the
"first writer wins" behavior happened to avoid it, so this is a narrow
regression.
Suggestion: widen the cache key to `(MvccTableInfo, tableSnapshot,
scanParams)`. `latestSnapshots` then folds back into `snapshots`, and it also
saves a duplicate remote metadata resolution per repeated relation.
One more thing worth a comment on the field: `snapshots.put(...)` is now
unconditional, so the statement-scoped compatibility map went from
first-writer-wins to last-writer-wins. Roughly thirty call sites still read it
through `MvccUtil.getSnapshotFromContext` (MTMV partition logic,
`HiveTableSink`, `MetadataGenerator`, the no-arg `getFullSchema()` overloads).
For a multi-version statement they now observe whichever relation was bound
last, which is an arbitrary order. Worth documenting on the field even if the
remaining consumers migrate later.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java:
##########
@@ -113,14 +156,41 @@ protected LogicalFileScan(RelationId id, ExternalTable
table, List<String> quali
}
private static SelectedPartitions initialSelectedPartitions(
- ExternalTable table, Optional<TableScanParams> scanParams) {
+ ExternalTable table, Optional<TableScanParams> scanParams,
+ Optional<MvccSnapshot> relationSnapshot) {
if ((table instanceof PaimonExternalTable || table instanceof
PaimonSysExternalTable)
&& scanParams.isPresent() && scanParams.get().isOptions()) {
// A relation-scoped historical snapshot cannot reuse partitions
cached for the
// statement-level latest snapshot; Paimon will prune its selected
snapshot instead.
return SelectedPartitions.NOT_PRUNED;
}
- return
table.initSelectedPartitions(MvccUtil.getSnapshotFromContext(table));
+ return table.initSelectedPartitions(relationSnapshot);
+ }
+
+ private static Optional<List<Column>> captureRelationSchema(
+ ExternalTable table, Optional<TableScanParams> scanParams,
+ Optional<MvccSnapshot> relationSnapshot) {
+ if (scanParams.isPresent() && scanParams.get().isOptions()) {
+ if (table instanceof PaimonExternalTable) {
+ return Optional.of(ImmutableList.copyOf(
+ ((PaimonExternalTable)
table).getFullSchema(scanParams.get())));
+ }
+ if (table instanceof PaimonSysExternalTable) {
+ return Optional.of(ImmutableList.copyOf(
+ ((PaimonSysExternalTable)
table).getFullSchema(scanParams.get())));
+ }
+ }
+ return captureRelationSchema(table, relationSnapshot);
+ }
+
+ protected static Optional<List<Column>> captureRelationSchema(
+ ExternalTable table, Optional<MvccSnapshot> relationSnapshot) {
+ if (!(table instanceof MvccTable)) {
Review Comment:
**Blocking — plain Hive relations pin the logical schema, but the physical
side cannot reproduce it.**
`HMSExternalTable` implements `MvccTable`, so this guard passes even for
`DLAType.HIVE` and the columns get frozen into an `ImmutableList` that
`computeOutput()` turns into slots. But `HMSExternalTable.loadSnapshot` returns
`new EmptyMvccSnapshot()` for plain Hive, and that snapshot carries no state,
so at execution time:
`FileQueryScanNode.initSchemaParams` →
`getBaseSchema(Optional.of(EmptyMvccSnapshot), false)` →
`HMSExternalTable.getFullSchema(snapshot)` → not HUDI/ICEBERG →
`super.getFullSchema(snapshot)` → **ignores the argument** and re-reads the
live schema cache.
The logical output is therefore pinned at T0 while `numOfColumnsFromFile`
and the ORC position mapping are computed at T1. If the schema cache is
refreshed inside the planning window (REFRESH TABLE, HMS event, TTL expiry) the
two disagree. This asymmetry is introduced by the patch — before it, both sides
read the same live cache.
Suggestion: either carry `relationSchema` through to the physical scan the
way `relationSnapshot` is carried, or skip capturing when the snapshot cannot
replay a schema.
##########
fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshotCacheValue.java:
##########
@@ -54,4 +79,142 @@ public IcebergSnapshot getSnapshot() {
public Optional<Map<Integer, List<String>>> getNameMapping() {
return nameMapping;
}
+
+ public Optional<Table> getIcebergTable() {
+ return icebergTable;
+ }
+
+ static Table retainTableGeneration(Table table) {
+ if (!(table instanceof HasTableOperations) ||
isFrozenGeneration(table)) {
+ return table;
+ }
+ TableOperations operations = ((HasTableOperations) table).operations();
+ // Capture current() exactly once so every projection derived from the
returned table sees
+ // one metadata generation even when the shared catalog handle
refreshes concurrently.
+ TableOperations frozenOperations = new
FrozenTableOperations(operations, operations.current());
+ return tableWithOperations(table, frozenOperations);
+ }
+
+ static boolean isFrozenGeneration(Table table) {
+ return table instanceof HasTableOperations
+ && ((HasTableOperations) table).operations() instanceof
FrozenTableOperations;
+ }
+
+ static Table createWritableTable(Table retainedTable, Table liveTable) {
+ if (!isFrozenGeneration(retainedTable)) {
+ return retainedTable;
+ }
+ if (!(liveTable instanceof HasTableOperations)
+ || isFrozenGeneration(liveTable)) {
+ throw new IllegalArgumentException(
+ "Iceberg commit table must provide writable table
operations");
+ }
+ TableMetadata retainedMetadata = ((HasTableOperations)
retainedTable).operations().current();
+ TableOperations liveOperations = ((HasTableOperations)
liveTable).operations();
+ return tableWithOperations(retainedTable,
+ new WritableTableOperations(liveOperations, retainedMetadata));
+ }
+
+ private static Table tableWithOperations(Table table, TableOperations
operations) {
+ if (table instanceof BaseTable) {
+ return new BaseTable(operations, table.name(), ((BaseTable)
table).reporter());
+ }
+ return new BaseTable(operations, table.name());
+ }
+
+ private abstract static class RetainedTableOperations implements
TableOperations {
+ protected final TableOperations delegate;
+ private final TableMetadata metadata;
+
+ private RetainedTableOperations(TableOperations delegate,
TableMetadata metadata) {
+ this.delegate = delegate;
+ this.metadata = metadata;
+ }
+
+ @Override
+ public TableMetadata current() {
+ return metadata;
+ }
+
+ @Override
+ public TableMetadata refresh() {
+ return metadata;
+ }
+
+ @Override
+ public FileIO io() {
+ return delegate.io();
+ }
+
+ @Override
+ public EncryptionManager encryption() {
+ return delegate.encryption();
+ }
+
+ @Override
+ public String metadataFileLocation(String fileName) {
+ return delegate.metadataFileLocation(fileName);
+ }
+
+ @Override
+ public LocationProvider locationProvider() {
+ return delegate.locationProvider();
+ }
+ }
+
+ private static class FrozenTableOperations extends RetainedTableOperations
{
+ private FrozenTableOperations(TableOperations delegate, TableMetadata
metadata) {
+ super(delegate, metadata);
+ }
+
+ @Override
+ public void commit(TableMetadata base, TableMetadata newMetadata) {
+ throw new UnsupportedOperationException("Frozen Iceberg table
generation is read-only");
+ }
+ }
+
+ private static class WritableTableOperations extends
RetainedTableOperations {
+ private final TableMetadata retainedMetadata;
+ private TableMetadata currentMetadata;
+
+ private WritableTableOperations(TableOperations delegate,
TableMetadata retainedMetadata) {
+ super(delegate, retainedMetadata);
+ this.retainedMetadata = retainedMetadata;
+ this.currentMetadata = retainedMetadata;
+ }
+
+ @Override
+ public TableMetadata current() {
+ return currentMetadata;
+ }
+
+ @Override
+ public TableMetadata refresh() {
+ TableMetadata refreshedMetadata = delegate.refresh();
+ // Data-only snapshot advances are safe to replay, but a changed
writer contract must
+ // fail instead of silently committing files produced for another
metadata generation.
+ if (!isWriterCompatible(refreshedMetadata)) {
+ throw new CommitFailedException(
Review Comment:
`CommitFailedException` is the one exception Iceberg's commit loop retries:
`SnapshotProducer.commit()` is built with
`Tasks...onlyRetryOn(CommitFailedException.class)`, and `apply()` calls
`refresh()` on every attempt.
So a condition you explicitly decided must *not* be retried gets retried
`commit.retry.num-retries` times (default 4) before it surfaces to the user.
`ValidationException` aborts immediately and reads better in the log.
##########
fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java:
##########
@@ -697,6 +724,33 @@ public TableScan createTableScan() throws UserException {
return icebergTableScan;
}
+ private Table useFrozenTableGeneration(Table currentTable) {
+ Optional<MvccSnapshot> snapshot = getPinnedRelationSnapshot();
+ if
(snapshot.filter(IcebergMvccSnapshot.class::isInstance).isPresent()) {
+ IcebergSnapshotCacheValue cacheValue =
+ ((IcebergMvccSnapshot)
snapshot.get()).getSnapshotCacheValue();
+ if (cacheValue.getIcebergTable().isPresent()) {
+ Table frozenBaseTable = cacheValue.getIcebergTable().get();
+ if (isSystemTable && source.getTargetTable() instanceof
IcebergSysExternalTable) {
+ IcebergSysExternalTable systemTable =
(IcebergSysExternalTable) source.getTargetTable();
+ if (systemTable.supportsSnapshotSelection()) {
+ MetadataTableType tableType =
MetadataTableType.from(systemTable.getSysTableType());
+ Preconditions.checkArgument(tableType != null,
+ "Unknown Iceberg system table type: %s",
systemTable.getSysTableType());
+ // Snapshot-selectable metadata tables must derive
their scans and schemas
+ // from the same frozen base generation as the
relation's snapshot fence.
+ return
MetadataTableUtils.createMetadataTableInstance(frozenBaseTable, tableType);
Review Comment:
The scan is built from the frozen base here, but the tuple slots are not.
`IcebergSysExternalTable` is constructed fresh per binding
(`SysTableResolver.resolveForPlan` → `createSysExternalTable`) and derives its
schema from `sourceTable.getIcebergTable()` at that moment — i.e. the live
generation.
With the shared metadata cache those are usually the same `BaseTable`, so
this is mostly benign. Under a session catalog it is not:
`loadIcebergTableWithSession` performs an independent `ops.loadTable(...)` on
every call, so the schema and the scan come from two separate remote loads. If
a partition spec evolved in between, the `partition` struct of `FILES` /
`ENTRIES` / `PARTITIONS` differs and the slots no longer line up with
`scan.project(...)`.
Deriving the system-table schema from the frozen base as well would close
this.
##########
fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshotCacheValue.java:
##########
@@ -54,4 +79,142 @@ public IcebergSnapshot getSnapshot() {
public Optional<Map<Integer, List<String>>> getNameMapping() {
return nameMapping;
}
+
+ public Optional<Table> getIcebergTable() {
+ return icebergTable;
+ }
+
+ static Table retainTableGeneration(Table table) {
+ if (!(table instanceof HasTableOperations) ||
isFrozenGeneration(table)) {
+ return table;
+ }
+ TableOperations operations = ((HasTableOperations) table).operations();
+ // Capture current() exactly once so every projection derived from the
returned table sees
+ // one metadata generation even when the shared catalog handle
refreshes concurrently.
+ TableOperations frozenOperations = new
FrozenTableOperations(operations, operations.current());
+ return tableWithOperations(table, frozenOperations);
+ }
+
+ static boolean isFrozenGeneration(Table table) {
+ return table instanceof HasTableOperations
+ && ((HasTableOperations) table).operations() instanceof
FrozenTableOperations;
+ }
+
+ static Table createWritableTable(Table retainedTable, Table liveTable) {
+ if (!isFrozenGeneration(retainedTable)) {
+ return retainedTable;
+ }
+ if (!(liveTable instanceof HasTableOperations)
+ || isFrozenGeneration(liveTable)) {
+ throw new IllegalArgumentException(
+ "Iceberg commit table must provide writable table
operations");
+ }
+ TableMetadata retainedMetadata = ((HasTableOperations)
retainedTable).operations().current();
+ TableOperations liveOperations = ((HasTableOperations)
liveTable).operations();
+ return tableWithOperations(retainedTable,
+ new WritableTableOperations(liveOperations, retainedMetadata));
+ }
+
+ private static Table tableWithOperations(Table table, TableOperations
operations) {
+ if (table instanceof BaseTable) {
+ return new BaseTable(operations, table.name(), ((BaseTable)
table).reporter());
+ }
+ return new BaseTable(operations, table.name());
+ }
+
+ private abstract static class RetainedTableOperations implements
TableOperations {
+ protected final TableOperations delegate;
+ private final TableMetadata metadata;
+
+ private RetainedTableOperations(TableOperations delegate,
TableMetadata metadata) {
+ this.delegate = delegate;
+ this.metadata = metadata;
+ }
+
+ @Override
+ public TableMetadata current() {
+ return metadata;
+ }
+
+ @Override
+ public TableMetadata refresh() {
+ return metadata;
+ }
+
+ @Override
+ public FileIO io() {
+ return delegate.io();
+ }
+
+ @Override
+ public EncryptionManager encryption() {
+ return delegate.encryption();
+ }
+
+ @Override
+ public String metadataFileLocation(String fileName) {
+ return delegate.metadataFileLocation(fileName);
+ }
+
+ @Override
+ public LocationProvider locationProvider() {
Review Comment:
The wrapper is not a complete freeze: `io()`, `locationProvider()` and
`metadataFileLocation()` all delegate straight through, so they follow the
newest metadata rather than the retained one. A table-property change to
`write.data.path` / `write.metadata.path` would be observed by what is supposed
to be a frozen read view.
It also does not override `temp(TableMetadata)`. The interface default is
`return this`, while `HadoopTableOperations`, `RESTTableOperations` and
`BaseMetastoreTableOperations` all override it, and
`BaseTransaction$TransactionTableOperations` calls it — so wrapping drops the
implementation's behavior. The `isWriterCompatible()` check on `location` +
`properties` covers most of the practical impact today, but the wrapper is
worth completing.
(For the record, `requireStrictCleanup()` is fine to leave alone — nothing
in iceberg-core 1.10.1 overrides it.)
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundIcebergTableSink.java:
##########
@@ -130,17 +151,28 @@ public <R, C> R accept(PlanVisitor<R, C> visitor, C
context) {
public Plan withGroupExpression(Optional<GroupExpression> groupExpression)
{
return new UnboundIcebergTableSink<>(nameParts, colNames, hints,
partitions,
dmlCommandType, groupExpression,
Optional.of(getLogicalProperties()), child(),
- staticPartitionKeyValues, rewrite);
+ staticPartitionKeyValues, rewrite, branchName);
}
@Override
public Plan withGroupExprLogicalPropChildren(Optional<GroupExpression>
groupExpression,
Optional<LogicalProperties> logicalProperties, List<Plan>
children) {
return new UnboundIcebergTableSink<>(nameParts, colNames, hints,
partitions,
- dmlCommandType, groupExpression, logicalProperties,
children.get(0), staticPartitionKeyValues, rewrite);
+ dmlCommandType, groupExpression, logicalProperties,
children.get(0),
+ staticPartitionKeyValues, rewrite, branchName);
}
public boolean isRewrite() {
return rewrite;
}
+
+ public Optional<String> getBranchName() {
Review Comment:
This looks like dead state. `branchName` is stored, threaded through
`withChildren` / `withGroupExpression` / `withGroupExprLogicalPropChildren`,
given a `withBranchName()` and asserted in a unit test — but nothing in
production code calls `getBranchName()`. The branch already reaches execution
through the `branchName` argument of `InsertIntoTableCommand` /
`InsertOverwriteTableCommand`, set a few lines below the new `withBranchName()`
call in `LogicalPlanBuilder#visitInsertTable`.
Unless something downstream is planned for it, suggest dropping the field
and the constructor overload.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]