This is an automated email from the ASF dual-hosted git repository.
924060929 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 631ccd57525 [fix](fe) Skip insert after losing concurrent CTAS table
creation (#68849)
631ccd57525 is described below
commit 631ccd575252a1ef1a9cbcf7809ea00b397b6dcc
Author: 924060929 <[email protected]>
AuthorDate: Sat Oct 10 17:20:13 2026 +0800
[fix](fe) Skip insert after losing concurrent CTAS table creation (#68849)
### What problem does this PR solve?
Issue Number: N/A
Related PR: #35210, #53486
Problem Summary:
Two concurrent `CREATE TABLE IF NOT EXISTS ... AS SELECT` statements can
both pass the initial table-existence check. Only one registers its
table; the other receives the atomic registration result indicating that
the table already exists. However, `InternalCatalog.createOlapTable`
leaves its returned `tableHasExist` flag false, so both CTAS statements
continue into INSERT and append their rows to the same table. For
example, two concurrent `CREATE TABLE IF NOT EXISTS t AS SELECT 1 AS k`
statements can leave two rows instead of one.
History: #35210 introduced the existing-table return contract and the
CTAS INSERT skip, but initialized `tableHasExist` to false without
propagating the duplicate-registration result in the OLAP path. This
fixed the sequential existing-table case while leaving the concurrent
registration case unhandled. #53486 later carried the same omission into
the `CreateTableInfo` path used by the current implementation.
Propagate the existing-table result from
`Database.createTableWithoutLock` while holding the existing database
write lock. The losing creator releases its resources, then returns true
so the existing CTAS early-return branch skips INSERT. In cloud mode,
`afterCreatePartitions` has already committed the losing indexes and
removed their PREPARED recycle markers before registration. FE-only
tablet/colocate cleanup cannot recycle these committed indexes. Add a
catalog lifecycle hook after the database lock is released;
`CloudInternalCatalog` uses it to call `dropMaterializedIndex` for the
losing table and all its index IDs, including row-binlog indexes. Invoke
this for both IF NOT EXISTS and duplicate-error paths. Propagate cleanup
failures instead of reporting a successful no-op; existing FE cleanup
still runs and the winning table is preserved.
Scope: this PR changes FE behavior only and uses the existing
MetaService drop-index RPC. The follow-up table-version KV cleanup
extension has been reverted; there are no changes under `cloud/` or to
`gensrc/proto/cloud.proto` in the final PR diff. Orphaned table-version
KV discovery in the cloud recycler is a separate existing issue outside
this FE repair.
The new tests deterministically simulate a stale initial existence probe
against a real database, exercise the actual atomic registration and
cleanup, and check that the winner's table and tablet metadata survive.
They also cover sequential IF NOT EXISTS, the duplicate-registration
error without IF NOT EXISTS, cleanup outside the database lock,
preservation of the winner on cleanup failure, and the cloud drop RPC
IDs and error propagation.
### Release note
Fix duplicate rows when concurrent CREATE TABLE IF NOT EXISTS AS SELECT
statements target the same internal table, and recycle committed cloud
indexes allocated by the losing creator.
### Check List (For Author)
- Test
- [x] Unit Test
- [ ] Regression test
- [x] Manual test (add detailed scripts or steps below)
- Behavior changed:
- [ ] No.
- [x] Yes. A CTAS statement that loses concurrent table registration
skips INSERT as required by IF NOT EXISTS.
- Does this need documentation?
- [x] No.
- [ ] Yes.
Validation (JDK 17):
- After reverting the cloud table-version extension, the tracked source
tree exactly matches `0a85b29c053`. Re-ran all three listed FE test
classes on restored head `f24ad6c5c69`: 27 tests passed, 0
failures/errors/skips. FE Checkstyle and `git diff --check` also passed.
The final PR diff contains only four files under `fe/`, with no
MetaService, recycler, or protobuf changes.
- Cloud cleanup follow-up: `bash run-fe-ut.sh --run
org.apache.doris.catalog.CreateTableTest,org.apache.doris.nereids.trees.plans.commands.CreateTableCommandTest,org.apache.doris.cloud.datasource.CloudInternalCatalogTest`:
27 tests passed (0 failures/errors/skips). Tests cover the real
registration race, cleanup outside the database lock, both
duplicate-result paths, winner preservation and FE cleanup on failure,
and the exact cloud drop RPC IDs and error propagation.
- Cloud cleanup follow-up: FE Checkstyle passed with 0 violations; `git
diff --check` passed. The RPC boundary is mocked; a live
MetaService/recycler deployment has not been tested.
- `bash run-fe-ut.sh --run
'org.apache.doris.catalog.CreateTableTest,org.apache.doris.nereids.trees.plans.commands.CreateTableCommandTest'`:
23 tests passed before the cloud cleanup follow-up.
- Negative control: removing only the production fix makes
`testCreateTableReturnsExistingAfterRegistrationRace` fail with
`expected: <true> but was: <false>`; restoring the fix passes.
- `cd fe && mvn checkstyle:check -pl fe-core`: passed.
- `git diff --check`: passed.
Deployed validation of the original CTAS return-value fix on 2026-10-10
(before the cloud cleanup follow-up): apply the same production fix to
the exact affected 4.1 candidate source
(`7fda449b7afb09c266ce2c4fc738a4d5e46fc458`), build FE with `build.sh
--fe --clean`, and deploy it against the same four unmodified candidate
BEs.
- Fresh unpatched control: all 8 concurrent `AS SELECT 1` trials
reproduce duplicate inserts (2 callers leave 2 rows; 8 callers leave 8
rows).
- Patched FE: all 80 concurrent trials pass (400 CTAS requests across
2/8 callers and default/local-shuffle-disabled settings). Exactly one
caller inserts one row per table; all other callers affect zero rows.
- All 24 trials of the four original queries pass, including full result
multiset comparison, across default, local-shuffle-disabled and
single-pipeline settings. Only one CTAS caller inserts each query's
38/32/26/25 rows. Snapshot GLOBAL variables are applied with SET GLOBAL;
final replay has no setup or query errors.
- All 80 sequential existing-table CTAS checks remain no-ops.
- All 10 concurrent trials without IF NOT EXISTS produce one successful
creator and one duplicate-table error, preserve the winning data, and
allow subsequent ordinary INSERTs.
- Existing regression suites `test_create_table_if_not_exists_as_select`
and `nereids_test_ctas` pass through `run-regression-test.sh` (0 failed
suites, 0 fatal scripts).
- FE audit/registration logs confirm the losing creators skip INSERT.
The final FE and all four BEs are healthy.
This is a local shared-nothing cluster; x86 BEs run under QEMU on an
arm64 host. The original S3/cloud deployment, long-running stress, and
the full regression suite have not been retested. Please backport to
branch-4.1.
---
.../cloud/datasource/CloudInternalCatalog.java | 5 ++
.../apache/doris/datasource/InternalCatalog.java | 11 +++
.../org/apache/doris/catalog/CreateTableTest.java | 93 ++++++++++++++++++++++
.../cloud/datasource/CloudInternalCatalogTest.java | 45 +++++++++++
4 files changed, 154 insertions(+)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cloud/datasource/CloudInternalCatalog.java
b/fe/fe-core/src/main/java/org/apache/doris/cloud/datasource/CloudInternalCatalog.java
index 283a53b455b..95228b01c14 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/cloud/datasource/CloudInternalCatalog.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/cloud/datasource/CloudInternalCatalog.java
@@ -1300,6 +1300,11 @@ public class CloudInternalCatalog extends
InternalCatalog {
}
}
+ @Override
+ public void onCreateTableConflict(long dbId, OlapTable table) throws
DdlException {
+ dropMaterializedIndex(dbId, table.getId(), table.getIndexIdList(true),
true);
+ }
+
public void dropMaterializedIndex(long dbId, long tableId, List<Long>
indexIds, boolean dropTable)
throws DdlException {
if (Config.enable_check_compatibility_mode) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/InternalCatalog.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/InternalCatalog.java
index 7165ae6b505..da9dbeeed0b 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/InternalCatalog.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/InternalCatalog.java
@@ -2421,6 +2421,10 @@ public class InternalCatalog implements
CatalogIf<Database> {
return partition;
}
+ /** Release resources allocated for a table that lost the atomic
registration race. */
+ public void onCreateTableConflict(long dbId, OlapTable table) throws
DdlException {
+ }
+
public void beforeCreatePartitions(long dbId, long tableId, List<Long>
partitionIds, List<Long> indexIds,
boolean isCreateTable)
throws DdlException {
@@ -3396,6 +3400,8 @@ public class InternalCatalog implements
CatalogIf<Database> {
}
// register table, write create table edit log
result = db.createTableWithoutLock(olapTable, false,
createTableInfo.isIfNotExists());
+ // CTAS must skip INSERT when another creator registered the
table first.
+ tableHasExist = result.second;
if (!result.second) {
olapTable.writeLock();
holdTableLock = true;
@@ -3404,6 +3410,11 @@ public class InternalCatalog implements
CatalogIf<Database> {
db.writeUnlock();
}
try {
+ if (result.second) {
+ // Cloud indexes have already been committed. Recycle them
outside the DB lock,
+ // including when a CREATE without IF NOT EXISTS must
report a duplicate error.
+ onCreateTableConflict(db.getId(), olapTable);
+ }
if (!result.first) {
ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tableShowName);
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/catalog/CreateTableTest.java
b/fe/fe-core/src/test/java/org/apache/doris/catalog/CreateTableTest.java
index d0c05a31ccb..eb61be84189 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/catalog/CreateTableTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/CreateTableTest.java
@@ -24,7 +24,11 @@ import org.apache.doris.common.DdlException;
import org.apache.doris.common.ExceptionChecker;
import org.apache.doris.common.FeConstants;
import org.apache.doris.common.UserException;
+import org.apache.doris.datasource.InternalCatalog;
import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.parser.NereidsParser;
+import org.apache.doris.nereids.trees.plans.commands.CreateTableCommand;
+import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo;
import org.apache.doris.resource.Tag;
import org.apache.doris.thrift.TInvertedIndexFileStorageFormat;
import org.apache.doris.utframe.TestWithFeService;
@@ -32,7 +36,10 @@ import org.apache.doris.utframe.TestWithFeService;
import com.google.common.collect.Maps;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import java.util.Collections;
+import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@@ -80,6 +87,92 @@ public class CreateTableTest extends TestWithFeService {
}
}
+ @Test
+ public void testCreateTableReturnsExistingAfterRegistrationRace() throws
Exception {
+ String sql = "CREATE TABLE IF NOT EXISTS test.registration_race (k
INT) "
+ + "DISTRIBUTED BY HASH(k) BUCKETS 1
PROPERTIES('replication_num'='1')";
+ InternalCatalog catalog = Env.getCurrentInternalCatalog();
+ CreateTableInfo winner = analyzeCreateTable(sql);
+ Assertions.assertFalse(catalog.createTable(winner));
+
+ Database db = catalog.getDbOrDdlException("test");
+ Table table = db.getTableOrDdlException("registration_race");
+ Set<Long> tabletIds = new
HashSet<>(Env.getCurrentInvertedIndex().getTabletMetaMap().keySet());
+
+ // Both creators can observe absence before either registers its
table. Make only the
+ // loser's initial probe stale; atomic registration must still see the
winner's real table.
+ Database racingDb = Mockito.spy(db);
+
Mockito.doReturn(false).doCallRealMethod().when(racingDb).isTableExist("registration_race");
+ InternalCatalog racingCatalog = Mockito.spy(catalog);
+
Mockito.doReturn(racingDb).when(racingCatalog).getDbOrDdlException("test");
+ Mockito.doAnswer(invocation -> {
+ Assertions.assertFalse(db.isWriteLockHeldByCurrentThread());
+ OlapTable loser = invocation.getArgument(1);
+ Assertions.assertTrue(Collections.disjoint(((OlapTable)
table).getIndexIdList(true),
+ loser.getIndexIdList(true)));
+ return invocation.callRealMethod();
+ }).when(racingCatalog).onCreateTableConflict(Mockito.eq(db.getId()),
Mockito.any(OlapTable.class));
+
Assertions.assertTrue(racingCatalog.createTable(analyzeCreateTable(sql)));
+
Mockito.verify(racingCatalog).onCreateTableConflict(Mockito.eq(db.getId()),
+ Mockito.argThat(loser -> loser.getId() != table.getId()));
+ Assertions.assertSame(table,
db.getTableOrDdlException("registration_race"));
+ Assertions.assertEquals(tabletIds,
Env.getCurrentInvertedIndex().getTabletMetaMap().keySet());
+
+ // A sequential IF NOT EXISTS remains a no-op as well.
+ Assertions.assertTrue(catalog.createTable(analyzeCreateTable(sql)));
+ }
+
+ @Test
+ public void testCreateTableRegistrationRaceWithoutIfNotExists() throws
Exception {
+ String sql = "CREATE TABLE test.registration_race_error (k INT) "
+ + "DISTRIBUTED BY HASH(k) BUCKETS 1
PROPERTIES('replication_num'='1')";
+ createTable(sql);
+ InternalCatalog catalog = Env.getCurrentInternalCatalog();
+ Database db = catalog.getDbOrDdlException("test");
+ Table table = db.getTableOrDdlException("registration_race_error");
+ Set<Long> tabletIds = new
HashSet<>(Env.getCurrentInvertedIndex().getTabletMetaMap().keySet());
+ Database racingDb = Mockito.spy(db);
+
Mockito.doReturn(false).doCallRealMethod().when(racingDb).isTableExist("registration_race_error");
+ InternalCatalog racingCatalog = Mockito.spy(catalog);
+
Mockito.doReturn(racingDb).when(racingCatalog).getDbOrDdlException("test");
+
+ Assertions.assertThrows(DdlException.class, () ->
racingCatalog.createTable(analyzeCreateTable(sql)));
+
Mockito.verify(racingCatalog).onCreateTableConflict(Mockito.eq(db.getId()),
+ Mockito.argThat(loser -> loser.getId() != table.getId()));
+ Assertions.assertSame(table,
db.getTableOrDdlException("registration_race_error"));
+ Assertions.assertEquals(tabletIds,
Env.getCurrentInvertedIndex().getTabletMetaMap().keySet());
+ }
+
+ @Test
+ public void testCreateTableRegistrationRaceCleanupFailure() throws
Exception {
+ String sql = "CREATE TABLE IF NOT EXISTS
test.registration_race_cleanup (k INT) "
+ + "DISTRIBUTED BY HASH(k) BUCKETS 1
PROPERTIES('replication_num'='1')";
+ createTable(sql);
+ InternalCatalog catalog = Env.getCurrentInternalCatalog();
+ Database db = catalog.getDbOrDdlException("test");
+ Table table = db.getTableOrDdlException("registration_race_cleanup");
+ Set<Long> tabletIds = new
HashSet<>(Env.getCurrentInvertedIndex().getTabletMetaMap().keySet());
+ Database racingDb = Mockito.spy(db);
+
Mockito.doReturn(false).doCallRealMethod().when(racingDb).isTableExist("registration_race_cleanup");
+ InternalCatalog racingCatalog = Mockito.spy(catalog);
+
Mockito.doReturn(racingDb).when(racingCatalog).getDbOrDdlException("test");
+ Mockito.doThrow(new DdlException("failed to recycle losing indexes"))
+
.when(racingCatalog).onCreateTableConflict(Mockito.eq(db.getId()),
Mockito.any(OlapTable.class));
+
+ DdlException error = Assertions.assertThrows(DdlException.class,
+ () -> racingCatalog.createTable(analyzeCreateTable(sql)));
+ Assertions.assertTrue(error.getMessage().contains("failed to recycle
losing indexes"));
+ Assertions.assertSame(table,
db.getTableOrDdlException("registration_race_cleanup"));
+ Assertions.assertEquals(tabletIds,
Env.getCurrentInvertedIndex().getTabletMetaMap().keySet());
+ }
+
+ private CreateTableInfo analyzeCreateTable(String sql) {
+ CreateTableCommand command = (CreateTableCommand) new
NereidsParser().parseSingle(sql);
+ CreateTableInfo info = command.getCreateTableInfo();
+ info.validate(connectContext);
+ return info;
+ }
+
@Test
public void testDuplicateCreateTable() throws Exception {
// test
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/cloud/datasource/CloudInternalCatalogTest.java
b/fe/fe-core/src/test/java/org/apache/doris/cloud/datasource/CloudInternalCatalogTest.java
index 0725fe07049..6f124c7fad9 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/cloud/datasource/CloudInternalCatalogTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/cloud/datasource/CloudInternalCatalogTest.java
@@ -19,9 +19,13 @@ package org.apache.doris.cloud.datasource;
import org.apache.doris.analysis.DataSortInfo;
import org.apache.doris.catalog.KeysType;
+import org.apache.doris.catalog.OlapTable;
import org.apache.doris.catalog.Replica;
import org.apache.doris.catalog.Tablet;
+import org.apache.doris.cloud.proto.Cloud;
+import org.apache.doris.cloud.rpc.MetaServiceProxy;
import org.apache.doris.common.Config;
+import org.apache.doris.common.DdlException;
import org.apache.doris.proto.OlapFile;
import org.apache.doris.thrift.TCompressionType;
import org.apache.doris.thrift.TInvertedIndexFileStorageFormat;
@@ -31,9 +35,13 @@ import org.apache.doris.thrift.TTabletType;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
import org.mockito.Mockito;
+import java.util.Arrays;
import java.util.Collections;
+import java.util.List;
public class CloudInternalCatalogTest {
@Test
@@ -69,6 +77,43 @@ public class CloudInternalCatalogTest {
}
}
+ @Test
+ public void testCreateTableConflictDropsOnlyLosingIndexes() throws
Exception {
+ testCreateTableConflictCleanup(Cloud.MetaServiceCode.OK);
+ }
+
+ @Test
+ public void testCreateTableConflictReportsCleanupFailure() throws
Exception {
+ testCreateTableConflictCleanup(Cloud.MetaServiceCode.INVALID_ARGUMENT);
+ }
+
+ private void testCreateTableConflictCleanup(Cloud.MetaServiceCode code)
throws Exception {
+ OlapTable loser = Mockito.mock(OlapTable.class);
+ List<Long> indexIds = Arrays.asList(31L, 32L);
+ Mockito.when(loser.getId()).thenReturn(21L);
+ Mockito.when(loser.getIndexIdList(true)).thenReturn(indexIds);
+ MetaServiceProxy proxy = Mockito.mock(MetaServiceProxy.class);
+
Mockito.when(proxy.dropIndex(Mockito.any())).thenReturn(Cloud.IndexResponse.newBuilder()
+
.setStatus(Cloud.MetaServiceResponseStatus.newBuilder().setCode(code).setMsg("cleanup
failed"))
+ .build());
+ try (MockedStatic<MetaServiceProxy> mockedProxy =
Mockito.mockStatic(MetaServiceProxy.class)) {
+ mockedProxy.when(MetaServiceProxy::getInstance).thenReturn(proxy);
+ CloudInternalCatalog catalog = new CloudInternalCatalog();
+ if (code == Cloud.MetaServiceCode.OK) {
+ catalog.onCreateTableConflict(11L, loser);
+ } else {
+ DdlException error =
Assertions.assertThrows(DdlException.class,
+ () -> catalog.onCreateTableConflict(11L, loser));
+ Assertions.assertTrue(error.getMessage().contains("cleanup
failed"));
+ }
+ ArgumentCaptor<Cloud.IndexRequest> request =
ArgumentCaptor.forClass(Cloud.IndexRequest.class);
+ Mockito.verify(proxy).dropIndex(request.capture());
+ Assertions.assertEquals(11L, request.getValue().getDbId());
+ Assertions.assertEquals(21L, request.getValue().getTableId());
+ Assertions.assertEquals(indexIds,
request.getValue().getIndexIdsList());
+ }
+ }
+
private OlapFile.TabletMetaCloudPB createTabletMeta(Tablet tablet) throws
Exception {
return new CloudInternalCatalog().createTabletMetaBuilder(
1L, 2L, 3L, tablet, TTabletType.TABLET_TYPE_DISK, 23,
KeysType.DUP_KEYS, (short) 1,
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]