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 0eb5131db62 [fix](iceberg) Honor standard row-level isolation
properties (#69702)
0eb5131db62 is described below
commit 0eb5131db62cd7939788508377fc148df6ac1ef1
Author: daidai <[email protected]>
AuthorDate: Wed Nov 4 15:20:10 2026 +0800
[fix](iceberg) Honor standard row-level isolation properties (#69702)
### What problem does this PR solve?
Issue Number: N/A
Related PR: #69551
Problem Summary:
Doris Iceberg DELETE, UPDATE, and MERGE all used the non-standard
`delete_isolation_level` property. As a result, the standard
per-operation table properties `write.delete.isolation-level`,
`write.update.isolation-level`, and `write.merge.isolation-level` were
ignored. A table configured for `snapshot` isolation therefore still
performed serializable data-file conflict validation and rejected normal
concurrent appends.
This change selects the Iceberg `TableProperties` key based on
`WriteOperation` and parses it with `IsolationLevel.fromName()`. The
existing delete-file, base-snapshot, conflict-filter, and
referenced-data-file validations remain enabled; only
`validateNoConflictingDataFiles` is gated on serializable isolation.
Invalid isolation-level values now fail explicitly instead of being
treated as snapshot isolation.
The three disabled regressions from #69551 are enabled. Unit coverage
also verifies per-operation key selection, serializable defaults, and
invalid values.
### Release note
Iceberg DELETE, UPDATE, and MERGE now honor their standard per-operation
isolation-level table properties.
### Check List (For Author)
- Test
- [ ] Regression test
- [x] Unit Test: `IcebergConnectorTransactionTest` and
`IcebergWriteKnownBugsTest` pass, including all three concurrent-append
snapshot-isolation regressions.
- [ ] 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. Snapshot isolation now permits concurrent data-file appends
while retaining the other row-delta validations.
- 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
---
.../iceberg/IcebergConnectorTransaction.java | 50 +++--
.../iceberg/IcebergConnectorTransactionTest.java | 202 +++++++++++++++++++--
.../iceberg/IcebergWriteKnownBugsTest.java | 3 -
.../iceberg/IcebergWritePlanProviderTest.java | 32 ++++
.../glue/translator/PhysicalPlanTranslator.java | 18 +-
.../PhysicalPlanTranslatorAdmissionGateTest.java | 4 +-
...ysicalPlanTranslatorIcebergRowLevelDmlTest.java | 9 +-
7 files changed, 280 insertions(+), 38 deletions(-)
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
index b6ca61f312f..9db7005897d 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
@@ -38,6 +38,7 @@ import org.apache.iceberg.FileContent;
import org.apache.iceberg.FileFormat;
import org.apache.iceberg.FileScanTask;
import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.IsolationLevel;
import org.apache.iceberg.ManifestFile;
import org.apache.iceberg.ManifestFiles;
import org.apache.iceberg.ManifestReader;
@@ -129,8 +130,7 @@ import java.util.Set;
public class IcebergConnectorTransaction implements ConnectorTransaction,
RewriteCapableTransaction {
private static final Logger LOG =
LogManager.getLogger(IcebergConnectorTransaction.class);
- private static final String DELETE_ISOLATION_LEVEL =
"delete_isolation_level";
- private static final String DELETE_ISOLATION_LEVEL_DEFAULT =
"serializable";
+ private static final String LEGACY_DELETE_ISOLATION_LEVEL =
"delete_isolation_level";
// Scope of a bare INSERT OVERWRITE (no PARTITION clause). Key + values
are byte-identical to the fe-core
// SessionVariable constants (the connector cannot import fe-core); the
value reaches the commit through
// ConnectorSession.getProperty, which reads the session-variable registry
by name.
@@ -170,6 +170,7 @@ public class IcebergConnectorTransaction implements
ConnectorTransaction, Rewrit
// Op context captured at begin time, consumed by commit() (the volatile
transaction write at the end of
// beginWrite publishes these plain writes to the commit thread).
private WriteOperation writeOperation = WriteOperation.INSERT;
+ private IsolationLevel rowLevelIsolationLevel;
private boolean staticPartitionOverwrite;
// True when iceberg_partition_overwrite_mode = 'static': a bare INSERT
OVERWRITE clears the whole table
// (every partition spec) instead of replacing only the partitions the new
data lands in. Captured at begin
@@ -292,15 +293,23 @@ public class IcebergConnectorTransaction implements
ConnectorTransaction, Rewrit
if (writeSchemaContext != null) {
writeSchemaContext.validateCurrentSchema(loaded,
ctx.isOverwrite());
}
+ // Reject invalid isolation before a sink can dispatch BE
file writes. Read the SDK
+ // transaction's refreshed metadata and keep that
selection for this statement's commit.
+ IsolationLevel isolationLevel = null;
+ if (writeOperation == WriteOperation.DELETE ||
writeOperation == WriteOperation.UPDATE
+ || writeOperation == WriteOperation.MERGE) {
+ isolationLevel = resolveIsolationLevel(opened.table(),
writeOperation);
+ }
synchronized (beginLock) {
if (closed) {
this.table = null;
throw new IllegalStateException(
"Iceberg transaction closed while its
write table was loading");
}
- this.transaction = opened;
+ this.rowLevelIsolationLevel = isolationLevel;
this.tableLease = retained;
retained = null;
+ this.transaction = opened;
writeStarted = true;
}
return null;
@@ -1235,7 +1244,7 @@ public class IcebergConnectorTransaction implements
ConnectorTransaction, Rewrit
List<TIcebergCommitData> commitData, List<String>
referencedDataFiles) {
applyBaseSnapshotValidation(rowDelta);
applyConflictDetectionFilter(rowDelta, icebergTable, commitData);
- if (isSerializableIsolationLevel(icebergTable)) {
+ if (rowLevelIsolationLevel == IsolationLevel.SERIALIZABLE) {
rowDelta.validateNoConflictingDataFiles();
}
rowDelta.validateDeletedFiles();
@@ -1390,13 +1399,32 @@ public class IcebergConnectorTransaction implements
ConnectorTransaction, Rewrit
return Collections.emptyList();
}
- boolean isSerializableIsolationLevel(Table icebergTable) {
- if (icebergTable == null) {
- return true;
- }
- String level = icebergTable.properties()
- .getOrDefault(DELETE_ISOLATION_LEVEL,
DELETE_ISOLATION_LEVEL_DEFAULT);
- return "serializable".equalsIgnoreCase(level);
+ IsolationLevel resolveIsolationLevel(Table icebergTable, WriteOperation
operation) {
+ String property;
+ String defaultLevel;
+ switch (operation) {
+ case DELETE:
+ property = TableProperties.DELETE_ISOLATION_LEVEL;
+ defaultLevel = TableProperties.DELETE_ISOLATION_LEVEL_DEFAULT;
+ break;
+ case UPDATE:
+ property = TableProperties.UPDATE_ISOLATION_LEVEL;
+ defaultLevel = TableProperties.UPDATE_ISOLATION_LEVEL_DEFAULT;
+ break;
+ case MERGE:
+ property = TableProperties.MERGE_ISOLATION_LEVEL;
+ defaultLevel = TableProperties.MERGE_ISOLATION_LEVEL_DEFAULT;
+ break;
+ default:
+ throw new IllegalArgumentException(
+ "Unsupported iceberg row-level write operation: " +
operation);
+ }
+ // Standard per-operation settings take precedence; existing
legacy-only tables retain their
+ // configured isolation until they migrate. Parse only the selected
value, including invalid ones.
+ Map<String, String> properties = icebergTable.properties();
+ String level = properties.getOrDefault(property,
+ properties.getOrDefault(LEGACY_DELETE_ISOLATION_LEVEL,
defaultLevel));
+ return IsolationLevel.fromName(level);
}
// ─────────────────── V3 deletion-vector "rewrite previous delete files"
(legacy :786-851) ───────────────────
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
index 771588ecca5..dbf2f0df886 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
@@ -43,12 +43,14 @@ import org.apache.iceberg.FileFormat;
import org.apache.iceberg.FileMetadata;
import org.apache.iceberg.FileScanTask;
import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.IsolationLevel;
import org.apache.iceberg.PartitionData;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Snapshot;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableOperations;
+import org.apache.iceberg.TableProperties;
import org.apache.iceberg.Transaction;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
@@ -2032,31 +2034,209 @@ public class IcebergConnectorTransactionTest {
}
@Test
- public void isSerializableIsolationLevelDefaultsAndReadsProperty() {
+ public void
resolveIsolationLevelUsesOperationDefaultsAndStandardKeyPrecedence() {
InMemoryCatalog catalog = freshCatalog();
IcebergConnectorTransaction txn = txnFor(opsReturning(null), new
RecordingConnectorContext());
+ Table defaults = catalog.createTable(TableIdentifier.of("db1",
"defaults"),
+ SCHEMA, PartitionSpec.unpartitioned());
+ Table legacy = catalog.createTable(TableIdentifier.of("db1", "legacy"),
+ SCHEMA, PartitionSpec.unpartitioned(),
props("delete_isolation_level", "snapshot"));
+ Table standard = catalog.createTable(TableIdentifier.of("db1",
"standard"),
+ SCHEMA, PartitionSpec.unpartitioned(), props(
+ "delete_isolation_level", "invalid",
+ TableProperties.DELETE_ISOLATION_LEVEL, "snapshot",
+ TableProperties.UPDATE_ISOLATION_LEVEL, "serializable",
+ TableProperties.MERGE_ISOLATION_LEVEL, "SNAPSHOT"));
+ Table override = catalog.createTable(TableIdentifier.of("db1",
"override"),
+ SCHEMA, PartitionSpec.unpartitioned(), props(
+ "delete_isolation_level", "snapshot",
+ TableProperties.DELETE_ISOLATION_LEVEL, "serializable",
+ TableProperties.UPDATE_ISOLATION_LEVEL, "serializable",
+ TableProperties.MERGE_ISOLATION_LEVEL,
"serializable"));
+ for (WriteOperation operation : Arrays.asList(
+ WriteOperation.DELETE, WriteOperation.UPDATE,
WriteOperation.MERGE)) {
+ Assertions.assertEquals(IsolationLevel.SERIALIZABLE,
txn.resolveIsolationLevel(defaults, operation));
+ Assertions.assertEquals(IsolationLevel.SNAPSHOT,
txn.resolveIsolationLevel(legacy, operation));
+ Assertions.assertEquals(IsolationLevel.SERIALIZABLE,
txn.resolveIsolationLevel(override, operation));
+ }
+ Assertions.assertEquals(IsolationLevel.SNAPSHOT,
txn.resolveIsolationLevel(standard, WriteOperation.DELETE));
+ Assertions.assertEquals(IsolationLevel.SERIALIZABLE,
txn.resolveIsolationLevel(standard, WriteOperation.UPDATE));
+ Assertions.assertEquals(IsolationLevel.SNAPSHOT,
txn.resolveIsolationLevel(standard, WriteOperation.MERGE));
+ Table invalid = catalog.createTable(TableIdentifier.of("db1",
"invalid"),
+ SCHEMA, PartitionSpec.unpartitioned(),
props("delete_isolation_level", "snapshot",
+ TableProperties.UPDATE_ISOLATION_LEVEL, "invalid"));
+ Assertions.assertEquals(IsolationLevel.SNAPSHOT,
txn.resolveIsolationLevel(invalid, WriteOperation.DELETE));
+ Assertions.assertEquals(IsolationLevel.SNAPSHOT,
txn.resolveIsolationLevel(invalid, WriteOperation.MERGE));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> txn.resolveIsolationLevel(invalid,
WriteOperation.UPDATE));
+ }
+
+ @Test
+ public void
legacySnapshotIsolationCommitsConcurrentAppendsForEveryRowOperation() throws
Exception {
+ for (WriteOperation operation : Arrays.asList(
+ WriteOperation.DELETE, WriteOperation.UPDATE,
WriteOperation.MERGE)) {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "legacy");
+ Table table = catalog.createTable(id, SCHEMA,
PartitionSpec.unpartitioned(),
+ props("format-version", "2", "delete_isolation_level",
"snapshot"));
+ String seed = "s3://b/db1/legacy/seed.parquet";
+ String appended = "s3://b/db1/legacy/appended.parquet";
+ String replacement = "s3://b/db1/legacy/replacement.parquet";
+ table.newAppend().appendFile(dataFile(table.spec(), seed,
5L)).commit();
+ IcebergConnectorTransaction txn = txnFor(opsReturning(table), new
RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "legacy", new IcebergWriteContext(
+ operation, false, Collections.emptyMap(),
Optional.empty()));
+
catalog.loadTable(id).newAppend().appendFile(dataFile(table.spec(), appended,
7L)).commit();
+ long appendSnapshot =
catalog.loadTable(id).currentSnapshot().snapshotId();
+
txn.addCommitData(commitBytes(positionDeleteItem("s3://b/db1/legacy/delete.parquet",
2L, seed)));
+ if (operation != WriteOperation.DELETE) {
+ txn.addCommitData(commitBytes(dataFileItem(replacement, 2L,
1024L)));
+ }
+
+ txn.commit();
+
+ Table committed = catalog.loadTable(id);
+ Assertions.assertNotEquals(appendSnapshot,
committed.currentSnapshot().snapshotId());
+ Assertions.assertEquals("1",
committed.currentSnapshot().summary().get("added-delete-files"));
+ Set<String> paths = new HashSet<>();
+ for (DataFile file : currentDataFiles(committed)) {
+ paths.add(file.path().toString());
+ }
+ Set<String> expected = new HashSet<>(Arrays.asList(seed,
appended));
+ if (operation != WriteOperation.DELETE) {
+ expected.add(replacement);
+ Assertions.assertEquals("1",
committed.currentSnapshot().summary().get("added-data-files"));
+ }
+ Assertions.assertEquals(expected, paths, "the row delta must
publish without losing the append");
+ txn.close();
+ }
+ }
- Table dflt = catalog.createTable(TableIdentifier.of("db1", "d"),
SCHEMA, PartitionSpec.unpartitioned());
- Assertions.assertTrue(txn.isSerializableIsolationLevel(dflt), "missing
property defaults to serializable");
+ @Test
+ public void
invalidIsolationFailsBeginAndReleasesRetainedCatalogResources() {
+ for (String property :
Arrays.asList(TableProperties.DELETE_ISOLATION_LEVEL,
+ TableProperties.UPDATE_ISOLATION_LEVEL,
TableProperties.MERGE_ISOLATION_LEVEL,
+ "delete_isolation_level")) {
+ InMemoryCatalog catalog = freshCatalog();
+ Table table = catalog.createTable(TableIdentifier.of("db1",
"invalid"),
+ SCHEMA, PartitionSpec.unpartitioned(),
props("format-version", "2", property, "invalid"));
+ WriteOperation operation =
TableProperties.UPDATE_ISOLATION_LEVEL.equals(property)
+ ? WriteOperation.UPDATE :
TableProperties.MERGE_ISOLATION_LEVEL.equals(property)
+ ? WriteOperation.MERGE : WriteOperation.DELETE;
+ IcebergCatalogResourceTracker tracker = new
IcebergCatalogResourceTracker();
+ IcebergConnectorTransaction txn = new IcebergConnectorTransaction(
+ 42L, opsReturning(table), new RecordingConnectorContext(),
tracker);
- Table ser = catalog.createTable(TableIdentifier.of("db1", "s"),
SCHEMA, PartitionSpec.unpartitioned(),
- props("delete_isolation_level", "serializable"));
- Assertions.assertTrue(txn.isSerializableIsolationLevel(ser));
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> txn.beginWrite(SESSION, "db1", "invalid", new
IcebergWriteContext(
+ operation, false, Collections.emptyMap(),
Optional.empty())));
+
+ Assertions.assertTrue(ex.getMessage().contains("Invalid isolation
level: invalid"));
+ Assertions.assertNull(txn.getTransaction(), "failed begin must not
publish the SDK transaction");
+ Assertions.assertNull(txn.getTable());
+ Assertions.assertTrue(txn.getCommitDataList().isEmpty());
+ AtomicInteger cleanup = new AtomicInteger();
+ tracker.close(cleanup::incrementAndGet);
+ Assertions.assertEquals(1, cleanup.get(), "failed begin must
release both local tracked-table leases");
+ txn.close();
+ Assertions.assertEquals(1, cleanup.get(), "close must not retain
or release the failed lease twice");
+ }
+ }
- Table snap = catalog.createTable(TableIdentifier.of("db1", "n"),
SCHEMA, PartitionSpec.unpartitioned(),
- props("delete_isolation_level", "snapshot"));
- Assertions.assertFalse(txn.isSerializableIsolationLevel(snap),
"snapshot isolation is not serializable");
+ @Test
+ public void beginWriteResolvesIsolationFromOpenedTransactionMetadata() {
+ InMemoryCatalog catalog = freshCatalog();
+ Table table = catalog.createTable(TableIdentifier.of("db1",
"refreshed"),
+ SCHEMA, PartitionSpec.unpartitioned(), props("format-version",
"2",
+ TableProperties.DELETE_ISOLATION_LEVEL, "snapshot"));
+ AtomicInteger opens = new AtomicInteger();
+ Table refreshing = (Table)
Proxy.newProxyInstance(Table.class.getClassLoader(), new Class<?>[]
{Table.class},
+ (proxy, method, args) -> {
+ if ("newTransaction".equals(method.getName())) {
+ if (opens.incrementAndGet() == 1) {
+
table.updateProperties().set(TableProperties.DELETE_ISOLATION_LEVEL,
"invalid").commit();
+ }
+ }
+ try {
+ return method.invoke(table, args);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ });
+ IcebergConnectorTransaction txn = txnFor(opsReturning(refreshing), new
RecordingConnectorContext());
+
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> txn.beginWrite(SESSION, "db1", "refreshed",
deleteCtx()));
+
+ Assertions.assertEquals(1, opens.get());
+ Assertions.assertTrue(ex.getMessage().contains("Invalid isolation
level: invalid"));
+ Assertions.assertNull(txn.getTransaction());
+ table.updateProperties().set(TableProperties.DELETE_ISOLATION_LEVEL,
"snapshot").commit();
+ // A failed begin must also release the begin-once guard, so the same
transaction can be prepared again.
+ txn.beginWrite(SESSION, "db1", "refreshed", deleteCtx());
+ Assertions.assertNotNull(txn.getTransaction());
+ txn.close();
+ }
+
+ @Test
+ public void rowLevelIsolationSelectionStaysFixedAfterBegin() throws
Exception {
+ for (String initial : Arrays.asList("snapshot", "serializable")) {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "fixed");
+ Table table = catalog.createTable(id, SCHEMA,
PartitionSpec.unpartitioned(), props(
+ "format-version", "2",
TableProperties.UPDATE_ISOLATION_LEVEL, initial));
+ String seed = "s3://b/db1/fixed/seed.parquet";
+ table.newAppend().appendFile(dataFile(table.spec(), seed,
5L)).commit();
+ IcebergConnectorTransaction txn = txnFor(opsReturning(table), new
RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "fixed", new IcebergWriteContext(
+ WriteOperation.UPDATE, false, Collections.emptyMap(),
Optional.empty()));
+ // Change even the SDK transaction table's properties: commit must
use the parsed begin-time enum.
+
txn.getTransaction().table().updateProperties().set(TableProperties.UPDATE_ISOLATION_LEVEL,
+ "snapshot".equals(initial) ? "invalid" :
"snapshot").commit();
+ catalog.loadTable(id).newAppend()
+ .appendFile(dataFile(table.spec(),
"s3://b/db1/fixed/appended.parquet", 7L)).commit();
+
txn.addCommitData(commitBytes(positionDeleteItem("s3://b/db1/fixed/delete.parquet",
2L, seed)));
+
txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/fixed/replacement.parquet",
2L, 1024L)));
+
+ if ("snapshot".equals(initial)) {
+ txn.commit();
+ Assertions.assertEquals("1", reloadCurrentSnapshot(catalog,
id).summary().get("added-delete-files"));
+ } else {
+ Assertions.assertThrows(DorisConnectorException.class,
txn::commit,
+ "begin-time serializable isolation must still reject
the concurrent append");
+ }
+ txn.close();
+ }
+ }
+
+ @Test
+ public void nonRowLevelBeginDoesNotParseRowLevelIsolation() {
+ InMemoryCatalog catalog = freshCatalog();
+ Table table = catalog.createTable(TableIdentifier.of("db1", "other"),
+ SCHEMA, PartitionSpec.unpartitioned(), props("format-version",
"2",
+ "delete_isolation_level", "invalid",
+ TableProperties.DELETE_ISOLATION_LEVEL, "invalid",
+ TableProperties.UPDATE_ISOLATION_LEVEL, "invalid",
+ TableProperties.MERGE_ISOLATION_LEVEL, "invalid"));
+ for (WriteOperation operation : Arrays.asList(
+ WriteOperation.INSERT, WriteOperation.OVERWRITE,
WriteOperation.REWRITE)) {
+ IcebergConnectorTransaction txn = txnFor(opsReturning(table), new
RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "other", new IcebergWriteContext(
+ operation, operation == WriteOperation.OVERWRITE,
Collections.emptyMap(), Optional.empty()));
+ Assertions.assertNotNull(txn.getTransaction());
+ txn.close();
+ }
}
@Test
public void deleteSnapshotIsolationSkipsConcurrentDataFileValidation() {
- // parity: at delete_isolation_level=snapshot (non-serializable)
validateNoConflictingDataFiles is NOT
+ // parity: at write.delete.isolation-level=snapshot (non-serializable)
validateNoConflictingDataFiles is NOT
// applied, so a concurrent data-file append since the base snapshot
does NOT fail the delete — the inverse
// of deleteDetectsConcurrentDataFileConflict (which runs at the
default serializable level).
InMemoryCatalog catalog = freshCatalog();
TableIdentifier id = TableIdentifier.of("db1", "t1");
Table table = catalog.createTable(id, SCHEMA,
PartitionSpec.unpartitioned(),
- props("format-version", "2", "delete_isolation_level",
"snapshot"));
+ props("format-version", "2",
TableProperties.DELETE_ISOLATION_LEVEL, "snapshot"));
table.newAppend().appendFile(dataFile(table.spec(),
"s3://b/db1/t1/seed.parquet", 5L)).commit();
IcebergConnectorTransaction txn =
txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext());
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteKnownBugsTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteKnownBugsTest.java
index a1ae89bc1ef..131e09cd9f4 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteKnownBugsTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteKnownBugsTest.java
@@ -50,19 +50,16 @@ public class IcebergWriteKnownBugsTest {
private static final String COMMIT_LOCATION = "s3://bucket/events";
@Test
- @Disabled("Known issue: DELETE ignores the standard snapshot isolation
property.")
public void deleteHonorsStandardSnapshotIsolationProperty() throws
Exception {
assertStandardSnapshotIsolation(WriteOperation.DELETE,
"write.delete.isolation-level");
}
@Test
- @Disabled("Known issue: UPDATE ignores the standard snapshot isolation
property.")
public void updateHonorsStandardSnapshotIsolationProperty() throws
Exception {
assertStandardSnapshotIsolation(WriteOperation.UPDATE,
"write.update.isolation-level");
}
@Test
- @Disabled("Known issue: MERGE ignores the standard snapshot isolation
property.")
public void mergeHonorsStandardSnapshotIsolationProperty() throws
Exception {
assertStandardSnapshotIsolation(WriteOperation.MERGE,
"write.merge.isolation-level");
}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
index c34c2da06ff..9df779895b0 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
@@ -402,6 +402,38 @@ public class IcebergWritePlanProviderTest {
return plan.getDataSink().getIcebergTableSink();
}
+ @Test
+ public void
invalidSelectedIsolationRejectsPlanBeforeReturningBackendSink() {
+ Map<WriteOperation, String> isolationKeys = new LinkedHashMap<>();
+ isolationKeys.put(WriteOperation.DELETE,
TableProperties.DELETE_ISOLATION_LEVEL);
+ isolationKeys.put(WriteOperation.UPDATE,
TableProperties.UPDATE_ISOLATION_LEVEL);
+ isolationKeys.put(WriteOperation.MERGE,
TableProperties.MERGE_ISOLATION_LEVEL);
+ for (Map.Entry<WriteOperation, String> entry :
isolationKeys.entrySet()) {
+ Table table = unpartitionedUnsortedTable(freshCatalog());
+ table.updateProperties().set("delete_isolation_level", "snapshot")
+ .set(entry.getValue(), "invalid").commit();
+ RecordingConnectorContext context = contextWithStorage();
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = table;
+ IcebergConnectorTransaction txn = new
IcebergConnectorTransaction(42L, ops, context);
+ WriteHandle handle = new WriteHandle(new IcebergTableHandle("db1",
"t2"))
+ .writeOperation(entry.getKey());
+
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> providerFor(table, context).planWrite(new
WriteSession(txn), handle));
+
+ Assertions.assertTrue(ex.getMessage().contains("Invalid isolation
level: invalid"),
+ "an invalid selected standard key must not fall back to
the valid legacy value");
+ Assertions.assertNull(txn.getTransaction(), "a failed preparation
cannot return an executable BE sink");
+ Assertions.assertNull(txn.getTable());
+ Assertions.assertTrue(txn.getCommitDataList().isEmpty());
+ Assertions.assertEquals(0, context.normalizeCount,
+ "invalid isolation must fail before output paths are bound
into a backend sink");
+ Assertions.assertNull(table.currentSnapshot(), "preparation must
not create write snapshots");
+ txn.close();
+ }
+ }
+
@Test
public void getWriteColumnsCarriesPinnedTypedDefaultsAndNullability() {
Schema writeSchema = new Schema(
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
index 12b8157dc8e..48da7623c5b 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
@@ -609,19 +609,19 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
outputExprs.add(slotRef);
}
rootFragment.setOutputExprs(outputExprs);
- // The MERGE/UPDATE target is a PluginDrivenExternalTable: route
through the connector's
- // PluginDrivenTableSink with WriteOperation.MERGE so the connector's
planWrite emits its
- // TIcebergMergeSink dialect (which threads its own sort_fields).
- // SQL MERGE INTO carries the cardinality requirement onto the write
handle; UPDATE shares this
- // sink dialect but has no such rule, so it threads false (see
PhysicalExternalRowLevelMergeSink).
- rootFragment.setSink(buildPluginRowLevelDmlSink(mergeSink,
WriteOperation.MERGE,
+ // UPDATE and SQL MERGE share the plugin BE sink dialect, but their
per-operation connector
+ // behavior differs. The existing SQL-MERGE cardinality requirement is
set only by the MERGE
+ // builder, so preserve the statement identity when selecting the
connector write operation.
+ WriteOperation rowLevelOperation =
mergeSink.isRequireMergeCardinalityCheck()
+ ? WriteOperation.MERGE : WriteOperation.UPDATE;
+ rootFragment.setSink(buildPluginRowLevelDmlSink(mergeSink,
rowLevelOperation,
mergeSink.isWritesDataFiles(),
mergeSink.isRequireMergeCardinalityCheck(),
mergeSink.getBoundWriteMetadataIdentity(), context));
return rootFragment;
}
/**
- * Builds the plugin-driven sink for an iceberg row-level DML (DELETE /
MERGE) whose target has
+ * Builds the plugin-driven sink for an iceberg row-level DML (DELETE /
UPDATE / MERGE) whose target has
* been flipped to a {@link PluginDrivenExternalTable}. Mirrors {@link
#visitPhysicalConnectorTableSink}'s
* connector resolution (catalog -> connector -> metadata ->
pinned table handle) but threads the
* given {@link WriteOperation} so the connector's {@code planWrite}
dispatches to its DELETE / MERGE BE sink
@@ -664,10 +664,10 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
Set<WriteOperation> writeOps = writePlanProvider == null
? EnumSet.noneOf(WriteOperation.class)
: writePlanProvider.supportedOperations();
- if (!(writeOps.contains(WriteOperation.DELETE) ||
writeOps.contains(WriteOperation.MERGE))) {
+ if (!writeOps.contains(writeOperation)) {
throw new AnalysisException(
"Connector '" + catalog.getName() + "' (type: " +
catalog.getType()
- + ") does not support row-level DML operations");
+ + ") does not support " + writeOperation + "
row-level DML");
}
// writeSortInfo == null: a row-level DML has no engine-resolved write
sort (MERGE's sort lives in the
// connector's TIcebergMergeSink.sort_fields, DELETE is unsorted).
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorAdmissionGateTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorAdmissionGateTest.java
index ff82ee634a9..c406886c406 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorAdmissionGateTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorAdmissionGateTest.java
@@ -125,7 +125,7 @@ public class PhysicalPlanTranslatorAdmissionGateTest {
@Test
public void
rowLevelDmlGateRejectsConnectorDeclaringOnlyInsertWithDistinctMessage() {
- // Declares INSERT (would pass the INSERT gate above) but neither
DELETE nor MERGE: the row-level DML
+ // Declares INSERT (would pass the INSERT gate above) but not DELETE:
the row-level DML
// helper must reject it, and with a message DISTINCT from the INSERT
gate's, so logs/callers can tell
// "this connector can't do row-level DML at all" apart from "this
connector can't write at all".
PlanTranslatorContext context = new PlanTranslatorContext();
@@ -141,7 +141,7 @@ public class PhysicalPlanTranslatorAdmissionGateTest {
PhysicalPlanTranslator translator = new
PhysicalPlanTranslator(context, null);
AnalysisException ex = Assertions.assertThrows(AnalysisException.class,
() -> translator.visitPhysicalExternalRowLevelDeleteSink(sink,
context));
- Assertions.assertTrue(ex.getMessage().contains("does not support
row-level DML operations"),
+ Assertions.assertTrue(ex.getMessage().contains("does not support
DELETE row-level DML"),
"got: " + ex.getMessage());
Assertions.assertFalse(ex.getMessage().contains("does not support
INSERT operations"),
"the row-level DML rejection must be a message DISTINCT from
the INSERT gate's, got: "
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorIcebergRowLevelDmlTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorIcebergRowLevelDmlTest.java
index a789c2bb359..447d41f3a25 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorIcebergRowLevelDmlTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorIcebergRowLevelDmlTest.java
@@ -142,6 +142,7 @@ public class PhysicalPlanTranslatorIcebergRowLevelDmlTest {
Mockito.doReturn(plugin.table).when(sink).getTargetTable();
Mockito.doReturn(ImmutableList.of(DATA)).when(sink).getCols();
Mockito.doReturn(ImmutableList.<Slot>of(dataSlot, opSlot,
rowidSlot)).when(sink).getOutput();
+ Mockito.doReturn(true).when(sink).isRequireMergeCardinalityCheck();
PhysicalPlanTranslator translator = new
PhysicalPlanTranslator(context, null);
translator.visitPhysicalExternalRowLevelMergeSink(sink, context);
@@ -179,6 +180,8 @@ public class PhysicalPlanTranslatorIcebergRowLevelDmlTest {
@Test
public void
mergePluginArmThreadsTheSqlMergeCardinalityRequirementOntoTheWriteHandle() {
PluginDrivenTableSink pluginSink =
translateMergeWithCardinalityRequirement(true);
+ Assertions.assertEquals(WriteOperation.MERGE,
Deencapsulation.getField(pluginSink, "writeOperation"),
+ "SQL MERGE must select write.merge.isolation-level through
WriteOperation.MERGE");
Assertions.assertEquals(true,
Deencapsulation.getField(pluginSink,
"requireMergeCardinalityCheck"),
"a SQL MERGE must thread requireMergeCardinalityCheck=true so
the connector stamps it onto"
@@ -188,6 +191,8 @@ public class PhysicalPlanTranslatorIcebergRowLevelDmlTest {
@Test
public void
updatePluginArmThreadsNoCardinalityRequirementOntoTheWriteHandle() {
PluginDrivenTableSink pluginSink =
translateMergeWithCardinalityRequirement(false);
+ Assertions.assertEquals(WriteOperation.UPDATE,
Deencapsulation.getField(pluginSink, "writeOperation"),
+ "UPDATE must select write.update.isolation-level through
WriteOperation.UPDATE");
Assertions.assertEquals(false,
Deencapsulation.getField(pluginSink,
"requireMergeCardinalityCheck"),
"an UPDATE reaches the same sink dialect but carries no SQL
cardinality rule");
@@ -392,9 +397,9 @@ public class PhysicalPlanTranslatorIcebergRowLevelDmlTest {
// Production selects the write provider per-handle; a plain mock does
not run the interface default.
Mockito.when(connector.getWritePlanProvider(Mockito.any())).thenReturn(provider);
// The row-level DML gate (buildPluginRowLevelDmlSink) resolves the
handle, fetches the per-handle
- // provider and admits on ITS supportedOperations containing
DELETE/MERGE.
+ // provider and admits on ITS supportedOperations containing the
concrete operation.
Mockito.when(provider.supportedOperations())
- .thenReturn(EnumSet.of(WriteOperation.DELETE,
WriteOperation.MERGE));
+ .thenReturn(EnumSet.of(WriteOperation.DELETE,
WriteOperation.UPDATE, WriteOperation.MERGE));
Mockito.when(provider.planWrite(Mockito.any(), Mockito.any()))
.thenReturn(new ConnectorSinkPlan(new TDataSink()));
Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata);
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]