This is an automated email from the ASF dual-hosted git repository.

yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new a61493093f0 branch-4.1: [fix](paimon) Keep write cache coherent and 
extend coverage (#67023)
a61493093f0 is described below

commit a61493093f09cecc5099c2b29756a825f45c0258
Author: Socrates <[email protected]>
AuthorDate: Mon Aug 24 09:38:35 2026 +0800

    branch-4.1: [fix](paimon) Keep write cache coherent and extend coverage 
(#67023)
    
    ### What
    
    - move Paimon write suites and golden outputs under
    `external_table_p0/paimon/write`
    - extend write coverage for sequence groups, MERGE semantics,
    sequence/rowkind, deletion vectors, row tracking/data evolution,
    external paths, partition deletes, bucket modes, and JDBC catalog
    concurrency
    - keep the Doris Paimon metacache as the owner of table instances used
    by writes
    - invalidate the same table in Paimon's `CachingCatalog` before
    reloading it after a Doris cache miss
    - add FE unit coverage for cache invalidation and table reload behavior
    - validate stable Doris query results through checked-in `.out` files
    - keep unsupported behavior disabled with targeted TODO markers
    
    ### Why
    
    Doris currently has its own Paimon table metacache on top of Paimon's
    `CachingCatalog`. Invalidating only the Doris cache could still make the
    loader receive a stale `Table` from the underlying Paimon cache. As a
    result, writes after external metadata changes could continue using
    outdated bucket, path, or table options.
    
    This change keeps the existing Doris cache boundary, documents why both
    cache layers exist, and makes a Doris cache reload invalidate the
    corresponding Paimon table entry first.
    
    ### Testing
    
    - `source ./custom_env.sh && ./build.sh --fe`
    - `source ./custom_env.sh && ./run-fe-ut.sh --run
    
org.apache.doris.datasource.paimon.PaimonExternalMetaCacheTest,org.apache.doris.datasource.paimon.PaimonExternalTableTest`
      - 31 tests, 0 failures, 0 errors
    - normal-mode regression execution for the added Paimon write suites and
    JDBC catalog concurrency coverage
    - focused `.out` verification after the cache fix:
      - `test_paimon_write_bucket_modes`
      - `test_paimon_write_sequence_group`
      - `test_paimon_write_deletion_vector`
      - `test_paimon_write_external_paths`
    - `git diff --check`
---
 .../metacache/paimon/PaimonTableLoader.java        |   7 +-
 .../datasource/paimon/PaimonExternalCatalog.java   |  73 +++++--
 .../datasource/paimon/PaimonExternalTable.java     |   7 +-
 .../paimon/PaimonExternalMetaCacheTest.java        |  57 ++++++
 .../datasource/paimon/PaimonExternalTableTest.java |  17 ++
 .../paimon/test_paimon_jdbc_catalog.out            |  16 ++
 .../test_paimon_create_ddl_write_properties.out    |   0
 .../write}/test_paimon_write_append_only.out       |   0
 .../write}/test_paimon_write_bucket_modes.out      |   7 +
 .../test_paimon_write_changelog_producer.out       |   0
 .../paimon/write}/test_paimon_write_compaction.out |   0
 .../write}/test_paimon_write_complex_types.out     |   0
 .../write/test_paimon_write_deletion_vector.out    |  38 ++++
 .../paimon/write}/test_paimon_write_edge_cases.out |   0
 .../write/test_paimon_write_external_paths.out     |  77 +++++++
 .../paimon/write}/test_paimon_write_failures.out   |   0
 .../write}/test_paimon_write_merge_engine.out      |   0
 .../write/test_paimon_write_merge_semantics.out    |   8 +
 .../write/test_paimon_write_partition_delete.out   |  20 ++
 .../paimon/write}/test_paimon_write_pk.out         |   0
 .../write}/test_paimon_write_row_level_dml.out     |   0
 .../test_paimon_write_row_tracking_evolution.out   |  17 ++
 .../write}/test_paimon_write_schema_change.out     |   0
 .../write/test_paimon_write_sequence_group.out     |  30 +++
 .../write/test_paimon_write_sequence_rowkind.out   |  17 ++
 .../write}/test_paimon_write_transaction.out       |   0
 .../paimon/write}/test_paimon_write_types.out      |   0
 .../paimon/write}/test_paimon_write_variant.out    |   0
 .../write}/test_paimon_write_variant_dml.out       |   0
 .../write}/test_paimon_write_variant_errors.out    |   0
 .../write}/test_paimon_write_variant_nested.out    |   0
 .../write}/test_paimon_write_variant_shredding.out |   0
 .../test_paimon_write_variant_table_modes.out      |   0
 .../paimon/test_paimon_jdbc_catalog.groovy         | 135 ++++++++++--
 .../test_paimon_create_ddl_write_properties.groovy |   0
 .../write}/test_paimon_write_append_only.groovy    |   0
 .../write}/test_paimon_write_bucket_modes.groovy   |  70 +++++++
 .../test_paimon_write_changelog_producer.groovy    |   0
 .../write}/test_paimon_write_compaction.groovy     |   0
 .../write}/test_paimon_write_complex_types.groovy  |   0
 .../write/test_paimon_write_deletion_vector.groovy | 194 ++++++++++++++++++
 .../write}/test_paimon_write_edge_cases.groovy     |   0
 .../write/test_paimon_write_external_paths.groovy  | 200 ++++++++++++++++++
 .../write}/test_paimon_write_failures.groovy       |   0
 ...paimon_write_key_dynamic_memory_negative.groovy |   0
 .../write}/test_paimon_write_merge_engine.groovy   |   0
 .../write/test_paimon_write_merge_semantics.groovy | 221 ++++++++++++++++++++
 .../test_paimon_write_partition_delete.groovy      | 150 ++++++++++++++
 .../paimon/write}/test_paimon_write_pk.groovy      |   0
 .../write}/test_paimon_write_row_level_dml.groovy  |   0
 ...test_paimon_write_row_tracking_evolution.groovy | 216 ++++++++++++++++++++
 .../write}/test_paimon_write_schema_change.groovy  |   0
 .../write/test_paimon_write_sequence_group.groovy  | 227 +++++++++++++++++++++
 .../test_paimon_write_sequence_rowkind.groovy      | 200 ++++++++++++++++++
 .../write}/test_paimon_write_snapshot_refs.groovy  |   0
 .../write}/test_paimon_write_source_models.groovy  |   0
 .../test_paimon_write_thread_lifecycle.groovy      |   0
 .../write}/test_paimon_write_transaction.groovy    |   0
 .../paimon/write}/test_paimon_write_types.groovy   |   0
 .../paimon/write}/test_paimon_write_variant.groovy |   0
 .../write}/test_paimon_write_variant_dml.groovy    |   0
 .../write}/test_paimon_write_variant_errors.groovy |   0
 .../write}/test_paimon_write_variant_nested.groovy |   0
 .../test_paimon_write_variant_shredding.groovy     |   0
 .../test_paimon_write_variant_table_modes.groovy   |   0
 65 files changed, 1962 insertions(+), 42 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonTableLoader.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonTableLoader.java
