mirror of
https://github.com/apache/doris.git
synced 2026-11-04 17:50:10 +00:00
(cloud) Hold table write lock across first-time dynamic partition setup to prevent CREATE MV race (#62755)
In cloud mode, `InternalCatalog.createTable` releases `db.writeLock`
right after writing the `OP_CREATE_TABLE` edit log, and only then
invokes `DynamicPartitionScheduler.executeDynamicPartitionFirstTime` to
create the first batch of dynamic partitions for the new table. There is
no lock guarding the gap between these two steps, which opens a race
window:
Thread A (CREATE TABLE with dynamic_partition)
-> db.writeLock
-> db.createTableWithoutLock() # writes OP_CREATE_TABLE (idToPartition
is empty)
-> db.writeUnlock # <- race window opens here
-> executeDynamicPartitionFirstTime
-> for each partition:
addPartition()
-> olapTable.readLock
-> checkNormalStateForAlter()
Thread B (CREATE MATERIALIZED VIEW from another client, concurrent)
-> olapTable.writeLockOrDdlException # succeeds, A's db lock does not
block this
-> checkNormalStateForAlter # passes, state is still NORMAL
-> for (Partition p : olapTable.getPartitions()) # snapshots leader's
half-built state
mvJob.addMVIndex(partitionId, ...)
-> olapTable.setState(ROLLUP)
-> logAlterJob(OP_ALTER_JOB_V2) # journals a rollup that references
partitions
# which have not (and never will) appear as
# OP_ADD_PARTITION entries in the journal
Thread A resumes on the next addPartition
-> checkNormalStateForAlter throws "state(ROLLUP) not NORMAL"
-> CREATE TABLE returns ERR,
but OP_CREATE_TABLE and OP_ALTER_JOB_V2 are already durably on disk,
leaving a permanent inconsistency in the journal.
In cloud mode, have two clients fire the following two statements
against the same table within the same second:
CREATE TABLE IF NOT EXISTS t (
...
) PARTITION BY RANGE(ts) () PROPERTIES (
"dynamic_partition.enable" = "true",
"dynamic_partition.time_unit" = "DAY",
"dynamic_partition.start" = "-7",
"dynamic_partition.end" = "1",
"dynamic_partition.create_history_partition" = "true"
);
CREATE MATERIALIZED VIEW mv AS SELECT ... FROM t GROUP BY ...;
The new regression test `test_create_table_and_create_mv_race.groovy`
uses the debug point
`FE.createOlapTable.beforeFirstTimeDynamicPartition` (param `sleepMs`)
to widen the race window and reproduce it deterministically.
Once the bad journal entry is persisted, any FE replaying it hits:
NullPointerException: Cannot invoke DataProperty.getStorageMedium()
because the return value of PartitionInfo.getDataProperty(long) is null
at RollupJobV2.addTabletToInvertedIndex(RollupJobV2.java:762)
at RollupJobV2.replayCreateJob(RollupJobV2.java:745)
at EditLog.loadJournal(EditLog.java:939)
`EditLog.loadJournal:1448` calls `System.exit(-1)`, so the FE JVM exits
immediately. Consequences observed in production:
- All followers that replicate the bad entry crash on replay; supervisor
restarts them and they crash again on the same entry, entering a
Extend the lifetime of `olapTable.writeLock` inside
`InternalCatalog.createTable`:
1. After `OP_CREATE_TABLE` has been written (i.e. `result.second ==
false`, the table was newly registered), acquire `olapTable.writeLock()`
before releasing `db.writeLock`.
2. Wrap everything that used to run after `db.writeUnlock` (colocate
persist, `executeDynamicPartitionFirstTime`,
`registerOrRemoveDynamicPartitionTable`, `createOrUpdateRuntimeInfo`) in
a new try/finally and release the table lock in the finally according to
the `holdTableLock` flag.
With this, Thread A holds the table write lock across the whole
first-time dynamic partition setup. Any concurrent CREATE MV / SCHEMA
CHANGE blocks on `olapTable.writeLockOrDdlException` until A releases
the lock, at which point `olapTable.getPartitions()` reflects the full
partition set and the rollup job B constructs only references partitions
that have matching `OP_ADD_PARTITION` entries in the journal. The
inconsistency is gone.
The lock is scoped to this one new table, so other tables in the same
database are unaffected. The new table has no user traffic yet, so the
extra lock hold time is effectively free.
The fix also introduces the debug point
`FE.createOlapTable.beforeFirstTimeDynamicPartition` (param `sleepMs`),
used only by the regression test to widen the race window. It is
disabled by default in production.
- Regression:
`regression-test/suites/cloud_p0/partition/test_create_table_and_create_mv_race.groovy`
runs CREATE TABLE and CREATE MV concurrently and asserts that MV
completes no earlier than CREATE TABLE. Without the fix, MV either
returns during the injected sleep (assertion fails) or CREATE TABLE
throws a ROLLUP-state error (future.get() re-throws before the
assertion). With the fix, MV blocks on the table lock and the test
passes.
### What problem does this PR solve?
Issue Number: close #xxx
Related PR: #xxx
Problem Summary:
### Release note
None
### Check List (For Author)
- Test <!-- At least one of them must be included. -->
- [x] Regression test
- [ ] Unit Test
- [ ] Manual test (add detailed scripts or steps below)
- [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
- [ ] Previous test can cover this change.
- [ ] No code files have been changed.
- [ ] Other reason <!-- Add your reason? -->
- Behavior changed:
- [x] No.
- [ ] Yes. <!-- Explain the behavior change -->
- Does this need documentation?
- [x] No.
- [ ] Yes. <!-- Add document PR link here. eg:
https://github.com/apache/doris-website/pull/1214 -->
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR
should merge into -->
This commit is contained in:
1 parent
1bdba3505e
commit
f060f176c9
2 files changed
+208
-39
No files matched your search
@@ -3188,6 +3188,7 @@ public class InternalCatalog implements CatalogIf<Database> {
|
||||
}
|
||||
|
||||
Pair<Boolean, Boolean> result;
|
||||
boolean holdTableLock = false;
|
||||
db.writeLockOrDdlException();
|
||||
try {
|
||||
// db name not changed
|
||||
@@ -3196,51 +3197,76 @@ public class InternalCatalog implements CatalogIf<Database> {
|
||||
}
|
||||
// register table, write create table edit log
|
||||
result = db.createTableWithoutLock(olapTable, false, createTableInfo.isIfNotExists());
|
||||
if (!result.second) {
|
||||
olapTable.writeLock();
|
||||
holdTableLock = true;
|
||||
}
|
||||
} finally {
|
||||
db.writeUnlock();
|
||||
}
|
||||
if (!result.first) {
|
||||
ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tableShowName);
|
||||
}
|
||||
|
||||
if (result.second) { // table already exists
|
||||
if (Env.getCurrentColocateIndex().isColocateTable(tableId)) {
|
||||
// if this is a colocate table, its table id is already added to colocate group
|
||||
// so we should remove the tableId here
|
||||
Env.getCurrentColocateIndex().removeTable(tableId);
|
||||
try {
|
||||
if (!result.first) {
|
||||
ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tableShowName);
|
||||
}
|
||||
for (Long tabletId : tabletIdSet) {
|
||||
Env.getCurrentInvertedIndex().deleteTablet(tabletId);
|
||||
}
|
||||
LOG.info("duplicate create table[{};{}] in db[{};{}], skip next steps",
|
||||
tableName, tableId, db.getName(), db.getId());
|
||||
} else {
|
||||
// if table not exists, then db.createTableWithLock will write an editlog.
|
||||
hadLogEditCreateTable = true;
|
||||
|
||||
// we have added these index to memory, only need to persist here
|
||||
if (Env.getCurrentColocateIndex().isColocateTable(tableId)) {
|
||||
GroupId groupId = Env.getCurrentColocateIndex().getGroup(tableId);
|
||||
Map<Tag, List<List<Long>>> backendsPerBucketSeq = Env.getCurrentColocateIndex()
|
||||
.getBackendsPerBucketSeq(groupId);
|
||||
ColocatePersistInfo info = ColocatePersistInfo.createForAddTable(groupId, tableId,
|
||||
backendsPerBucketSeq);
|
||||
Env.getCurrentEnv().getEditLog().logColocateAddTable(info);
|
||||
}
|
||||
LOG.info("successfully create table[{};{}] in db[{};{}]",
|
||||
tableName, tableId, db.getName(), db.getId());
|
||||
Env.getCurrentEnv().getDynamicPartitionScheduler()
|
||||
.executeDynamicPartitionFirstTime(db.getId(), olapTable.getId());
|
||||
// register or remove table from DynamicPartition after table created
|
||||
DynamicPartitionUtil.registerOrRemoveDynamicPartitionTable(db.getId(), olapTable, false);
|
||||
Env.getCurrentEnv().getDynamicPartitionScheduler()
|
||||
.createOrUpdateRuntimeInfo(tableId, DynamicPartitionScheduler.LAST_UPDATE_TIME,
|
||||
TimeUtils.getCurrentFormatTime());
|
||||
}
|
||||
if (result.second) { // table already exists
|
||||
if (Env.getCurrentColocateIndex().isColocateTable(tableId)) {
|
||||
// if this is a colocate table, its table id is already added to colocate group
|
||||
// so we should remove the tableId here
|
||||
Env.getCurrentColocateIndex().removeTable(tableId);
|
||||
}
|
||||
for (Long tabletId : tabletIdSet) {
|
||||
Env.getCurrentInvertedIndex().deleteTablet(tabletId);
|
||||
}
|
||||
LOG.info("duplicate create table[{};{}] in db[{};{}], skip next steps",
|
||||
tableName, tableId, db.getName(), db.getId());
|
||||
} else {
|
||||
// if table not exists, then db.createTableWithLock will write an editlog.
|
||||
hadLogEditCreateTable = true;
|
||||
|
||||
if (DebugPointUtil.isEnable("FE.createOlapTable.exception")) {
|
||||
LOG.info("debug point FE.createOlapTable.exception, throw e");
|
||||
throw new DdlException("debug point FE.createOlapTable.exception");
|
||||
// we have added these index to memory, only need to persist here
|
||||
if (Env.getCurrentColocateIndex().isColocateTable(tableId)) {
|
||||
GroupId groupId = Env.getCurrentColocateIndex().getGroup(tableId);
|
||||
Map<Tag, List<List<Long>>> backendsPerBucketSeq = Env.getCurrentColocateIndex()
|
||||
.getBackendsPerBucketSeq(groupId);
|
||||
ColocatePersistInfo info = ColocatePersistInfo.createForAddTable(groupId, tableId,
|
||||
backendsPerBucketSeq);
|
||||
Env.getCurrentEnv().getEditLog().logColocateAddTable(info);
|
||||
}
|
||||
LOG.info("successfully create table[{};{}] in db[{};{}]",
|
||||
tableName, tableId, db.getName(), db.getId());
|
||||
|
||||
if (DebugPointUtil.isEnable("FE.createOlapTable.beforeFirstTimeDynamicPartition")) {
|
||||
long sleepMs = DebugPointUtil.getDebugParamOrDefault(
|
||||
"FE.createOlapTable.beforeFirstTimeDynamicPartition", "sleepMs", 0L);
|
||||
if (sleepMs > 0) {
|
||||
LOG.info("debug point FE.createOlapTable.beforeFirstTimeDynamicPartition, sleep {}ms",
|
||||
sleepMs);
|
||||
try {
|
||||
Thread.sleep(sleepMs);
|
||||
} catch (InterruptedException ignore) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Env.getCurrentEnv().getDynamicPartitionScheduler()
|
||||
.executeDynamicPartitionFirstTime(db.getId(), olapTable.getId());
|
||||
// register or remove table from DynamicPartition after table created
|
||||
DynamicPartitionUtil.registerOrRemoveDynamicPartitionTable(db.getId(), olapTable, false);
|
||||
Env.getCurrentEnv().getDynamicPartitionScheduler()
|
||||
.createOrUpdateRuntimeInfo(tableId, DynamicPartitionScheduler.LAST_UPDATE_TIME,
|
||||
TimeUtils.getCurrentFormatTime());
|
||||
}
|
||||
|
||||
if (DebugPointUtil.isEnable("FE.createOlapTable.exception")) {
|
||||
LOG.info("debug point FE.createOlapTable.exception, throw e");
|
||||
throw new DdlException("debug point FE.createOlapTable.exception");
|
||||
}
|
||||
} finally {
|
||||
if (holdTableLock) {
|
||||
olapTable.writeUnlock();
|
||||
}
|
||||
}
|
||||
} catch (DdlException e) {
|
||||
LOG.warn("create table failed {} - {}", tabletIdSet, e.getMessage());
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
// 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.
|
||||
import org.apache.doris.regression.suite.ClusterOptions
|
||||
import org.apache.doris.regression.util.NodeType
|
||||
|
||||
// Regression test for the cloud-specific race where a concurrent
|
||||
// CREATE MATERIALIZED VIEW slips in after OP_CREATE_TABLE is journaled
|
||||
// but before first-time dynamic partition entries are journaled, causing
|
||||
// the rollup job to reference partitions that never appear in the journal.
|
||||
// Replay later NPEs at RollupJobV2.addTabletToInvertedIndex.
|
||||
//
|
||||
// The fix holds olapTable.writeLock across the whole first-time dynamic
|
||||
// partition setup so CREATE MV must wait until the table is fully built.
|
||||
suite("test_create_table_and_create_mv_race", 'p0, docker') {
|
||||
if (!isCloudMode()) {
|
||||
return
|
||||
}
|
||||
def options = new ClusterOptions()
|
||||
options.enableDebugPoints()
|
||||
options.setFeNum(3)
|
||||
options.feConfigs.add('sys_log_verbose_modules=org')
|
||||
options.setBeNum(1)
|
||||
options.cloudMode = true
|
||||
|
||||
docker(options) {
|
||||
sql """set enable_sql_cache=false"""
|
||||
|
||||
def tbl = 'test_create_table_and_create_mv_race_tbl'
|
||||
def mvName = 'test_create_table_and_create_mv_race_mv'
|
||||
|
||||
sql "DROP TABLE IF EXISTS ${tbl}"
|
||||
|
||||
// Widen the race window: slow down first-time dynamic partition
|
||||
// so the concurrent CREATE MV has time to arrive.
|
||||
// With the fix, CREATE MV will block on olapTable.writeLock() and
|
||||
// only run after the table is fully built.
|
||||
//
|
||||
// params serialize to HTTP query strings on the wire, so values
|
||||
// must be String-convertible (Groovy handles the coercion).
|
||||
cluster.injectDebugPoints(NodeType.FE, [
|
||||
'FE.createOlapTable.beforeFirstTimeDynamicPartition': [sleepMs: '10000']
|
||||
])
|
||||
|
||||
try {
|
||||
def createDoneAt = new java.util.concurrent.atomic.AtomicLong(0L)
|
||||
def mvDoneAt = new java.util.concurrent.atomic.AtomicLong(0L)
|
||||
|
||||
def createFuture = thread('create-table') {
|
||||
sql """
|
||||
CREATE TABLE ${tbl} (
|
||||
user_id BIGINT,
|
||||
create_dt datetime,
|
||||
amount BIGINT
|
||||
)
|
||||
DUPLICATE KEY(user_id, create_dt)
|
||||
PARTITION BY RANGE(create_dt) ()
|
||||
DISTRIBUTED BY HASH(user_id) BUCKETS 1
|
||||
PROPERTIES (
|
||||
"replication_num" = "1",
|
||||
"dynamic_partition.enable" = "true",
|
||||
"dynamic_partition.time_unit" = "DAY",
|
||||
"dynamic_partition.start" = "-2",
|
||||
"dynamic_partition.end" = "2",
|
||||
"dynamic_partition.prefix" = "p",
|
||||
"dynamic_partition.create_history_partition" = "true"
|
||||
)
|
||||
"""
|
||||
createDoneAt.set(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
// Give CREATE TABLE time to reach the injected sleep.
|
||||
sleep(2000)
|
||||
|
||||
def mvFuture = thread('create-mv') {
|
||||
// If CREATE MV fires before the table exists in the namespace, retry a few times.
|
||||
def attempts = 30
|
||||
while (attempts-- > 0) {
|
||||
try {
|
||||
sql """
|
||||
CREATE MATERIALIZED VIEW ${mvName} AS
|
||||
SELECT user_id AS mv_user_id, sum(amount) AS mv_sum_amount
|
||||
FROM ${tbl}
|
||||
GROUP BY user_id
|
||||
"""
|
||||
break
|
||||
} catch (Exception e) {
|
||||
def msg = e.getMessage() ?: ''
|
||||
if (msg.contains("Unknown table") || msg.contains("does not exist")) {
|
||||
sleep(200)
|
||||
continue
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
mvDoneAt.set(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
createFuture.get()
|
||||
mvFuture.get()
|
||||
|
||||
// Correctness invariant: CREATE MV must have finished AFTER CREATE TABLE.
|
||||
// If the lock is missing, CREATE MV would return first (it's cheap) while
|
||||
// CREATE TABLE is still inside the injected 10s sleep.
|
||||
def ct = createDoneAt.get()
|
||||
def mt = mvDoneAt.get()
|
||||
assert ct > 0 && mt > 0, "both futures should have completed"
|
||||
assert mt >= ct, "CREATE MV (${mt}) must finish after CREATE TABLE (${ct})," +
|
||||
" otherwise the lock fix is missing and MV raced ahead of first-time dynamic partition setup"
|
||||
} finally {
|
||||
cluster.clearFrontendDebugPoints()
|
||||
}
|
||||
|
||||
cluster.checkFeIsAlive(1, true)
|
||||
|
||||
// Verify the table has all dynamic partitions.
|
||||
// dynamic_partition.start=-2, end=2, create_history_partition=true → 5 partitions expected
|
||||
def partitions = sql "SHOW PARTITIONS FROM ${tbl}"
|
||||
assert partitions.size() >= 3,
|
||||
"dynamic_partition.start=-2/end=2 should have produced at least 3 partitions, got ${partitions.size()}"
|
||||
|
||||
// Sanity: the MV exists and an end-to-end insert works.
|
||||
def now = new Date()
|
||||
def dateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd")
|
||||
def today = dateFormat.format(now)
|
||||
sql "INSERT INTO ${tbl} VALUES (1, '${today} 12:00:00', 100)"
|
||||
def cnt = sql "SELECT count(*) FROM ${tbl}"
|
||||
assert cnt[0][0] == 1L, "expected 1 row, got ${cnt[0][0]}"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user