index 0a134cfd7d7..977d20fbb1e 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonTableLoader.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonTableLoader.java
@@ -33,7 +33,12 @@ public final class PaimonTableLoader {
 
     public Table load(NameMapping nameMapping) {
         try {
-            return catalog(nameMapping).getPaimonTable(nameMapping);
+            // Doris caches Paimon table handles to implement the 
external-catalog TTL and REFRESH
+            // lifecycle. Paimon's CachingCatalog has another table cache 
because it also owns
+            // lower-level manifest, snapshot and deletion-vector caches. This 
method is reached on
+            // a Doris cache miss, so invalidate the Paimon table entry as 
well; otherwise Doris can
+            // reload the same stale handle immediately after REFRESH.
+            return catalog(nameMapping).reloadPaimonTable(nameMapping);
         } catch (Exception e) {
             throw new CacheException("failed to load paimon table %s.%s.%s: 
%s",
                     e, nameMapping.getCtlId(), nameMapping.getLocalDbName(), 
nameMapping.getLocalTblName(),
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalCatalog.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalCatalog.java
index 14dfd290519..40d2621cedc 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalCatalog.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalCatalog.java
@@ -124,34 +124,65 @@ public class PaimonExternalCatalog extends 
ExternalCatalog {
 
     public Table getPaimonTable(NameMapping nameMapping, String branch, String 
queryType) {
         makeSureInitialized();
+        Identifier identifier = tableIdentifier(nameMapping, branch, 
queryType);
+        return loadPaimonTable(nameMapping, queryType, identifier);
+    }
+
+    /**
+     * Invalidates Paimon's catalog-level table cache and reloads the table.
+     *
+     * <p>Doris and Paimon cache table handles for different purposes. Doris 
owns the external
+     * metadata lifecycle (TTL, REFRESH and FE-wide invalidation), while 
Paimon's CachingCatalog
+     * attaches lower-level manifest, snapshot and deletion-vector caches to a 
table handle. A miss
+     * in the Doris cache must therefore invalidate Paimon's table entry 
before loading; otherwise a
+     * Doris REFRESH can repopulate its cache with the same stale Paimon table 
handle.
+     */
+    public Table reloadPaimonTable(NameMapping nameMapping) {
+        makeSureInitialized();
+        Identifier identifier = tableIdentifier(nameMapping, null, null);
         try {
-            Identifier identifier;
-            if (branch != null && queryType != null) {
-                identifier = new Identifier(nameMapping.getRemoteDbName(), 
nameMapping.getRemoteTblName(),
-                        branch, queryType);
-            } else if (branch != null) {
-                identifier = new Identifier(nameMapping.getRemoteDbName(), 
nameMapping.getRemoteTblName(),
-                        branch);
-            } else if (queryType != null) {
-                identifier = new Identifier(nameMapping.getRemoteDbName(), 
nameMapping.getRemoteTblName(),
-                        "main", queryType);
-            } else {
-                identifier = new Identifier(nameMapping.getRemoteDbName(), 
nameMapping.getRemoteTblName());
-            }
             return executionAuthenticator.execute(() -> {
-                Table table = catalog.getTable(identifier);
-                Map<String, String> tableOptions = 
paimonProperties.getTableOptionsForCopy();
-                // This handle is relation-neutral. Runtime validation and 
CPU-local capping belong
-                // to the final relation copy, where relation options can 
override physical values.
-                return tableOptions.isEmpty() ? table : 
table.copy(tableOptions);
+                catalog.invalidateTable(identifier);
+                return 
copyWithCatalogTableOptions(catalog.getTable(identifier));
             });
         } catch (Exception e) {
-            throw new RuntimeException("Failed to get Paimon table:" + 
getName() + "."
-                    + nameMapping.getRemoteDbName() + "." + 
nameMapping.getRemoteTblName() + "$" + queryType
-                    + ", because " + ExceptionUtils.getRootCauseMessage(e), e);
+            throw tableLoadException(nameMapping, null, e);
+        }
+    }
+
+    private Identifier tableIdentifier(NameMapping nameMapping, String branch, 
String queryType) {
+        if (branch != null && queryType != null) {
+            return new Identifier(nameMapping.getRemoteDbName(), 
nameMapping.getRemoteTblName(),
+                    branch, queryType);
+        } else if (branch != null) {
+            return new Identifier(nameMapping.getRemoteDbName(), 
nameMapping.getRemoteTblName(), branch);
+        } else if (queryType != null) {
+            return new Identifier(nameMapping.getRemoteDbName(), 
nameMapping.getRemoteTblName(), "main", queryType);
+        }
+        return new Identifier(nameMapping.getRemoteDbName(), 
nameMapping.getRemoteTblName());
+    }
+
+    private Table loadPaimonTable(NameMapping nameMapping, String queryType, 
Identifier identifier) {
+        try {
+            return executionAuthenticator.execute(() -> 
copyWithCatalogTableOptions(catalog.getTable(identifier)));
+        } catch (Exception e) {
+            throw tableLoadException(nameMapping, queryType, e);
         }
     }
 
+    private Table copyWithCatalogTableOptions(Table table) {
+        Map<String, String> tableOptions = 
paimonProperties.getTableOptionsForCopy();
+        // This handle is relation-neutral. Runtime validation and CPU-local 
capping belong
+        // to the final relation copy, where relation options can override 
physical values.
+        return tableOptions.isEmpty() ? table : table.copy(tableOptions);
+    }
+
+    private RuntimeException tableLoadException(NameMapping nameMapping, 
String queryType, Exception e) {
+        return new RuntimeException("Failed to get Paimon table:" + getName() 
+ "."
+                + nameMapping.getRemoteDbName() + "." + 
nameMapping.getRemoteTblName() + "$" + queryType
+                + ", because " + ExceptionUtils.getRootCauseMessage(e), e);
+    }
+
     protected Catalog createCatalog() {
         try {
             return paimonProperties.initializeCatalog(getName(), new 
ArrayList<>(catalogProperty
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
index d0a3c858f4b..b38d0d5a8bb 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
@@ -140,11 +140,12 @@ public class PaimonExternalTable extends ExternalTable 
implements MTMVRelatedTab
      *
      * <p>A statement MVCC snapshot belongs to a read relation. In a 
time-travel self-insert the
      * same Doris table identity can therefore have a historical source 
snapshot registered in
-     * StatementContext. Write planning must never reuse that snapshot: the 
writer, target schema
-     * and partition metadata must all come from the latest remote table 
handle.
+     * StatementContext. Write planning must never reuse that snapshot. The 
base table cache is the
+     * current write target; REFRESH and cache expiry reload it through {@code 
PaimonTableLoader},
+     * which also invalidates Paimon's catalog-level table cache.
      */
     public Table getPaimonTableForWrite() {
-        return ((PaimonExternalCatalog) 
catalog).getPaimonTable(getOrBuildNameMapping());
+        return getBasePaimonTable();
     }
 
     private PaimonSnapshotCacheValue 
getPaimonSnapshotCacheValue(Optional<TableSnapshot> tableSnapshot,
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalMetaCacheTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalMetaCacheTest.java
index 228bb112ff0..12052898c25 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalMetaCacheTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalMetaCacheTest.java
@@ -55,6 +55,7 @@ import org.junit.Assume;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
+import org.mockito.InOrder;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 
@@ -274,6 +275,62 @@ public class PaimonExternalMetaCacheTest {
         }
     }
 
+    @Test
+    public void testReloadInvalidatesPaimonTableCacheBeforeLoad() throws 
Exception {
+        java.io.File warehouse = temporaryFolder.newFolder("reload_table");
+        Map<String, String> properties = new HashMap<>();
+        properties.put("type", "paimon");
+        properties.put(PaimonExternalCatalog.PAIMON_CATALOG_TYPE,
+                PaimonExternalCatalog.PAIMON_FILESYSTEM);
+        properties.put("warehouse", warehouse.toURI().toString());
+        PaimonExternalCatalog dorisCatalog = new PaimonExternalCatalog(
+                92L, "paimon_reload_test", null, properties, "");
+        dorisCatalog.makeSureInitialized();
+        dorisCatalog.catalog.close();
+
+        Catalog paimonCatalog = Mockito.mock(Catalog.class);
+        Table expected = Mockito.mock(Table.class);
+        Identifier identifier = Identifier.create("remote_db", "remote_table");
+        Mockito.when(paimonCatalog.getTable(identifier)).thenReturn(expected);
+        dorisCatalog.catalog = paimonCatalog;
+
+        Table actual = dorisCatalog.reloadPaimonTable(
+                new NameMapping(92L, "local_db", "local_table", "remote_db", 
"remote_table"));
+
+        Assert.assertSame(expected, actual);
+        InOrder inOrder = Mockito.inOrder(paimonCatalog);
+        inOrder.verify(paimonCatalog).invalidateTable(identifier);
+        inOrder.verify(paimonCatalog).getTable(identifier);
+    }
+
+    @Test
+    public void testDorisTableCacheMissReloadsPaimonCatalogOnce() throws 
Exception {
+        long catalogId = 93L;
+        NameMapping nameMapping = new NameMapping(
+                catalogId, "local_db", "local_table", "remote_db", 
"remote_table");
+        Table expected = Mockito.mock(Table.class);
+        PaimonExternalCatalog dorisCatalog = 
Mockito.mock(PaimonExternalCatalog.class);
+        
Mockito.when(dorisCatalog.reloadPaimonTable(nameMapping)).thenReturn(expected);
+        CatalogMgr catalogMgr = Mockito.mock(CatalogMgr.class);
+        Env env = Mockito.mock(Env.class);
+        Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
+        Mockito.doReturn(dorisCatalog).when(catalogMgr)
+                .getCatalogOrException(Mockito.eq(catalogId), Mockito.any());
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+            mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+            PaimonExternalMetaCache cache = new 
PaimonExternalMetaCache(executor);
+            cache.initCatalog(catalogId, Collections.emptyMap());
+
+            Assert.assertSame(expected, cache.getPaimonTable(nameMapping));
+            Assert.assertSame(expected, cache.getPaimonTable(nameMapping));
+
+            Mockito.verify(dorisCatalog, 
Mockito.times(1)).reloadPaimonTable(nameMapping);
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
     @Test
     public void 
testPartitionProjectionRejectsUnsafeEffectiveTableBeforeEnumeration() throws 
Exception {
         FileStoreTable unsafeTable = newPartitionedTable(
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalTableTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalTableTest.java
index a0f01f25cdf..4399e59b7d1 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalTableTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalTableTest.java
@@ -67,6 +67,23 @@ import java.util.concurrent.atomic.AtomicInteger;
 
 public class PaimonExternalTableTest {
 
+    @Test
+    public void testWriteUsesBaseTableInsteadOfStatementSnapshot() {
+        PaimonExternalCatalog catalog = 
Mockito.mock(PaimonExternalCatalog.class);
+        PaimonExternalDatabase database = 
Mockito.mock(PaimonExternalDatabase.class);
+        Table paimonTable = Mockito.mock(Table.class);
+        Mockito.when(catalog.getId()).thenReturn(1L);
+        Mockito.when(database.getCatalog()).thenReturn(catalog);
+        Mockito.when(database.getFullName()).thenReturn("local_db");
+        Mockito.when(database.getRemoteName()).thenReturn("remote_db");
+        PaimonExternalTable externalTable = Mockito.spy(new 
PaimonExternalTable(
+                10L, "local_table", "remote_table", catalog, database));
+        Mockito.doReturn(paimonTable).when(externalTable).getBasePaimonTable();
+
+        Assert.assertSame(paimonTable, externalTable.getPaimonTableForWrite());
+        Mockito.verify(externalTable).getBasePaimonTable();
+    }
+
     @Test
     public void 
testStatementContextDefersPhysicalManifestValidationUntilRelationOptions() {
         PaimonExternalCatalog catalog = 
Mockito.mock(PaimonExternalCatalog.class);
diff --git 
a/regression-test/data/external_table_p0/paimon/test_paimon_jdbc_catalog.out 
b/regression-test/data/external_table_p0/paimon/test_paimon_jdbc_catalog.out
index 0866e5961ba..0dc7742b94d 100644
--- a/regression-test/data/external_table_p0/paimon/test_paimon_jdbc_catalog.out
+++ b/regression-test/data/external_table_p0/paimon/test_paimon_jdbc_catalog.out
@@ -3,3 +3,19 @@
 1      alice   2025-01-01
 2      bob     2025-01-02
 
+-- !paimon_jdbc_concurrent_append --
+left   128     128     8128
+right  128     128     136128
+
+-- !paimon_jdbc_same_partition_append --
+128    128     324032
+
+-- !paimon_jdbc_concurrent_aggregation --
+1      30
+
+-- !paimon_jdbc_concurrent_dynamic --
+left   64      64
+right  64      64
+
+-- !paimon_jdbc_merge_during_compact --
+2      merge-during-compact    200
diff --git 
a/regression-test/data/paimon_write/test_paimon_create_ddl_write_properties.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_create_ddl_write_properties.out
similarity index 100%
rename from 
regression-test/data/paimon_write/test_paimon_create_ddl_write_properties.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_create_ddl_write_properties.out
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_append_only.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_append_only.out
similarity index 100%
rename from regression-test/data/paimon_write/test_paimon_write_append_only.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_append_only.out
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_bucket_modes.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_bucket_modes.out
similarity index 88%
rename from regression-test/data/paimon_write/test_paimon_write_bucket_modes.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_bucket_modes.out
index 425fdf51f4c..55d921313e1 100644
--- a/regression-test/data/paimon_write/test_paimon_write_bucket_modes.out
+++ 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_bucket_modes.out
@@ -2,6 +2,13 @@
 -- !bucket_hash_fixed --
 16     0       15      2
 
+-- !bucket_rescale_partial --
+p1     1       p1-old-1
+p1     10      p1-after-rescale
+p1     2       p1-old-2
+p2     3       p2-old-3
+p2     4       p2-old-4
+
 -- !bucket_hash_dynamic --
 p1     1       v1_updated
 p1     2       v2
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_changelog_producer.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_changelog_producer.out
similarity index 100%
rename from 
regression-test/data/paimon_write/test_paimon_write_changelog_producer.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_changelog_producer.out
diff --git a/regression-test/data/paimon_write/test_paimon_write_compaction.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_compaction.out
similarity index 100%
rename from regression-test/data/paimon_write/test_paimon_write_compaction.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_compaction.out
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_complex_types.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_complex_types.out
similarity index 100%
rename from 
regression-test/data/paimon_write/test_paimon_write_complex_types.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_complex_types.out
diff --git 
a/regression-test/data/external_table_p0/paimon/write/test_paimon_write_deletion_vector.out
 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_deletion_vector.out
new file mode 100644
index 00000000000..a13f2ca16fc
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_deletion_vector.out
@@ -0,0 +1,38 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !dv_before_compact_jni --
+1      merged-1        12
+4      insert-4        40
+5      inserted-5      50
+
+-- !dv_before_compact_native --
+1      merged-1        12
+4      insert-4        40
+5      inserted-5      50
+
+-- !dv_after_compact_jni --
+1      merged-1        12
+4      insert-4        40
+5      inserted-5      50
+
+-- !dv_after_compact_native --
+1      merged-1        12
+4      insert-4        40
+5      inserted-5      50
+
+-- !dv_post_compact_write_jni --
+1      post-compact-1  13
+4      insert-4        40
+5      inserted-5      50
+6      post-compact-6  60
+
+-- !dv_post_compact_write_native --
+1      post-compact-1  13
+4      insert-4        40
+5      inserted-5      50
+6      post-compact-6  60
+
+-- !dv_enabled_after_mor_jni --
+1      mow-new-1
+
+-- !dv_enabled_after_mor_native --
+1      mow-new-1
diff --git a/regression-test/data/paimon_write/test_paimon_write_edge_cases.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_edge_cases.out
similarity index 100%
rename from regression-test/data/paimon_write/test_paimon_write_edge_cases.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_edge_cases.out
diff --git 
a/regression-test/data/external_table_p0/paimon/write/test_paimon_write_external_paths.out
 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_external_paths.out
new file mode 100644
index 00000000000..e5763fe6c94
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_external_paths.out
@@ -0,0 +1,77 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !external_round_robin_initial --
+p-bulk 100     2048
+p-bulk 101     2048
+p-bulk 102     2048
+p-bulk 103     2048
+p-bulk 104     2048
+p-bulk 105     2048
+p-bulk 106     2048
+p-bulk 107     2048
+p-bulk 108     2048
+p-bulk 109     2048
+p-bulk 110     2048
+p-bulk 111     2048
+p-bulk 112     2048
+p-bulk 113     2048
+p-bulk 114     2048
+p-bulk 115     2048
+p1     1       3
+p1     2       3
+p2     3       5
+p2     4       4
+
+-- !external_round_robin_changed --
+p-bulk 100     2048
+p-bulk 101     2048
+p-bulk 102     2048
+p-bulk 103     2048
+p-bulk 104     2048
+p-bulk 105     2048
+p-bulk 106     2048
+p-bulk 107     2048
+p-bulk 108     2048
+p-bulk 109     2048
+p-bulk 110     2048
+p-bulk 111     2048
+p-bulk 112     2048
+p-bulk 113     2048
+p-bulk 114     2048
+p-bulk 115     2048
+p-new-bulk     200     2048
+p-new-bulk     201     2048
+p-new-bulk     202     2048
+p-new-bulk     203     2048
+p-new-bulk     204     2048
+p-new-bulk     205     2048
+p-new-bulk     206     2048
+p-new-bulk     207     2048
+p-new-bulk     208     2048
+p-new-bulk     209     2048
+p-new-bulk     210     2048
+p-new-bulk     211     2048
+p-new-bulk     212     2048
+p-new-bulk     213     2048
+p-new-bulk     214     2048
+p-new-bulk     215     2048
+p1     1       3
+p1     2       3
+p2     3       5
+p2     4       4
+p3     5       4
+p3     6       3
+
+-- !external_weight_robin --
+1      weight-1
+2      weight-2
+3      weight-3
+4      weight-4
+5      weight-5
+6      weight-6
+
+-- !external_specific_fs --
+1      specific-1
+2      specific-2
+
+-- !external_default_path --
+1      default-path
diff --git a/regression-test/data/paimon_write/test_paimon_write_failures.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_failures.out
similarity index 100%
rename from regression-test/data/paimon_write/test_paimon_write_failures.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_failures.out
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_merge_engine.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_merge_engine.out
similarity index 100%
rename from regression-test/data/paimon_write/test_paimon_write_merge_engine.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_merge_engine.out
diff --git 
a/regression-test/data/external_table_p0/paimon/write/test_paimon_write_merge_semantics.out
 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_merge_semantics.out
new file mode 100644
index 00000000000..5db9108a548
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_merge_semantics.out
@@ -0,0 +1,8 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !merge_semantics_result --
+1      15      11      base-1-source-1 updated required-1-new
+3      30      3       base-3  stable  required-3
+4      40      44      source-4        inserted        required-4
+
+-- !merge_semantics_recovered --
+5      recovered
diff --git 
a/regression-test/data/external_table_p0/paimon/write/test_paimon_write_partition_delete.out
 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_partition_delete.out
new file mode 100644
index 00000000000..92f03fca8c8
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_partition_delete.out
@@ -0,0 +1,20 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !partition_delete_full_partition --
+\N     6       60      default-partition
+p2     3       30      p2-a
+p2     4       40      p2-b
+p3     5       50      p3-a
+
+-- !partition_delete_partial_partition --
+\N     6       60      default-partition
+p2     3       30      p2-a
+p3     5       50      p3-a
+
+-- !partition_delete_expression --
+\N     6       60      default-partition
+p3     5       50      p3-a
+
+-- !partition_delete_not_exists --
+\N     6       60      default-partition
+
+-- !partition_delete_default_partition --
diff --git a/regression-test/data/paimon_write/test_paimon_write_pk.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_pk.out
similarity index 100%
rename from regression-test/data/paimon_write/test_paimon_write_pk.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_pk.out
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_row_level_dml.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_row_level_dml.out
similarity index 100%
rename from 
regression-test/data/paimon_write/test_paimon_write_row_level_dml.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_row_level_dml.out
diff --git 
a/regression-test/data/external_table_p0/paimon/write/test_paimon_write_row_tracking_evolution.out
 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_row_tracking_evolution.out
new file mode 100644
index 00000000000..1f71d511f7d
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_row_tracking_evolution.out
@@ -0,0 +1,17 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !row_tracking_after_spark_changes --
+1      one-merged
+2      two-updated
+4      four
+
+-- !row_tracking_after_compact_write --
+1      one-merged
+2      two-updated
+4      four
+5      five-after-compact
+
+-- !data_evolution_after_spark_merge --
+1      11      100
+2      22      200
+3      30      \N
+4      44      444
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_schema_change.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_schema_change.out
similarity index 100%
rename from 
regression-test/data/paimon_write/test_paimon_write_schema_change.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_schema_change.out
diff --git 
a/regression-test/data/external_table_p0/paimon/write/test_paimon_write_sequence_group.out
 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_sequence_group.out
new file mode 100644
index 00000000000..1c72d08a0cc
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_sequence_group.out
@@ -0,0 +1,30 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !sequence_group_stale_profile --
+1      alice   shanghai        10      12      90      11      base
+
+-- !sequence_group_new_profile --
+1      alice-new       shenzhen        12      112     99      11      new-note
+
+-- !sequence_group_null_sequence --
+1      alice-new       shenzhen        12      115     99      13      new-note
+
+-- !remove_on_delete_empty --
+0
+
+-- !remove_on_delete_partial --
+1      old-a   new-b
+
+-- !remove_on_delete_complete --
+1      new-a   new-b
+
+-- !group_remove_on_delete_unchanged --
+1      old-a   100     old-b   100
+
+-- !group_remove_on_delete_sequence --
+1      high-a  101     old-b   100
+
+-- !property_change_still_writable --
+1      still-writable  10
+
+-- !property_change_sequence_group --
+1      high    20
diff --git 
a/regression-test/data/external_table_p0/paimon/write/test_paimon_write_sequence_rowkind.out
 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_sequence_rowkind.out
new file mode 100644
index 00000000000..5b1cf8b4496
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_sequence_rowkind.out
@@ -0,0 +1,17 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !sequence_ascending --
+1      10      21      new-second-field
+2      1       0       nonnull-wins
+
+-- !sequence_equal --
+3      second-equal
+
+-- !sequence_descending --
+1      5       smaller-wins
+
+-- !rowkind_changelog --
+1      new-1
+3      new-3
+
+-- !rowkind_recovered --
+4      recovered
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_transaction.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_transaction.out
similarity index 100%
rename from regression-test/data/paimon_write/test_paimon_write_transaction.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_transaction.out
diff --git a/regression-test/data/paimon_write/test_paimon_write_types.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_types.out
similarity index 100%
rename from regression-test/data/paimon_write/test_paimon_write_types.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_types.out
diff --git a/regression-test/data/paimon_write/test_paimon_write_variant.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant.out
similarity index 100%
rename from regression-test/data/paimon_write/test_paimon_write_variant.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant.out
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_variant_dml.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant_dml.out
similarity index 100%
rename from regression-test/data/paimon_write/test_paimon_write_variant_dml.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant_dml.out
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_variant_errors.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant_errors.out
similarity index 100%
rename from 
regression-test/data/paimon_write/test_paimon_write_variant_errors.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant_errors.out
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_variant_nested.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant_nested.out
similarity index 100%
rename from 
regression-test/data/paimon_write/test_paimon_write_variant_nested.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant_nested.out
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_variant_shredding.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant_shredding.out
similarity index 100%
rename from 
regression-test/data/paimon_write/test_paimon_write_variant_shredding.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant_shredding.out
diff --git 
a/regression-test/data/paimon_write/test_paimon_write_variant_table_modes.out 
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant_table_modes.out
similarity index 100%
rename from 
regression-test/data/paimon_write/test_paimon_write_variant_table_modes.out
rename to 
regression-test/data/external_table_p0/paimon/write/test_paimon_write_variant_table_modes.out
diff --git 
a/regression-test/suites/external_table_p0/paimon/test_paimon_jdbc_catalog.groovy
 
b/regression-test/suites/external_table_p0/paimon/test_paimon_jdbc_catalog.groovy
index 28a71862c0e..b57bd35617b 100644
--- 
a/regression-test/suites/external_table_p0/paimon/test_paimon_jdbc_catalog.groovy
+++ 
b/regression-test/suites/external_table_p0/paimon/test_paimon_jdbc_catalog.groovy
@@ -68,6 +68,11 @@ suite("test_paimon_jdbc_catalog", "p0,external") {
     String sparkSeedCatalogName = "${catalogName}_seed"
     // Reuse the fixture-wide Docker command so local and CI permission models 
behave identically.
     String dockerCommand = 
context.config.otherConfigs.get("externalDockerCommand") ?: "docker"
+    String sparkMaster = 
context.config.otherConfigs.get("paimon_jdbc_spark_master")
+    String sparkExternalEnvIp = 
context.config.otherConfigs.get("paimon_jdbc_spark_external_env_ip")
+    if (sparkExternalEnvIp == null || sparkExternalEnvIp.isEmpty()) {
+        sparkExternalEnvIp = externalEnvIp
+    }
 
     assertTrue(jdbcDriversDir != null && !jdbcDriversDir.isEmpty(), 
"jdbc_drivers_dir must be configured")
 
@@ -168,7 +173,10 @@ suite("test_paimon_jdbc_catalog", "p0,external") {
     }
     executeCommand("${dockerCommand} cp ${localDriverPath} 
${sparkContainerName}:${sparkDriverPath}", true, 60)
 
-    String sparkMinioEndpoint = "http://${externalEnvIp}:${minioPort}";
+    String sparkMinioEndpoint = 
context.config.otherConfigs.get("paimon_jdbc_spark_minio_endpoint")
+    if (sparkMinioEndpoint == null || sparkMinioEndpoint.isEmpty()) {
+        sparkMinioEndpoint = "http://${sparkExternalEnvIp}:${minioPort}";
+    }
     if (sparkContainerName.contains("spark-iceberg")) {
         String sparkMinioContainerName = 
sparkContainerName.replaceFirst("spark-iceberg", "minio")
         String resolvedSparkMinioContainer = executeCommand(
@@ -182,10 +190,14 @@ suite("test_paimon_jdbc_catalog", "p0,external") {
         }
     }
     logger.info("spark seed minio endpoint: ${sparkMinioEndpoint}")
+    if (sparkMaster == null || sparkMaster.isEmpty()) {
+        sparkMaster = "spark://${sparkContainerName}:7077"
+    }
+    logger.info("spark seed master: ${sparkMaster}")
 
     def sparkPaimonJdbc = { String sqlText ->
         String escapedSql = sqlText.replaceAll('"', '\\\\"')
-        String command = """${dockerCommand} exec ${sparkContainerName} 
spark-sql --master spark://${sparkContainerName}:7077 \
+        String command = """${dockerCommand} exec ${sparkContainerName} 
spark-sql --master ${sparkMaster} \
 --jars ${sparkDriverPath} \
 --driver-class-path ${sparkDriverPath} \
 --conf spark.driver.extraClassPath=${sparkDriverPath} \
@@ -194,7 +206,7 @@ suite("test_paimon_jdbc_catalog", "p0,external") {
 --conf 
spark.sql.catalog.${sparkSeedCatalogName}=org.apache.paimon.spark.SparkCatalog \
 --conf 
spark.sql.catalog.${sparkSeedCatalogName}.warehouse=s3://${warehouseBucket}/paimon_jdbc_catalog/
 \
 --conf spark.sql.catalog.${sparkSeedCatalogName}.metastore=jdbc \
---conf 
spark.sql.catalog.${sparkSeedCatalogName}.uri=jdbc:postgresql://${externalEnvIp}:${jdbcPort}/postgres
 \
+--conf 
spark.sql.catalog.${sparkSeedCatalogName}.uri=jdbc:postgresql://${sparkExternalEnvIp}:${jdbcPort}/postgres
 \
 --conf spark.sql.catalog.${sparkSeedCatalogName}.catalog-key=${catalogName} \
 --conf spark.sql.catalog.${sparkSeedCatalogName}.jdbc.user=postgres \
 --conf spark.sql.catalog.${sparkSeedCatalogName}.jdbc.password=123456 \
@@ -408,15 +420,12 @@ suite("test_paimon_jdbc_catalog", "p0,external") {
             """
         })
         sql """REFRESH TABLE paimon_jdbc_concurrent_append"""
-        assertEquals([
-                ["left", 128L, 128L, 8128L],
-                ["right", 128L, 128L, 136128L]
-        ], sql("""
+        order_qt_paimon_jdbc_concurrent_append """
             SELECT pt, COUNT(*), COUNT(DISTINCT id), SUM(id)
             FROM paimon_jdbc_concurrent_append
             GROUP BY pt
             ORDER BY pt
-        """))
+        """
         assertEquals(appendSnapshots + 2L, (sql """
             SELECT COUNT(*) FROM paimon_jdbc_concurrent_append\$snapshots
         """)[0][0] as long)
@@ -435,11 +444,11 @@ suite("test_paimon_jdbc_catalog", "p0,external") {
             """
         })
         sql """REFRESH TABLE paimon_jdbc_concurrent_append"""
-        assertEquals([[128L, 128L, 324032L]], sql("""
+        qt_paimon_jdbc_same_partition_append """
             SELECT COUNT(*), COUNT(DISTINCT id), SUM(id)
             FROM paimon_jdbc_concurrent_append
             WHERE pt = 'same'
-        """))
+        """
         assertEquals(appendSnapshots + 4L, (sql """
             SELECT COUNT(*) FROM paimon_jdbc_concurrent_append\$snapshots
         """)[0][0] as long)
@@ -509,9 +518,10 @@ suite("test_paimon_jdbc_catalog", "p0,external") {
             """
         })
         sql """REFRESH TABLE paimon_jdbc_concurrent_aggregation"""
-        assertEquals([[1, 30L]], sql("""
+        order_qt_paimon_jdbc_concurrent_aggregation """
             SELECT id, total FROM paimon_jdbc_concurrent_aggregation
-        """))
+            ORDER BY id
+        """
 
         // Dynamic bucket only permits multiple jobs when they own disjoint 
partitions.
         sql """DROP TABLE IF EXISTS paimon_jdbc_concurrent_dynamic"""
@@ -543,18 +553,109 @@ suite("test_paimon_jdbc_catalog", "p0,external") {
             """
         })
         sql """REFRESH TABLE paimon_jdbc_concurrent_dynamic"""
-        assertEquals([
-                ["left", 64L, 64L],
-                ["right", 64L, 64L]
-        ], sql("""
+        order_qt_paimon_jdbc_concurrent_dynamic """
             SELECT pt, COUNT(*), COUNT(DISTINCT id)
             FROM paimon_jdbc_concurrent_dynamic
             GROUP BY pt
             ORDER BY pt
-        """))
+        """
+
+        // P12: Row-level writers use the same catalog lock and snapshot commit
+        // protocol as INSERT. Concurrent MERGEs on one key may expose either
+        // last value, but both transactions must commit without duplicating 
it.
+        sql """DROP TABLE IF EXISTS paimon_jdbc_concurrent_merge"""
+        sql """
+            CREATE TABLE ${dbName}.paimon_jdbc_concurrent_merge (
+                id INT,
+                payload STRING,
+                score INT
+            ) ENGINE=paimon
+            PROPERTIES (
+                'primary-key' = 'id',
+                'bucket' = '1',
+                'merge-engine' = 'deduplicate',
+                'num-sorted-run.compaction-trigger' = '100'
+            )
+        """
+        sql """INSERT INTO paimon_jdbc_concurrent_merge VALUES
+            (1, 'base-1', 0), (2, 'base-2', 0)
+        """
+        long mergeSnapshots = (sql """
+            SELECT COUNT(*) FROM paimon_jdbc_concurrent_merge\$snapshots
+        """)[0][0] as long
+
+        runConcurrent("paimon-jdbc-merge-left", {
+            sql """
+                MERGE INTO 
${catalogName}.${dbName}.paimon_jdbc_concurrent_merge t
+                USING (SELECT 1 AS id, 'left' AS payload, 10 AS score) s
+                ON t.id = s.id
+                WHEN MATCHED THEN UPDATE SET
+                    payload = s.payload, score = s.score
+            """
+        }, "paimon-jdbc-merge-right", {
+            sql """
+                MERGE INTO 
${catalogName}.${dbName}.paimon_jdbc_concurrent_merge t
+                USING (SELECT 1 AS id, 'right' AS payload, 20 AS score) s
+                ON t.id = s.id
+                WHEN MATCHED THEN UPDATE SET
+                    payload = s.payload, score = s.score
+            """
+        })
+        sql """REFRESH TABLE paimon_jdbc_concurrent_merge"""
+        def sameKeyMergeRows = sql """
+            SELECT id, payload, score
+            FROM paimon_jdbc_concurrent_merge
+            WHERE id = 1
+        """
+        assertEquals(1, sameKeyMergeRows.size())
+        assertTrue([
+                [1, "left", 10],
+                [1, "right", 20]
+        ].contains(sameKeyMergeRows[0]))
+        assertEquals(mergeSnapshots + 2L, (sql """
+            SELECT COUNT(*) FROM paimon_jdbc_concurrent_merge\$snapshots
+        """)[0][0] as long)
+
+        // Interleave a Doris MERGE with a Spark full compaction. Whichever
+        // operation obtains the catalog lock first, the MERGE result must 
remain
+        // visible and compaction must not resurrect the pre-update value.
+        runConcurrent("paimon-jdbc-merge-compact", {
+            sql """
+                MERGE INTO 
${catalogName}.${dbName}.paimon_jdbc_concurrent_merge t
+                USING (SELECT 2 AS id, 'merge-during-compact' AS payload,
+                              200 AS score) s
+                ON t.id = s.id
+                WHEN MATCHED THEN UPDATE SET
+                    payload = s.payload, score = s.score
+            """
+        }, "paimon-jdbc-spark-compact", {
+            sparkPaimonJdbc """
+                CALL ${sparkSeedCatalogName}.sys.compact(
+                    table => '${dbName}.paimon_jdbc_concurrent_merge',
+                    compact_strategy => 'full')
+            """
+        })
+        sql """REFRESH TABLE paimon_jdbc_concurrent_merge"""
+        order_qt_paimon_jdbc_merge_during_compact """
+            SELECT id, payload, score
+            FROM paimon_jdbc_concurrent_merge
+            WHERE id = 2
+            ORDER BY id
+        """
+
+        sparkPaimonJdbc """
+            SELECT assert_true(
+                COUNT(*) = 2 AND
+                SUM(CASE WHEN id = 2
+                          AND payload = 'merge-during-compact'
+                          AND score = 200
+                         THEN 1 ELSE 0 END) = 1)
+            FROM ${sparkSeedCatalogName}.${dbName}.paimon_jdbc_concurrent_merge
+        """
     } finally {
         try {
             sql """SWITCH ${catalogName}"""
+            sql """DROP TABLE IF EXISTS 
${dbName}.paimon_jdbc_concurrent_merge"""
             sql """DROP TABLE IF EXISTS 
${dbName}.paimon_jdbc_concurrent_dynamic"""
             sql """DROP TABLE IF EXISTS 
${dbName}.paimon_jdbc_concurrent_aggregation"""
             sql """DROP TABLE IF EXISTS ${dbName}.paimon_jdbc_concurrent_pk"""
diff --git 
a/regression-test/suites/paimon_write/test_paimon_create_ddl_write_properties.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_create_ddl_write_properties.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_create_ddl_write_properties.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_create_ddl_write_properties.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_append_only.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_append_only.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_append_only.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_append_only.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_bucket_modes.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_bucket_modes.groovy
similarity index 87%
rename from 
regression-test/suites/paimon_write/test_paimon_write_bucket_modes.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_bucket_modes.groovy
index 08e9fd85b04..f8ad1af6586 100644
--- a/regression-test/suites/paimon_write/test_paimon_write_bucket_modes.groovy
+++ 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_bucket_modes.groovy
@@ -42,6 +42,17 @@ suite("test_paimon_write_bucket_modes", 
"p0,external,paimon") {
             'bucket-key' = 'id'
         );
 
+        DROP TABLE IF EXISTS paimon.${dbName}.t_rescale;
+        CREATE TABLE paimon.${dbName}.t_rescale (
+            pt STRING, id INT, name STRING
+        ) USING paimon
+        PARTITIONED BY (pt)
+        TBLPROPERTIES (
+            'primary-key' = 'pt,id',
+            'bucket' = '2',
+            'bucket-key' = 'id'
+        );
+
         DROP TABLE IF EXISTS paimon.${dbName}.t_hash_dynamic;
         CREATE TABLE paimon.${dbName}.t_hash_dynamic (
             pt STRING, id INT, name STRING
@@ -235,6 +246,65 @@ suite("test_paimon_write_bucket_modes", 
"p0,external,paimon") {
         def fixedBuckets = assertBucketsInRange("t_hash_fixed", 0, 3)
         assertTrue(fixedBuckets.size() > 1)
 
+        // P04: ALTER only changes the configured bucket count. Existing
+        // partitions must be rewritten before a new writer can use bucket=4.
+        sql """INSERT INTO t_rescale VALUES
+            ('p1', 1, 'p1-old-1'),
+            ('p1', 2, 'p1-old-2'),
+            ('p2', 3, 'p2-old-3'),
+            ('p2', 4, 'p2-old-4')
+        """
+        spark_paimon """
+            ALTER TABLE paimon.${dbName}.t_rescale
+            SET TBLPROPERTIES ('bucket' = '4')
+        """
+        sql """refresh table t_rescale"""
+        long rescaleSnapshot = (sql """
+            SELECT max(snapshot_id) FROM t_rescale\$snapshots
+        """)[0][0] as long
+        long rescaleFiles = (sql """
+            SELECT count(*) FROM t_rescale\$files
+        """)[0][0] as long
+        boolean rejectedBeforeRescale = false
+        try {
+            sql """INSERT INTO t_rescale VALUES ('p1', 10, 
'must-fail-before-rescale')"""
+        } catch (Exception ignored) {
+            rejectedBeforeRescale = true
+        }
+        assertTrue(rejectedBeforeRescale)
+        assertEquals(rescaleSnapshot, (sql """
+            SELECT max(snapshot_id) FROM t_rescale\$snapshots
+        """)[0][0] as long)
+        assertEquals(rescaleFiles, (sql """
+            SELECT count(*) FROM t_rescale\$files
+        """)[0][0] as long)
+
+        // Rescale only p1. The rewritten partition accepts new writes, while 
p2
+        // remains readable with its old layout and still rejects bucket=4.
+        sql """
+            INSERT OVERWRITE TABLE t_rescale PARTITION (pt = 'p1')
+            SELECT id, name FROM t_rescale WHERE pt = 'p1'
+        """
+        sql """INSERT INTO t_rescale VALUES ('p1', 10, 'p1-after-rescale')"""
+        boolean unreformedPartitionRejected = false
+        try {
+            sql """INSERT INTO t_rescale VALUES ('p2', 20, 
'p2-must-still-fail')"""
+        } catch (Exception ignored) {
+            unreformedPartitionRejected = true
+        }
+        assertTrue(unreformedPartitionRejected)
+        order_qt_bucket_rescale_partial """
+            SELECT * FROM t_rescale ORDER BY pt, id
+        """
+
+        sql """
+            INSERT OVERWRITE TABLE t_rescale PARTITION (pt = 'p2')
+            SELECT id, name FROM t_rescale WHERE pt = 'p2'
+        """
+        sql """INSERT INTO t_rescale VALUES ('p2', 20, 'p2-after-rescale')"""
+        assertTableEquals("t_rescale", "ORDER BY pt, id")
+        assertBucketsInRange("t_rescale", 0, 3)
+
         // HASH_DYNAMIC: new keys expand buckets independently per partition.
         sql """INSERT INTO t_hash_dynamic VALUES
             ('p1', 1, 'v1'),
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_changelog_producer.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_changelog_producer.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_changelog_producer.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_changelog_producer.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_compaction.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_compaction.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_compaction.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_compaction.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_complex_types.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_complex_types.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_complex_types.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_complex_types.groovy
diff --git 
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_deletion_vector.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_deletion_vector.groovy
new file mode 100644
index 00000000000..62462d1a3b9
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_deletion_vector.groovy
@@ -0,0 +1,194 @@
+// 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_paimon_write_deletion_vector", "p0,external,paimon") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test.")
+        return
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String catalogName = "test_pw_dv_catalog"
+    String dbName = "test_pw_dv_db"
+
+    spark_paimon_multi """
+        CREATE DATABASE IF NOT EXISTS paimon.${dbName};
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_dv;
+        CREATE TABLE paimon.${dbName}.t_dv (
+            id INT, payload STRING, score INT
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '1',
+            'bucket-key' = 'id',
+            'deletion-vectors.enabled' = 'true'
+        );
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_enable_dv;
+        CREATE TABLE paimon.${dbName}.t_enable_dv (
+            id INT, payload STRING
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '1',
+            'bucket-key' = 'id',
+            'deletion-vectors.modifiable' = 'true'
+        );
+    """
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        CREATE CATALOG ${catalogName} PROPERTIES (
+            'type' = 'paimon',
+            'paimon.catalog.type' = 'filesystem',
+            'warehouse' = 's3://warehouse/wh',
+            's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+            's3.access_key' = 'admin',
+            's3.secret_key' = 'password',
+            's3.path.style.access' = 'true'
+        )
+    """
+    sql """switch ${catalogName}"""
+    sql """use ${dbName}"""
+
+    sql """create database if not exists internal.${dbName}"""
+    sql """drop table if exists internal.${dbName}.dv_source"""
+    sql """
+        create table internal.${dbName}.dv_source (
+            id int, payload string, score int, action string
+        ) distributed by hash(id) buckets 1
+        properties ('replication_num' = '1')
+    """
+
+    try {
+        def deletionVectorEntries = { String tableName ->
+            def rows = spark_paimon """
+                SELECT coalesce(sum(row_count), 0)
+                FROM paimon.${dbName}.`${tableName}\$table_indexes`
+                WHERE index_type = 'DELETION_VECTORS'
+            """
+            return rows[0][0].toString().toLong()
+        }
+        def assertReaders = { String tag, String tableName, String columns,
+                              String orderBy ->
+            [true, false].each { boolean forceJni ->
+                sql """set force_jni_scanner = ${forceJni}"""
+                String reader = forceJni ? "jni" : "native"
+                "order_qt_${tag}_${reader}" """
+                    SELECT ${columns} FROM ${tableName} ${orderBy}
+                """
+            }
+            def sparkRows = spark_paimon """
+                SELECT ${columns} FROM paimon.${dbName}.${tableName} ${orderBy}
+            """
+            sql """set force_jni_scanner = false"""
+            def dorisRows = sql """SELECT ${columns} FROM ${tableName} 
${orderBy}"""
+            assertSparkDorisResultEquals(sparkRows, dorisRows)
+        }
+
+        // Start in MOW mode. Do not set write-only=true here: Paimon 
implements
+        // primary-key deletion vectors during lookup compaction, while 
write-only
+        // deliberately disables that compaction and leaves new level-0 files
+        // invisible to the DV-optimized reader until a dedicated compaction 
runs.
+        sql """INSERT INTO t_dv VALUES
+            (1, 'old-1', 10),
+            (2, 'delete-2', 20),
+            (3, 'delete-by-merge-3', 30)
+        """
+        spark_paimon_multi """
+            CALL paimon.sys.compact(
+                table => '${dbName}.t_dv',
+                compact_strategy => 'full');
+        """
+        sql """REFRESH CATALOG ${catalogName}"""
+        sql """USE ${dbName}"""
+        sql """INSERT INTO t_dv VALUES
+            (1, 'upsert-1', 11),
+            (4, 'insert-4', 40)
+        """
+        sql """DELETE FROM t_dv WHERE id = 2"""
+        boolean dvProducedBeforeCompact = deletionVectorEntries("t_dv") > 0L
+
+        sql """INSERT INTO internal.${dbName}.dv_source VALUES
+            (1, 'merged-1', 12, 'U'),
+            (3, 'unused-3', 0, 'D'),
+            (5, 'inserted-5', 50, 'I')
+        """
+        sql """
+            MERGE INTO t_dv t
+            USING internal.${dbName}.dv_source s ON t.id = s.id
+            WHEN MATCHED AND s.action = 'D' THEN DELETE
+            WHEN MATCHED THEN UPDATE SET payload = s.payload, score = s.score
+            WHEN NOT MATCHED THEN INSERT (id, payload, score)
+                VALUES (s.id, s.payload, s.score)
+        """
+        assertReaders("dv_before_compact", "t_dv", "id, payload, score", 
"ORDER BY id")
+        long dvEntriesBeforeCompact = deletionVectorEntries("t_dv")
+
+        // Full compaction must preserve the logical rows while materializing 
at
+        // least part of the accumulated deletion-vector state.
+        spark_paimon_multi """
+            CALL paimon.sys.compact(
+                table => '${dbName}.t_dv',
+                compact_strategy => 'full'
+            );
+        """
+        sql """refresh table t_dv"""
+        assertReaders("dv_after_compact", "t_dv", "id, payload, score", "ORDER 
BY id")
+        assertTrue(deletionVectorEntries("t_dv") <= dvEntriesBeforeCompact,
+                "Full compaction must not increase retained deletion-vector 
entries")
+
+        // A writer opened after compaction must restore the current index and
+        // continue to hide the previous physical row for the same key.
+        sql """INSERT INTO t_dv VALUES
+            (1, 'post-compact-1', 13),
+            (6, 'post-compact-6', 60)
+        """
+        assertReaders("dv_post_compact_write", "t_dv", "id, payload, score", 
"ORDER BY id")
+
+        // P08/P11 transition: enable MOW after MOR files already exist. The
+        // next Doris statement must reload the changed table options.
+        sql """INSERT INTO t_enable_dv VALUES
+            (1, 'mor-old-1'),
+            (2, 'mor-delete-2')
+        """
+        spark_paimon_multi """
+            CALL paimon.sys.compact(
+                table => '${dbName}.t_enable_dv',
+                compact_strategy => 'full');
+            ALTER TABLE paimon.${dbName}.t_enable_dv SET TBLPROPERTIES (
+                'deletion-vectors.enabled' = 'true')
+        """
+        sql """refresh catalog ${catalogName}"""
+        sql """use ${dbName}"""
+        sql """INSERT INTO t_enable_dv VALUES (1, 'mow-new-1')"""
+        sql """DELETE FROM t_enable_dv WHERE id = 2"""
+        // The first MOR-to-MOW lookup compaction may rewrite the old files 
instead
+        // of retaining a non-empty DV index, so validate the stable contract 
here:
+        // both readers must expose the converted update/delete result.
+        assertReaders("dv_enabled_after_mor", "t_enable_dv", "id, payload", 
"ORDER BY id")
+        assertTrue(dvProducedBeforeCompact,
+                "Doris UPDATE/DELETE must leave a physical deletion vector 
before compaction")
+    } finally {
+        sql """set force_jni_scanner = false"""
+        sql """drop catalog if exists ${catalogName}"""
+        sql """drop table if exists internal.${dbName}.dv_source"""
+    }
+}
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_edge_cases.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_edge_cases.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_edge_cases.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_edge_cases.groovy
diff --git 
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_external_paths.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_external_paths.groovy
new file mode 100644
index 00000000000..1ce16faf5ce
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_external_paths.groovy
@@ -0,0 +1,200 @@
+// 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_paimon_write_external_paths", "p0,external,paimon") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test.")
+        return
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String catalogName = "test_pw_external_paths_catalog"
+    String dbName = "test_pw_external_paths_db"
+    String pathRoot = "s3://warehouse/paimon-external-paths/${dbName}"
+    String filesTableSuffix = '$files'
+
+    spark_paimon_multi """
+        CREATE DATABASE IF NOT EXISTS paimon.${dbName};
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_round_robin;
+        CREATE TABLE paimon.${dbName}.t_round_robin (
+            pt STRING, id INT, payload STRING
+        ) USING paimon
+        PARTITIONED BY (pt)
+        TBLPROPERTIES (
+            'primary-key' = 'pt,id',
+            'bucket' = '1',
+            'write-only' = 'true',
+            'target-file-size' = '1 kb',
+            'data-file.external-paths' = 
'${pathRoot}/round-a,${pathRoot}/round-b',
+            'data-file.external-paths.strategy' = 'round-robin'
+        );
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_weight_robin;
+        CREATE TABLE paimon.${dbName}.t_weight_robin (
+            id INT, payload STRING
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '1',
+            'write-only' = 'true',
+            'target-file-size' = '1 kb',
+            'data-file.external-paths' = 
'${pathRoot}/weight-a,${pathRoot}/weight-b',
+            'data-file.external-paths.strategy' = 'weight-robin',
+            'data-file.external-paths.weights' = '1,1'
+        );
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_specific_fs;
+        CREATE TABLE paimon.${dbName}.t_specific_fs (
+            id INT, payload STRING
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '1',
+            'write-only' = 'true',
+            'data-file.external-paths' = 
'${pathRoot}/specific-a,${pathRoot}/specific-b',
+            'data-file.external-paths.strategy' = 'specific-fs',
+            'data-file.external-paths.specific-fs' = 's3'
+        );
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_none;
+        CREATE TABLE paimon.${dbName}.t_none (
+            id INT, payload STRING
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '1',
+            'write-only' = 'true',
+            'data-file.external-paths' = 
'${pathRoot}/unused-a,${pathRoot}/unused-b',
+            'data-file.external-paths.strategy' = 'none'
+        );
+    """
+
+    sql """DROP CATALOG IF EXISTS ${catalogName}"""
+    sql """
+        CREATE CATALOG ${catalogName} PROPERTIES (
+            'type' = 'paimon',
+            'paimon.catalog.type' = 'filesystem',
+            'warehouse' = 's3://warehouse/wh',
+            's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+            's3.access_key' = 'admin',
+            's3.secret_key' = 'password',
+            's3.path.style.access' = 'true'
+        )
+    """
+    sql """SWITCH ${catalogName}"""
+    sql """USE ${dbName}"""
+
+    try {
+        def dataFiles = { String tableName ->
+            String query = """
+                SELECT file_path
+                FROM paimon.${dbName}.`${tableName}${filesTableSuffix}`
+                ORDER BY file_path
+            """
+            return spark_paimon(query).collect { row -> row[0].toString() }
+        }
+        def assertDorisSparkRows = { String tag, String tableName,
+                                     String columns, String orderBy ->
+            def sparkRows = spark_paimon """
+                SELECT ${columns} FROM paimon.${dbName}.${tableName} ${orderBy}
+            """
+            "order_qt_${tag}" """
+                SELECT ${columns} FROM ${tableName} ${orderBy}
+            """
+            def dorisRows = sql """SELECT ${columns} FROM ${tableName} 
${orderBy}"""
+            assertSparkDorisResultEquals(sparkRows, dorisRows)
+        }
+
+        // Separate statements force separate writer lifecycles. The selected
+        // path must therefore be refreshed for every Doris commit.
+        sql """INSERT INTO t_round_robin VALUES ('p1', 1, 'one')"""
+        sql """INSERT INTO t_round_robin VALUES ('p1', 2, 'two')"""
+        sql """INSERT INTO t_round_robin VALUES ('p2', 3, 'three')"""
+        sql """INSERT INTO t_round_robin VALUES ('p2', 4, 'four')"""
+        sql """
+            INSERT INTO t_round_robin
+            SELECT 'p-bulk', CAST(number + 100 AS INT), repeat('x', 2048)
+            FROM numbers("number" = "16")
+        """
+        def oldRoundFiles = dataFiles("t_round_robin")
+        assertTrue(oldRoundFiles.any { it.startsWith("${pathRoot}/round-a/") })
+        assertTrue(oldRoundFiles.any { it.startsWith("${pathRoot}/round-b/") })
+        assertDorisSparkRows("external_round_robin_initial", "t_round_robin",
+                "pt, id, length(payload)", "ORDER BY pt, id")
+
+        spark_paimon """
+            ALTER TABLE paimon.${dbName}.t_round_robin SET TBLPROPERTIES (
+                'data-file.external-paths' = 
'${pathRoot}/round-c,${pathRoot}/round-d'
+            )
+        """
+        sql """REFRESH CATALOG ${catalogName}"""
+        sql """USE ${dbName}"""
+        sql """INSERT INTO t_round_robin VALUES ('p3', 5, 'five')"""
+        sql """INSERT INTO t_round_robin VALUES ('p3', 6, 'six')"""
+        sql """
+            INSERT INTO t_round_robin
+            SELECT 'p-new-bulk', CAST(number + 200 AS INT), repeat('y', 2048)
+            FROM numbers("number" = "16")
+        """
+        def changedRoundFiles = dataFiles("t_round_robin")
+        boolean oldRoundFilesRetained = 
changedRoundFiles.containsAll(oldRoundFiles)
+        def newRoundFiles = changedRoundFiles - oldRoundFiles
+        assertTrue(oldRoundFilesRetained)
+        assertFalse(newRoundFiles.isEmpty())
+        // Round-robin selection is scoped to a writer lifecycle, so a small
+        // number of independent Doris statements need not hit both paths. The
+        // stable contract is that every new file uses the refreshed path set.
+        assertTrue(newRoundFiles.every {
+            it.startsWith("${pathRoot}/round-c/") ||
+                    it.startsWith("${pathRoot}/round-d/")
+        })
+        assertDorisSparkRows("external_round_robin_changed", "t_round_robin",
+                "pt, id, length(payload)", "ORDER BY pt, id")
+
+        (1..6).each { id ->
+            sql """INSERT INTO t_weight_robin VALUES (${id}, 'weight-${id}')"""
+        }
+        def weightedFiles = dataFiles("t_weight_robin")
+        assertFalse(weightedFiles.isEmpty())
+        assertTrue(weightedFiles.every {
+            it.startsWith("${pathRoot}/weight-a/") ||
+                    it.startsWith("${pathRoot}/weight-b/")
+        })
+        assertDorisSparkRows("external_weight_robin", "t_weight_robin",
+                "id, payload", "ORDER BY id")
+
+        sql """INSERT INTO t_specific_fs VALUES (1, 'specific-1')"""
+        sql """INSERT INTO t_specific_fs VALUES (2, 'specific-2')"""
+        def specificFiles = dataFiles("t_specific_fs")
+        assertFalse(specificFiles.isEmpty())
+        assertTrue(specificFiles.every { 
it.startsWith("${pathRoot}/specific-") })
+        assertDorisSparkRows("external_specific_fs", "t_specific_fs",
+                "id, payload", "ORDER BY id")
+
+        sql """INSERT INTO t_none VALUES (1, 'default-path')"""
+        def defaultFiles = dataFiles("t_none")
+        assertFalse(defaultFiles.isEmpty())
+        assertTrue(defaultFiles.every { !it.startsWith(pathRoot) })
+        assertDorisSparkRows("external_default_path", "t_none",
+                "id, payload", "ORDER BY id")
+    } finally {
+        sql """DROP CATALOG IF EXISTS ${catalogName}"""
+    }
+}
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_failures.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_failures.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_failures.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_failures.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_key_dynamic_memory_negative.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_key_dynamic_memory_negative.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_key_dynamic_memory_negative.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_key_dynamic_memory_negative.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_merge_engine.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_merge_engine.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_merge_engine.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_merge_engine.groovy
diff --git 
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_merge_semantics.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_merge_semantics.groovy
new file mode 100644
index 00000000000..2fe05e130df
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_merge_semantics.groovy
@@ -0,0 +1,221 @@
+// 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_paimon_write_merge_semantics", "p0,external,paimon") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test.")
+        return
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String catalogName = "test_pw_merge_semantics_catalog"
+    String dbName = "test_pw_merge_semantics_db"
+
+    spark_paimon_multi """
+        CREATE DATABASE IF NOT EXISTS paimon.${dbName};
+        DROP TABLE IF EXISTS paimon.${dbName}.t_merge;
+        CREATE TABLE paimon.${dbName}.t_merge (
+            id INT,
+            score INT,
+            payload STRUCT<x: INT, y: STRING>,
+            status STRING,
+            required_value STRING NOT NULL
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '2',
+            'bucket-key' = 'id',
+            'num-sorted-run.compaction-trigger' = '100'
+        );
+    """
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        CREATE CATALOG ${catalogName} PROPERTIES (
+            'type' = 'paimon',
+            'paimon.catalog.type' = 'filesystem',
+            'warehouse' = 's3://warehouse/wh',
+            's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+            's3.access_key' = 'admin',
+            's3.secret_key' = 'password',
+            's3.path.style.access' = 'true'
+        )
+    """
+    sql """switch ${catalogName}"""
+    sql """use ${dbName}"""
+
+    sql """create database if not exists internal.${dbName}"""
+    sql """drop table if exists internal.${dbName}.merge_source"""
+    sql """
+        create table internal.${dbName}.merge_source (
+            id int,
+            delta int,
+            new_x int,
+            new_y string,
+            action string,
+            required_value string
+        ) distributed by hash(id) buckets 1
+        properties ('replication_num' = '1')
+    """
+
+    try {
+        def latestSnapshotId = {
+            def rows = spark_paimon """
+                SELECT max(snapshot_id)
+                FROM paimon.${dbName}.`t_merge\$snapshots`
+            """
+            return rows[0][0] == null ? 0L : rows[0][0].toString().toLong()
+        }
+        def activeFileCount = {
+            def rows = spark_paimon """
+                SELECT count(*) FROM paimon.${dbName}.`t_merge\$files`
+            """
+            return rows[0][0].toString().toLong()
+        }
+        def assertCrossEngine = {
+            def sparkRows = spark_paimon """
+                SELECT id, score, payload.x, payload.y, status, required_value
+                FROM paimon.${dbName}.t_merge ORDER BY id
+            """
+            def dorisRows = sql """
+                SELECT id, score, payload.x, payload.y, status, required_value
+                FROM t_merge ORDER BY id
+            """
+            assertSparkDorisResultEquals(sparkRows, dorisRows)
+        }
+
+        sql """INSERT INTO t_merge VALUES
+            (1, 10, named_struct('x', 1, 'y', 'base-1'), 'old', 'required-1'),
+            (2, 20, named_struct('x', 2, 'y', 'base-2'), 'old', 'required-2'),
+            (3, 30, named_struct('x', 3, 'y', 'base-3'), 'stable', 
'required-3')
+        """
+        sql """INSERT INTO internal.${dbName}.merge_source VALUES
+            (1, 5, 11, 'source-1', 'U', 'required-1-new'),
+            (2, 0, 22, 'source-2', 'D', 'required-2-new'),
+            (4, 40, 44, 'source-4', 'I', 'required-4')
+        """
+
+        long beforeMerge = latestSnapshotId()
+        sql """
+            MERGE INTO t_merge t
+            USING internal.${dbName}.merge_source s
+            ON t.id = s.id
+            WHEN MATCHED AND s.action = 'U' THEN UPDATE SET
+                score = t.score + s.delta,
+                payload = named_struct(
+                    'x', s.new_x,
+                    'y', concat(t.payload.y, '-', s.new_y)),
+                status = 'updated',
+                required_value = s.required_value
+            WHEN MATCHED THEN DELETE
+            WHEN NOT MATCHED AND s.action = 'I' THEN INSERT
+                (required_value, status, payload, score, id)
+                VALUES (
+                    s.required_value,
+                    'inserted',
+                    named_struct('x', s.new_x, 'y', s.new_y),
+                    s.delta,
+                    s.id)
+        """
+        assertEquals(beforeMerge + 1L, latestSnapshotId())
+        order_qt_merge_semantics_result """
+            SELECT id, score, payload.x, payload.y, status, required_value
+            FROM t_merge ORDER BY id
+        """
+        assertCrossEngine()
+
+        // An empty source is a true no-op: it must not publish an empty Paimon
+        // snapshot or alter the active file set.
+        sql """TRUNCATE TABLE internal.${dbName}.merge_source"""
+        long beforeEmptySnapshot = latestSnapshotId()
+        long beforeEmptyFiles = activeFileCount()
+        sql """
+            MERGE INTO t_merge t
+            USING internal.${dbName}.merge_source s
+            ON t.id = s.id
+            WHEN MATCHED THEN UPDATE SET score = t.score + s.delta
+            WHEN NOT MATCHED THEN INSERT
+                (id, score, payload, status, required_value)
+                VALUES (s.id, s.delta,
+                    named_struct('x', s.new_x, 'y', s.new_y),
+                    'inserted', s.required_value)
+        """
+        assertEquals(beforeEmptySnapshot, latestSnapshotId())
+        assertEquals(beforeEmptyFiles, activeFileCount())
+
+        // P09 failure atomicity: a forbidden key update and a missing required
+        // insert column both fail before a snapshot or file becomes visible.
+        sql """INSERT INTO internal.${dbName}.merge_source VALUES
+            (1, 1, 100, 'invalid-key-update', 'U', 'still-required')
+        """
+        long beforeFailureSnapshot = latestSnapshotId()
+        long beforeFailureFiles = activeFileCount()
+        test {
+            sql """
+                MERGE INTO t_merge t
+                USING internal.${dbName}.merge_source s ON t.id = s.id
+                WHEN MATCHED THEN UPDATE SET id = s.id + 100
+            """
+            exception "primary-key"
+        }
+        assertEquals(beforeFailureSnapshot, latestSnapshotId())
+        assertEquals(beforeFailureFiles, activeFileCount())
+
+        sql """TRUNCATE TABLE internal.${dbName}.merge_source"""
+        sql """INSERT INTO internal.${dbName}.merge_source VALUES
+            (9, 9, 9, 'missing-required', 'I', NULL)
+        """
+        test {
+            sql """
+                MERGE INTO t_merge t
+                USING internal.${dbName}.merge_source s ON t.id = s.id
+                WHEN NOT MATCHED THEN INSERT (id, score, payload, status)
+                    VALUES (s.id, s.delta,
+                        named_struct('x', s.new_x, 'y', s.new_y), 'invalid')
+            """
+            exception "requires values for every table column"
+        }
+        assertEquals(beforeFailureSnapshot, latestSnapshotId())
+        assertEquals(beforeFailureFiles, activeFileCount())
+
+        // A legal MERGE after both failures proves that no stale writer or
+        // transaction state poisoned the table.
+        sql """TRUNCATE TABLE internal.${dbName}.merge_source"""
+        sql """INSERT INTO internal.${dbName}.merge_source VALUES
+            (5, 50, 55, 'recovery', 'I', 'required-5')
+        """
+        sql """
+            MERGE INTO t_merge t
+            USING internal.${dbName}.merge_source s ON t.id = s.id
+            WHEN NOT MATCHED THEN INSERT
+                (id, score, payload, status, required_value)
+                VALUES (s.id, s.delta,
+                    named_struct('x', s.new_x, 'y', s.new_y),
+                    'recovered', s.required_value)
+        """
+        assertEquals(beforeFailureSnapshot + 1L, latestSnapshotId())
+        order_qt_merge_semantics_recovered """
+            SELECT id, status FROM t_merge WHERE id = 5 ORDER BY id
+        """
+        assertCrossEngine()
+    } finally {
+        sql """drop catalog if exists ${catalogName}"""
+        sql """drop table if exists internal.${dbName}.merge_source"""
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_partition_delete.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_partition_delete.groovy
new file mode 100644
index 00000000000..3334a6cf005
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_partition_delete.groovy
@@ -0,0 +1,150 @@
+// 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_paimon_write_partition_delete", "p0,external,paimon") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test.")
+        return
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String catalogName = "test_pw_partition_delete_catalog"
+    String dbName = "test_pw_partition_delete_db"
+    String snapshotsTableSuffix = '$snapshots'
+
+    spark_paimon_multi """
+        CREATE DATABASE IF NOT EXISTS paimon.${dbName};
+        DROP TABLE IF EXISTS paimon.${dbName}.t_partition_delete;
+        CREATE TABLE paimon.${dbName}.t_partition_delete (
+            pt STRING, id INT, score INT, payload STRING
+        ) USING paimon
+        PARTITIONED BY (pt)
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '-1',
+            'dynamic-bucket.target-row-num' = '2',
+            'write-only' = 'true'
+        );
+    """
+
+    sql """DROP CATALOG IF EXISTS ${catalogName}"""
+    sql """
+        CREATE CATALOG ${catalogName} PROPERTIES (
+            'type' = 'paimon',
+            'paimon.catalog.type' = 'filesystem',
+            'warehouse' = 's3://warehouse/wh',
+            's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+            's3.access_key' = 'admin',
+            's3.secret_key' = 'password',
+            's3.path.style.access' = 'true'
+        )
+    """
+    sql """SWITCH ${catalogName}"""
+    sql """USE ${dbName}"""
+
+    try {
+        def latestSnapshot = {
+            String query = """
+                SELECT snapshot_id, commit_kind
+                FROM 
paimon.${dbName}.`t_partition_delete${snapshotsTableSuffix}`
+                ORDER BY snapshot_id DESC LIMIT 1
+            """
+            def rows = spark_paimon(query)
+            return rows.isEmpty() ? [0L, null] : [
+                    rows[0][0].toString().toLong(), 
rows[0][1].toString().toUpperCase()]
+        }
+        def assertRows = { String tag ->
+            "order_qt_${tag}" """
+                SELECT pt, id, score, payload
+                FROM t_partition_delete ORDER BY id
+            """
+            def dorisRows = sql """
+                SELECT pt, id, score, payload
+                FROM t_partition_delete ORDER BY id
+            """
+            def sparkRows = spark_paimon """
+                SELECT pt, id, score, payload
+                FROM paimon.${dbName}.t_partition_delete ORDER BY id
+            """
+            assertSparkDorisResultEquals(sparkRows, dorisRows)
+        }
+        def assertDeleteCommit = { List<Object> before ->
+            def after = latestSnapshot()
+            assertEquals(before[0] + 1L, after[0])
+            assertEquals("APPEND", after[1])
+            return after
+        }
+
+        sql """INSERT INTO t_partition_delete VALUES
+            ('p1', 1, 10, 'p1-a'),
+            ('p1', 2, 20, 'p1-b'),
+            ('p2', 3, 30, 'p2-a'),
+            ('p2', 4, 40, 'p2-b'),
+            ('p3', 5, 50, 'p3-a'),
+            (NULL, 6, 60, 'default-partition')
+        """
+
+        // A predicate covering the complete partition must not affect any
+        // other partition, including the default partition represented by 
NULL.
+        def snapshot = latestSnapshot()
+        sql """DELETE FROM t_partition_delete WHERE pt = 'p1'"""
+        snapshot = assertDeleteCommit(snapshot)
+        assertRows("partition_delete_full_partition")
+
+        // A partial-partition predicate is evaluated row by row.
+        sql """DELETE FROM t_partition_delete WHERE pt = 'p2' AND score >= 
40"""
+        snapshot = assertDeleteCommit(snapshot)
+        assertRows("partition_delete_partial_partition")
+
+        // A non-convertible partition expression must retain its exact SQL
+        // semantics instead of expanding into a full-partition delete.
+        sql """DELETE FROM t_partition_delete WHERE upper(pt) = 'P2' AND id = 
3"""
+        snapshot = assertDeleteCommit(snapshot)
+        assertRows("partition_delete_expression")
+
+        // UNKNOWN predicates match no rows and must not create empty commits.
+        sql """DELETE FROM t_partition_delete WHERE pt = NULL"""
+        assertEquals(snapshot, latestSnapshot())
+        sql """DELETE FROM t_partition_delete WHERE pt NOT IN ('p3', NULL)"""
+        assertEquals(snapshot, latestSnapshot())
+        sql """DELETE FROM t_partition_delete WHERE EXISTS (SELECT 1 WHERE 
FALSE)"""
+        assertEquals(snapshot, latestSnapshot())
+
+        // NOT EXISTS is true here and is combined with a target predicate so
+        // only the intended row is removed.
+        sql """
+            DELETE FROM t_partition_delete
+            WHERE id = 5 AND NOT EXISTS (SELECT 1 WHERE FALSE)
+        """
+        snapshot = assertDeleteCommit(snapshot)
+        assertRows("partition_delete_not_exists")
+
+        // IS NULL addresses the default partition explicitly.
+        sql """DELETE FROM t_partition_delete WHERE pt IS NULL"""
+        snapshot = assertDeleteCommit(snapshot)
+        assertRows("partition_delete_default_partition")
+
+        // A second no-match delete proves the empty-table path also avoids a
+        // metadata-only snapshot.
+        sql """DELETE FROM t_partition_delete WHERE id = 999"""
+        assertEquals(snapshot, latestSnapshot())
+    } finally {
+        sql """DROP CATALOG IF EXISTS ${catalogName}"""
+    }
+}
diff --git a/regression-test/suites/paimon_write/test_paimon_write_pk.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_pk.groovy
similarity index 100%
rename from regression-test/suites/paimon_write/test_paimon_write_pk.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_pk.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_row_level_dml.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_row_level_dml.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_row_level_dml.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_row_level_dml.groovy
diff --git 
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_row_tracking_evolution.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_row_tracking_evolution.groovy
new file mode 100644
index 00000000000..cdea849f37a
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_row_tracking_evolution.groovy
@@ -0,0 +1,216 @@
+// 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_paimon_write_row_tracking_evolution", "p0,external,paimon") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test.")
+        return
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String catalogName = "test_pw_row_tracking_catalog"
+    String dbName = "test_pw_row_tracking_db"
+
+    spark_paimon_multi """
+        CREATE DATABASE IF NOT EXISTS paimon.${dbName};
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_tracking;
+        CREATE TABLE paimon.${dbName}.t_tracking (
+            id INT, payload STRING
+        ) USING paimon
+        TBLPROPERTIES (
+            'bucket' = '-1',
+            'row-tracking.enabled' = 'true',
+            'compaction.min.file-num' = '2'
+        );
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_evolution;
+        CREATE TABLE paimon.${dbName}.t_evolution (
+            id INT, b INT, c INT
+        ) USING paimon
+        TBLPROPERTIES (
+            'bucket' = '-1',
+            'row-tracking.enabled' = 'true',
+            'data-evolution.enabled' = 'true'
+        );
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_evolution_source;
+        CREATE TABLE paimon.${dbName}.t_evolution_source (
+            id INT, b INT, c INT
+        ) USING paimon;
+    """
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        CREATE CATALOG ${catalogName} PROPERTIES (
+            'type' = 'paimon',
+            'paimon.catalog.type' = 'filesystem',
+            'warehouse' = 's3://warehouse/wh',
+            's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+            's3.access_key' = 'admin',
+            's3.secret_key' = 'password',
+            's3.path.style.access' = 'true'
+        )
+    """
+    sql """switch ${catalogName}"""
+    sql """use ${dbName}"""
+
+    try {
+        def trackingRows = { String tableName ->
+            return sql("""
+                SELECT id, _ROW_ID, _SEQUENCE_NUMBER
+                FROM `${tableName}\$row_tracking`
+                ORDER BY id
+            """)
+        }
+        def latestSnapshotId = { String tableName ->
+            def rows = spark_paimon """
+                SELECT max(snapshot_id)
+                FROM paimon.${dbName}.`${tableName}\$snapshots`
+            """
+            return rows[0][0] == null ? 0L : rows[0][0].toString().toLong()
+        }
+
+        // Doris assigns row ids through the Paimon committer. Their exact
+        // values are not assumed, but they must be unique and stable.
+        sql """INSERT INTO t_tracking VALUES
+            (1, 'one'), (2, 'two'), (3, 'three')
+        """
+        def initialTracking = trackingRows("t_tracking")
+        assertEquals(3, initialTracking.size())
+        assertEquals(3, initialTracking.collect { it[1] }.toSet().size())
+        Map<Integer, Long> initialRowIds = initialTracking.collectEntries { 
row ->
+            [(row[0].toString().toInteger()): row[1].toString().toLong()]
+        }
+        Map<Integer, Long> initialSequences = initialTracking.collectEntries { 
row ->
+            [(row[0].toString().toInteger()): row[2].toString().toLong()]
+        }
+
+        // Spark performs row-level changes which Doris does not expose for an
+        // append table. This verifies that rows originally written by Doris 
have
+        // valid tracking metadata for every upstream operation.
+        spark_paimon_multi """
+            UPDATE paimon.${dbName}.t_tracking
+                SET payload = 'two-updated' WHERE id = 2;
+            DELETE FROM paimon.${dbName}.t_tracking WHERE id = 3;
+            MERGE INTO paimon.${dbName}.t_tracking t
+            USING (SELECT 1 AS id, 'one-merged' AS payload
+                   UNION ALL
+                   SELECT 4 AS id, 'four' AS payload) s
+            ON t.id = s.id
+            WHEN MATCHED THEN UPDATE SET payload = s.payload
+            WHEN NOT MATCHED THEN INSERT (id, payload) VALUES (s.id, 
s.payload);
+        """
+        sql """refresh table t_tracking"""
+        order_qt_row_tracking_after_spark_changes """
+            SELECT id, payload FROM t_tracking ORDER BY id
+        """
+        def changedTracking = trackingRows("t_tracking")
+        Map<Integer, Long> changedRowIds = changedTracking.collectEntries { 
row ->
+            [(row[0].toString().toInteger()): row[1].toString().toLong()]
+        }
+        Map<Integer, Long> changedSequences = changedTracking.collectEntries { 
row ->
+            [(row[0].toString().toInteger()): row[2].toString().toLong()]
+        }
+        assertEquals(initialRowIds[1], changedRowIds[1])
+        assertEquals(initialRowIds[2], changedRowIds[2])
+        assertFalse(initialRowIds.values().contains(changedRowIds[4]))
+        assertTrue(changedSequences[1] > initialSequences[1])
+        assertTrue(changedSequences[2] > initialSequences[2])
+
+        spark_paimon """
+            CALL paimon.sys.compact(
+                table => '${dbName}.t_tracking',
+                compact_strategy => 'full')
+        """
+        sql """refresh table t_tracking"""
+        def compactedTracking = trackingRows("t_tracking")
+        Map<Integer, Long> compactedRowIds = compactedTracking.collectEntries 
{ row ->
+            [(row[0].toString().toInteger()): row[1].toString().toLong()]
+        }
+        assertEquals(changedRowIds, compactedRowIds)
+
+        sql """INSERT INTO t_tracking VALUES (5, 'five-after-compact')"""
+        order_qt_row_tracking_after_compact_write """
+            SELECT id, payload FROM t_tracking ORDER BY id
+        """
+        def afterDorisReopen = trackingRows("t_tracking")
+        assertEquals(4, afterDorisReopen.collect { it[1] }.toSet().size())
+        assertFalse(compactedRowIds.values().contains(
+                afterDorisReopen.find { it[0].toString().toInteger() == 5 }[1]
+                        .toString().toLong()))
+
+        // Data evolution accepts Doris full and partial INSERTs. Spark MERGE
+        // then updates only selected columns and keeps the original row ids.
+        sql """INSERT INTO t_evolution VALUES (1, 10, 100), (2, 20, 200)"""
+        sql """INSERT INTO t_evolution (id, b) VALUES (3, 30)"""
+        def evolutionBefore = trackingRows("t_evolution")
+        Map<Integer, Long> evolutionRowIds = evolutionBefore.collectEntries { 
row ->
+            [(row[0].toString().toInteger()): row[1].toString().toLong()]
+        }
+        spark_paimon_multi """
+            INSERT INTO paimon.${dbName}.t_evolution_source VALUES
+                (1, 11, 111), (2, 22, 222), (4, 44, 444);
+            MERGE INTO paimon.${dbName}.t_evolution t
+            USING paimon.${dbName}.t_evolution_source s
+            ON t.id = s.id
+            WHEN MATCHED THEN UPDATE SET b = s.b
+            WHEN NOT MATCHED THEN INSERT (id, b, c)
+                VALUES (s.id, s.b, s.c);
+        """
+        sql """refresh table t_evolution"""
+        order_qt_data_evolution_after_spark_merge """
+            SELECT * FROM t_evolution ORDER BY id
+        """
+        def evolutionAfter = trackingRows("t_evolution")
+        Map<Integer, Long> evolutionAfterIds = evolutionAfter.collectEntries { 
row ->
+            [(row[0].toString().toInteger()): row[1].toString().toLong()]
+        }
+        assertEquals(evolutionRowIds[1], evolutionAfterIds[1])
+        assertEquals(evolutionRowIds[2], evolutionAfterIds[2])
+        assertEquals(evolutionRowIds[3], evolutionAfterIds[3])
+        assertFalse(evolutionRowIds.values().contains(evolutionAfterIds[4]))
+
+        // Paimon 1.4.2 does not support ordinary UPDATE/DELETE on a data
+        // evolution table. Doris currently rejects them at its append-table
+        // boundary; either way no Paimon snapshot may be committed.
+        long evolutionSnapshot = latestSnapshotId("t_evolution")
+        test {
+            sql """UPDATE t_evolution SET b = 999 WHERE id = 1"""
+            exception "primary-key table"
+        }
+        assertEquals(evolutionSnapshot, latestSnapshotId("t_evolution"))
+        test {
+            sql """DELETE FROM t_evolution WHERE id = 1"""
+            exception "primary-key table"
+        }
+        assertEquals(evolutionSnapshot, latestSnapshotId("t_evolution"))
+        test {
+            sql """
+                MERGE INTO t_evolution t
+                USING (SELECT 1 AS id, 999 AS b) s ON t.id = s.id
+                WHEN MATCHED THEN UPDATE SET b = s.b
+            """
+            exception "primary-key table"
+        }
+        assertEquals(evolutionSnapshot, latestSnapshotId("t_evolution"))
+    } finally {
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_schema_change.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_schema_change.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_schema_change.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_schema_change.groovy
diff --git 
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_sequence_group.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_sequence_group.groovy
new file mode 100644
index 00000000000..1da8ddca25a
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_sequence_group.groovy
@@ -0,0 +1,227 @@
+// 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_paimon_write_sequence_group", "p0,external,paimon") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test.")
+        return
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String catalogName = "test_pw_sequence_group_catalog"
+    String dbName = "test_pw_sequence_group_db"
+
+    spark_paimon_multi """
+        CREATE DATABASE IF NOT EXISTS paimon.${dbName};
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_multi_group;
+        CREATE TABLE paimon.${dbName}.t_multi_group (
+            id INT,
+            profile_name STRING,
+            profile_city STRING,
+            profile_seq INT,
+            total BIGINT,
+            peak INT,
+            metric_seq INT,
+            note STRING
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '1',
+            'merge-engine' = 'partial-update',
+            'fields.profile_seq.sequence-group' = 'profile_name,profile_city',
+            'fields.metric_seq.sequence-group' = 'total,peak',
+            'fields.total.aggregate-function' = 'sum',
+            'fields.peak.aggregate-function' = 'max',
+            'fields.note.aggregate-function' = 'last_non_null_value'
+        );
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_remove_on_delete;
+        CREATE TABLE paimon.${dbName}.t_remove_on_delete (
+            id INT, a STRING, b STRING
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '1',
+            'merge-engine' = 'partial-update',
+            'partial-update.remove-record-on-delete' = 'true'
+        );
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_group_remove_on_delete;
+        CREATE TABLE paimon.${dbName}.t_group_remove_on_delete (
+            id INT,
+            a STRING,
+            seq_a INT,
+            b STRING,
+            seq_b INT
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '1',
+            'merge-engine' = 'partial-update',
+            'fields.seq_a.sequence-group' = 'a',
+            'fields.seq_b.sequence-group' = 'b',
+            'partial-update.remove-record-on-sequence-group' = 'seq_a'
+        );
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_property_change;
+        CREATE TABLE paimon.${dbName}.t_property_change (
+            id INT, a STRING, seq INT
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '1',
+            'merge-engine' = 'partial-update'
+        );
+    """
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        CREATE CATALOG ${catalogName} PROPERTIES (
+            'type' = 'paimon',
+            'paimon.catalog.type' = 'filesystem',
+            'warehouse' = 's3://warehouse/wh',
+            's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+            's3.access_key' = 'admin',
+            's3.secret_key' = 'password',
+            's3.path.style.access' = 'true'
+        )
+    """
+    sql """switch ${catalogName}"""
+    sql """use ${dbName}"""
+
+    try {
+        def assertSparkEquals = { String tableName, String columns, String 
orderBy ->
+            def sparkRows = spark_paimon """
+                SELECT ${columns} FROM paimon.${dbName}.${tableName} ${orderBy}
+            """
+            def dorisRows = sql """SELECT ${columns} FROM ${tableName} 
${orderBy}"""
+            assertSparkDorisResultEquals(sparkRows, dorisRows)
+        }
+
+        // P01: Each sequence group advances independently. Aggregations only
+        // consume a row when the sequence of their own group is accepted.
+        sql """INSERT INTO t_multi_group VALUES
+            (1, 'alice', 'shanghai', 10, 5, 80, 10, 'base')
+        """
+        sql """INSERT INTO t_multi_group VALUES
+            (1, 'stale-profile', 'beijing', 9, 7, 90, 11, NULL)
+        """
+        order_qt_sequence_group_stale_profile """
+            SELECT * FROM t_multi_group ORDER BY id
+        """
+
+        sql """INSERT INTO t_multi_group VALUES
+            (1, 'alice-new', 'shenzhen', 12, 100, 99, 10, 'new-note')
+        """
+        order_qt_sequence_group_new_profile """
+            SELECT * FROM t_multi_group ORDER BY id
+        """
+
+        // A NULL sequence does not advance its group. A different group in the
+        // same row can still advance and apply its aggregate functions.
+        sql """INSERT INTO t_multi_group VALUES
+            (1, 'null-sequence', 'hangzhou', NULL, 3, 88, 13, NULL)
+        """
+        order_qt_sequence_group_null_sequence """
+            SELECT * FROM t_multi_group ORDER BY id
+        """
+        assertSparkEquals("t_multi_group", "*", "ORDER BY id")
+
+        // P02: remove-record-on-delete must discard the old partial row. A
+        // later partial insert creates a new row and must not revive old 
fields.
+        sql """INSERT INTO t_remove_on_delete VALUES (1, 'old-a', 'old-b')"""
+        sql """DELETE FROM t_remove_on_delete WHERE id = 1"""
+        qt_remove_on_delete_empty """SELECT count(*) FROM t_remove_on_delete"""
+        sql """INSERT INTO t_remove_on_delete (id, b) VALUES (1, 'new-b')"""
+        order_qt_remove_on_delete_partial """
+            SELECT id, a, b FROM t_remove_on_delete ORDER BY id
+        """
+        sql """INSERT INTO t_remove_on_delete (id, a) VALUES (1, 'new-a')"""
+        order_qt_remove_on_delete_complete """
+            SELECT id, a, b FROM t_remove_on_delete ORDER BY id
+        """
+        assertSparkEquals("t_remove_on_delete", "*", "ORDER BY id")
+
+        // Paimon makes remove-record-on-delete and sequence groups mutually
+        // exclusive. Doris must reject a whole-row DELETE on this legal
+        // sequence-group configuration without changing its accumulated row.
+        sql """INSERT INTO t_group_remove_on_delete VALUES
+            (1, 'old-a', 100, 'old-b', 100)
+        """
+        test {
+            sql """DELETE FROM t_group_remove_on_delete WHERE id = 1"""
+            exception "partial-update.remove-record-on-delete=true"
+        }
+        order_qt_group_remove_on_delete_unchanged """
+            SELECT * FROM t_group_remove_on_delete ORDER BY id
+        """
+        sql """INSERT INTO t_group_remove_on_delete VALUES
+            (1, 'low-a', 1, 'low-b', 1)
+        """
+        sql """INSERT INTO t_group_remove_on_delete (id, a, seq_a) VALUES
+            (1, 'high-a', 101)
+        """
+        order_qt_group_remove_on_delete_sequence """
+            SELECT * FROM t_group_remove_on_delete ORDER BY id
+        """
+        assertSparkEquals("t_group_remove_on_delete", "*", "ORDER BY id")
+
+        // P03: Invalid writer properties fail as metadata changes and must not
+        // poison the last valid schema. A legal sequence group takes effect 
for
+        // the next writer without recreating the catalog.
+        sql """INSERT INTO t_property_change VALUES (1, 'base', 10)"""
+        long propertySnapshot = (sql """
+            SELECT max(snapshot_id) FROM t_property_change\$snapshots
+        """)[0][0] as long
+        String invalidPropertyError = null
+        try {
+            spark_paimon """
+                ALTER TABLE paimon.${dbName}.t_property_change SET 
TBLPROPERTIES (
+                    'fields.missing.sequence-group' = 'a')
+            """
+        } catch (Exception e) {
+            invalidPropertyError = e.getMessage()
+        }
+        assertNotNull(invalidPropertyError)
+        assertTrue(invalidPropertyError.toLowerCase().contains("missing"))
+        assertEquals(propertySnapshot, (sql """
+            SELECT max(snapshot_id) FROM t_property_change\$snapshots
+        """)[0][0] as long)
+        sql """INSERT INTO t_property_change (id, a) VALUES (1, 
'still-writable')"""
+        order_qt_property_change_still_writable """
+            SELECT * FROM t_property_change ORDER BY id
+        """
+
+        spark_paimon """
+            ALTER TABLE paimon.${dbName}.t_property_change SET TBLPROPERTIES (
+                'fields.seq.sequence-group' = 'a')
+        """
+        sql """REFRESH CATALOG ${catalogName}"""
+        sql """USE ${dbName}"""
+        sql """INSERT INTO t_property_change VALUES (1, 'high', 20)"""
+        sql """INSERT INTO t_property_change VALUES (1, 'low-must-lose', 15)"""
+        order_qt_property_change_sequence_group """
+            SELECT * FROM t_property_change ORDER BY id
+        """
+        assertSparkEquals("t_property_change", "*", "ORDER BY id")
+    } finally {
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_sequence_rowkind.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_sequence_rowkind.groovy
new file mode 100644
index 00000000000..b33c981e716
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_sequence_rowkind.groovy
@@ -0,0 +1,200 @@
+// 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_paimon_write_sequence_rowkind", "p0,external,paimon") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test.")
+        return
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String catalogName = "test_pw_sequence_rowkind_catalog"
+    String dbName = "test_pw_sequence_rowkind_db"
+
+    spark_paimon_multi """
+        CREATE DATABASE IF NOT EXISTS paimon.${dbName};
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_sequence_asc;
+        CREATE TABLE paimon.${dbName}.t_sequence_asc (
+            id INT, seq1 INT, seq2 INT, payload STRING
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '2',
+            'sequence.field' = 'seq1,seq2',
+            'sequence.field.sort-order' = 'ascending'
+        );
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_sequence_desc;
+        CREATE TABLE paimon.${dbName}.t_sequence_desc (
+            id INT, seq INT, payload STRING
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '1',
+            'sequence.field' = 'seq',
+            'sequence.field.sort-order' = 'descending'
+        );
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_rowkind;
+        CREATE TABLE paimon.${dbName}.t_rowkind (
+            id INT, row_kind STRING, payload STRING
+        ) USING paimon
+        TBLPROPERTIES (
+            'primary-key' = 'id',
+            'bucket' = '1',
+            'rowkind.field' = 'row_kind',
+            'changelog-producer' = 'input'
+        );
+    """
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        CREATE CATALOG ${catalogName} PROPERTIES (
+            'type' = 'paimon',
+            'paimon.catalog.type' = 'filesystem',
+            'warehouse' = 's3://warehouse/wh',
+            's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+            's3.access_key' = 'admin',
+            's3.secret_key' = 'password',
+            's3.path.style.access' = 'true'
+        )
+    """
+    sql """switch ${catalogName}"""
+    sql """use ${dbName}"""
+
+    try {
+        def latestSnapshotId = { String tableName ->
+            def rows = spark_paimon """
+                SELECT max(snapshot_id)
+                FROM paimon.${dbName}.`${tableName}\$snapshots`
+            """
+            return rows[0][0] == null ? 0L : rows[0][0].toString().toLong()
+        }
+        def activeFileCount = { String tableName ->
+            def rows = spark_paimon """
+                SELECT count(*) FROM paimon.${dbName}.`${tableName}\$files`
+            """
+            return rows[0][0].toString().toLong()
+        }
+        def assertSparkEquals = { String tableName, String columns, String 
orderBy ->
+            def sparkRows = spark_paimon """
+                SELECT ${columns} FROM paimon.${dbName}.${tableName} ${orderBy}
+            """
+            def dorisRows = sql """SELECT ${columns} FROM ${tableName} 
${orderBy}"""
+            assertSparkDorisResultEquals(sparkRows, dorisRows)
+        }
+
+        // Multi-column ascending sequences compare lexicographically across
+        // Doris commits. A NULL tuple loses once a concrete sequence arrives.
+        sql """INSERT INTO t_sequence_asc VALUES
+            (1, 10, 20, 'base'),
+            (2, NULL, NULL, 'null-base')
+        """
+        sql """INSERT INTO t_sequence_asc VALUES
+            (1, 9, 99, 'stale-first-field'),
+            (2, 1, 0, 'nonnull-wins')
+        """
+        sql """INSERT INTO t_sequence_asc VALUES
+            (1, 10, 21, 'new-second-field'),
+            (2, NULL, NULL, 'null-must-not-return')
+        """
+        order_qt_sequence_ascending """
+            SELECT * FROM t_sequence_asc ORDER BY id
+        """
+
+        // Equal sequences fall back to input order. Keep the writer 
single-task
+        // so this oracle checks Paimon's tie rule instead of scheduler 
ordering.
+        sql """set parallel_pipeline_task_num = 1"""
+        sql """INSERT INTO t_sequence_asc VALUES
+            (3, 7, 7, 'first-equal'),
+            (3, 7, 7, 'second-equal')
+        """
+        order_qt_sequence_equal """
+            SELECT id, payload FROM t_sequence_asc WHERE id = 3 ORDER BY id
+        """
+        assertSparkEquals("t_sequence_asc", "*", "ORDER BY id")
+
+        // Descending order reverses priority: a smaller sequence supersedes 
the
+        // current row while a larger sequence is ignored.
+        sql """INSERT INTO t_sequence_desc VALUES (1, 10, 'base')"""
+        sql """INSERT INTO t_sequence_desc VALUES (1, 20, 'larger-is-stale')"""
+        sql """INSERT INTO t_sequence_desc VALUES (1, 5, 'smaller-wins')"""
+        order_qt_sequence_descending """
+            SELECT * FROM t_sequence_desc ORDER BY id
+        """
+        assertSparkEquals("t_sequence_desc", "*", "ORDER BY id")
+
+        // rowkind.field turns ordinary INSERT rows into an input changelog.
+        sql """INSERT INTO t_rowkind VALUES
+            (1, '+I', 'old-1'),
+            (2, '+I', 'old-2')
+        """
+        long rowkindBefore = latestSnapshotId("t_rowkind")
+        sql """INSERT INTO t_rowkind VALUES
+            (1, '+U', 'new-1'),
+            (2, '-D', 'old-2'),
+            (3, '+I', 'new-3')
+        """
+        long rowkindAfter = latestSnapshotId("t_rowkind")
+        order_qt_rowkind_changelog """
+            SELECT id, payload FROM t_rowkind ORDER BY id
+        """
+
+        def auditRows = spark_paimon """
+            SELECT rowkind, id, payload
+            FROM paimon_incremental_query(
+                'paimon.${dbName}.`t_rowkind\$audit_log`',
+                '${rowkindBefore}', '${rowkindAfter}')
+            ORDER BY id
+        """
+        assertEquals([
+                ["+U", 1, "new-1"],
+                ["-D", 2, "old-2"],
+                ["+I", 3, "new-3"]
+        ], auditRows)
+        assertSparkEquals("t_rowkind", "id, payload", "ORDER BY id")
+
+        // Invalid or omitted row kinds fail atomically. The following valid
+        // changelog record must still be accepted by a newly opened writer.
+        long beforeInvalidSnapshot = latestSnapshotId("t_rowkind")
+        long beforeInvalidFiles = activeFileCount("t_rowkind")
+        test {
+            sql """INSERT INTO t_rowkind VALUES (9, 'XX', 'invalid')"""
+            exception "row kind"
+        }
+        assertEquals(beforeInvalidSnapshot, latestSnapshotId("t_rowkind"))
+        assertEquals(beforeInvalidFiles, activeFileCount("t_rowkind"))
+
+        test {
+            sql """INSERT INTO t_rowkind VALUES (9, NULL, 'missing-kind')"""
+            exception "cannot be null"
+        }
+        assertEquals(beforeInvalidSnapshot, latestSnapshotId("t_rowkind"))
+        assertEquals(beforeInvalidFiles, activeFileCount("t_rowkind"))
+
+        sql """INSERT INTO t_rowkind VALUES (4, '+I', 'recovered')"""
+        order_qt_rowkind_recovered """
+            SELECT id, payload FROM t_rowkind WHERE id = 4 ORDER BY id
+        """
+        assertSparkEquals("t_rowkind", "id, payload", "ORDER BY id")
+    } finally {
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_snapshot_refs.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_snapshot_refs.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_snapshot_refs.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_snapshot_refs.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_source_models.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_source_models.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_source_models.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_source_models.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_thread_lifecycle.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_thread_lifecycle.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_thread_lifecycle.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_thread_lifecycle.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_transaction.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_transaction.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_transaction.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_transaction.groovy
diff --git a/regression-test/suites/paimon_write/test_paimon_write_types.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_types.groovy
similarity index 100%
rename from regression-test/suites/paimon_write/test_paimon_write_types.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_types.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_variant.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant.groovy
similarity index 100%
rename from regression-test/suites/paimon_write/test_paimon_write_variant.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_variant_dml.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant_dml.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_variant_dml.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant_dml.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_variant_errors.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant_errors.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_variant_errors.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant_errors.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_variant_nested.groovy 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant_nested.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_variant_nested.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant_nested.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_variant_shredding.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant_shredding.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_variant_shredding.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant_shredding.groovy
diff --git 
a/regression-test/suites/paimon_write/test_paimon_write_variant_table_modes.groovy
 
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant_table_modes.groovy
similarity index 100%
rename from 
regression-test/suites/paimon_write/test_paimon_write_variant_table_modes.groovy
rename to 
regression-test/suites/external_table_p0/paimon/write/test_paimon_write_variant_table_modes.groovy


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to