This is an automated email from the ASF dual-hosted git repository.
hello-stephen 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 f959bb904df [fix](doris-catalog) avoid metadata refresh lock
contention (#68042)
f959bb904df is described below
commit f959bb904df2571f95edcc26838213141d3c8468
Author: re20052 <[email protected]>
AuthorDate: Thu Oct 22 15:05:14 2026 +0800
[fix](doris-catalog) avoid metadata refresh lock contention (#68042)
Problem Summary:
Concurrent queries against the same Remote Doris Catalog table with
`use_arrow_flight=false` can stall during FE planning.
Two factors compound the problem:
1. **Lock design:** `makeSureInitialized()` and metadata refresh share
the same table monitor, which the refresh holds throughout RPC,
deserialization, partition reconstruction, and cache updates. Arriving
requests block at initialization before reaching the reuse branch. After
a refresh completes, a thread leaving initialization may immediately
reacquire the monitor for another refresh while others remain blocked,
limiting result reuse.
2. **Large partition counts:** More partitions increase metadata
processing work and extend the monitor hold time, amplifying contention
and request buildup.
This PR uses current-and-successor `FutureTask<RemoteOlapTable>`
coordination:
- Refresh coordination holds the monitor only to create or select a
task; initialization remains synchronized.
- Callers run their selected refresh outside the monitor, without an
executor or background thread.
- A task is closed to new callers before its metadata RPC starts.
Requests arriving while it is unfinished wait for and share a successor
task instead of consuming the current task's result.
- The first late caller creates an open successor. Other late callers
join it while its predecessor is unfinished.
- After the predecessor completes, every successor caller may attempt to
run the task. `FutureTask.run()` ensures that only one caller executes
it.
- An interrupted waiter exits before starting the successor; another
current or future caller can still run the same successor.
- If a refresh fails, a later successor can retry. An interrupted waiter
restores its interrupt status without cancelling the shared task.
- No TTL cache is added.
The scope is one `RemoteDorisExternalTable` instance within one FE
process. The metadata RPC parameters, partition synchronization, and
cache update logic remain unchanged.
Co-authored-by: renyiyang.1 <[email protected]>
---
.../datasource/doris/RemoteDorisExternalTable.java | 136 +++++++----
.../doris/RemoteDorisExternalTableTest.java | 271 +++++++++++++++++++++
2 files changed, 353 insertions(+), 54 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalTable.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalTable.java
index 9b057a0fbc7..d178072f0e6 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalTable.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalTable.java
@@ -38,15 +38,15 @@ import org.apache.logging.log4j.Logger;
import java.util.List;
import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
public class RemoteDorisExternalTable extends ExternalTable {
private static final Logger LOG =
LogManager.getLogger(RemoteDorisExternalTable.class);
private volatile List<Partition> partitions = Lists.newArrayList();
private volatile List<Partition> tempPartitions = Lists.newArrayList();
private volatile long tableId = -1;
- private volatile boolean isSyncOlapTable = false;
- private volatile RemoteOlapTable remoteOlapTable = null;
- private volatile Exception lastException = null;
+ private transient RefreshTask latestRefreshTask;
public RemoteDorisExternalTable(long id, String name, String remoteName,
RemoteDorisExternalCatalog catalog, ExternalDatabase db) {
@@ -62,62 +62,90 @@ public class RemoteDorisExternalTable extends ExternalTable
{
}
private RemoteOlapTable getDorisOlapTable() {
- if (!isSyncOlapTable) {
- synchronized (this) {
- if (!isSyncOlapTable) {
- try {
- isSyncOlapTable = true;
- remoteOlapTable = null;
- lastException = null; // clear previous exception
-
- List<Partition> cachedPartitions =
Lists.newArrayList(partitions);
- List<Partition> cachedTempPartitions =
Lists.newArrayList(tempPartitions);
- RemoteOlapTable olapTable =
((RemoteDorisExternalCatalog) catalog).getFeServiceClient()
- .getOlapTable(dbName, remoteName, tableId,
cachedPartitions, cachedTempPartitions);
- olapTable.setCatalog((RemoteDorisExternalCatalog)
catalog);
- olapTable.setDatabase((RemoteDorisExternalDatabase)
db);
-
- // Remove redundant nested synchronized block
- tableId = olapTable.getId();
- partitions =
Lists.newArrayList(olapTable.getPartitions());
- tempPartitions =
Lists.newArrayList(olapTable.getTempPartitions().getPartitions());
-
- olapTable.setId(id); // change id in case of possible
conflicts
- olapTable.invalidateBackendsIfNeed();
- remoteOlapTable = olapTable;
- } catch (Exception e) {
- // Save exception for waiting threads
- lastException = e;
- LOG.warn("Failed to get remote doris olap table:
{}.{}", dbName, remoteName, e);
- throw e; // Re-throw the exception
- } finally {
- isSyncOlapTable = false;
- this.notifyAll();
- }
- return remoteOlapTable;
- }
+ RefreshTask refreshTask;
+ synchronized (this) {
+ if (latestRefreshTask == null || latestRefreshTask.isStarted()) {
+ FutureTask<RemoteOlapTable> predecessor =
+ latestRefreshTask == null ? null :
latestRefreshTask.task;
+ latestRefreshTask = new RefreshTask(predecessor);
}
+ refreshTask = latestRefreshTask;
}
- synchronized (this) {
- while (isSyncOlapTable) {
- try {
- this.wait();
- } catch (InterruptedException e) {
- throw new AnalysisException("interrupted while getting
doris olap table", e);
- }
- }
+ refreshTask.run();
+ return getRefreshResult(refreshTask.task);
+ }
+
+ private class RefreshTask {
+ private final FutureTask<RemoteOlapTable> task;
+ private volatile FutureTask<RemoteOlapTable> predecessor;
+ private volatile boolean started;
+
+ private RefreshTask(FutureTask<RemoteOlapTable> predecessor) {
+ this.predecessor = predecessor;
+ this.task = new
FutureTask<>(RemoteDorisExternalTable.this::loadDorisOlapTable);
+ }
+
+ private boolean isStarted() {
+ return started;
+ }
- // If there is a saved exception, throw it with more details
- if (remoteOlapTable == null) {
- if (lastException != null) {
- throw new AnalysisException(
- "failed to get remote doris olap table: " +
Util.getRootCauseMessage(lastException),
- lastException);
- }
- throw new AnalysisException("failed to get remote doris olap
table");
+ private void run() {
+ FutureTask<RemoteOlapTable> previousTask = predecessor;
+ if (previousTask != null) {
+ waitForRefreshCompletion(previousTask);
+ predecessor = null;
}
- return remoteOlapTable;
+ started = true;
+ task.run();
+ }
+ }
+
+ private RemoteOlapTable loadDorisOlapTable() {
+ try {
+ List<Partition> cachedPartitions = Lists.newArrayList(partitions);
+ List<Partition> cachedTempPartitions =
Lists.newArrayList(tempPartitions);
+ RemoteOlapTable olapTable = ((RemoteDorisExternalCatalog)
catalog).getFeServiceClient()
+ .getOlapTable(dbName, remoteName, tableId,
cachedPartitions, cachedTempPartitions);
+ olapTable.setCatalog((RemoteDorisExternalCatalog) catalog);
+ olapTable.setDatabase((RemoteDorisExternalDatabase) db);
+
+ tableId = olapTable.getId();
+ partitions = Lists.newArrayList(olapTable.getPartitions());
+ tempPartitions =
Lists.newArrayList(olapTable.getTempPartitions().getPartitions());
+
+ olapTable.setId(id); // change id in case of possible conflicts
+ olapTable.invalidateBackendsIfNeed();
+ return olapTable;
+ } catch (RuntimeException e) {
+ LOG.warn("Failed to get remote doris olap table: {}.{}", dbName,
remoteName, e);
+ throw e;
+ }
+ }
+
+ private RemoteOlapTable getRefreshResult(FutureTask<RemoteOlapTable>
refreshTask) {
+ try {
+ // The underlying Thrift RPC has its own timeout; this only waits
for the shared refresh result.
+ return refreshTask.get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AnalysisException("interrupted while getting doris olap
table", e);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ throw new AnalysisException(
+ "failed to get remote doris olap table: " +
Util.getRootCauseMessage(cause),
+ cause);
+ }
+ }
+
+ private void waitForRefreshCompletion(FutureTask<RemoteOlapTable>
refreshTask) {
+ try {
+ refreshTask.get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AnalysisException("interrupted while getting doris olap
table", e);
+ } catch (ExecutionException e) {
+ // A later generation can retry after an ordinary refresh failure.
}
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/doris/RemoteDorisExternalTableTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/doris/RemoteDorisExternalTableTest.java
new file mode 100644
index 00000000000..5382e0755d5
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/doris/RemoteDorisExternalTableTest.java
@@ -0,0 +1,271 @@
+// 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.
+
+package org.apache.doris.datasource.doris;
+
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.TempPartitions;
+import org.apache.doris.nereids.exceptions.AnalysisException;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mockito;
+import org.mockito.stubbing.OngoingStubbing;
+
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class RemoteDorisExternalTableTest {
+ private static final String DB_NAME = "test_db";
+ private static final String REMOTE_TABLE_NAME = "remote_test_table";
+
+ private RemoteDorisExternalTable table;
+ private FeServiceClient client;
+ private RemoteOlapTable versionOneTable;
+ private RemoteOlapTable versionTwoTable;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ RemoteDorisExternalCatalog catalog =
Mockito.mock(RemoteDorisExternalCatalog.class);
+ RemoteDorisExternalDatabase db =
Mockito.mock(RemoteDorisExternalDatabase.class);
+ client = Mockito.mock(FeServiceClient.class);
+ versionOneTable = createRemoteOlapTable(3L);
+ versionTwoTable = createRemoteOlapTable(5L);
+
+ Mockito.when(catalog.getId()).thenReturn(1L);
+ Mockito.doReturn(db).when(catalog).getDbOrAnalysisException(DB_NAME);
+ Mockito.when(catalog.getFeServiceClient()).thenReturn(client);
+ Mockito.when(db.getId()).thenReturn(2L);
+ Mockito.when(db.getFullName()).thenReturn(DB_NAME);
+ Mockito.when(db.getRemoteName()).thenReturn(DB_NAME);
+
+ table = new RemoteDorisExternalTable(
+ 4L, "test_table", REMOTE_TABLE_NAME, catalog, db);
+ }
+
+ @Test
+ public void testPostCommitCallersShareSuccessorRefresh() throws Exception {
+ CountDownLatch versionOneSnapshotTaken = new CountDownLatch(1);
+ CountDownLatch releaseVersionOneRpc = new CountDownLatch(1);
+ CountDownLatch versionTwoRpcStarted = new CountDownLatch(1);
+ CountDownLatch releaseVersionTwoRpc = new CountDownLatch(1);
+ AtomicInteger refreshCount = new AtomicInteger();
+ AtomicReference<RemoteOlapTable> remoteMetadata = new
AtomicReference<>(versionOneTable);
+ whenRefreshCalled().thenAnswer(invocation -> {
+ RemoteOlapTable snapshot = remoteMetadata.get();
+ if (refreshCount.incrementAndGet() == 1) {
+ versionOneSnapshotTaken.countDown();
+ await(releaseVersionOneRpc);
+ } else {
+ versionTwoRpcStarted.countDown();
+ await(releaseVersionTwoRpc);
+ }
+ return snapshot;
+ });
+
+ AtomicReference<OlapTable> ownerResult = new AtomicReference<>();
+ AtomicReference<OlapTable> firstWaiterResult = new AtomicReference<>();
+ AtomicReference<OlapTable> secondWaiterResult = new
AtomicReference<>();
+ AtomicReference<Throwable> ownerFailure = new AtomicReference<>();
+ AtomicReference<Throwable> firstWaiterFailure = new
AtomicReference<>();
+ AtomicReference<Throwable> secondWaiterFailure = new
AtomicReference<>();
+
+ Thread owner = startRefresh(ownerResult, ownerFailure);
+ Assertions.assertTrue(versionOneSnapshotTaken.await(5,
TimeUnit.SECONDS));
+
+ // The remote commit becomes visible after the first RPC captured V.
+ remoteMetadata.set(versionTwoTable);
+ Thread firstWaiter = startRefresh(firstWaiterResult,
firstWaiterFailure);
+ Thread secondWaiter = startRefresh(secondWaiterResult,
secondWaiterFailure);
+ waitUntilWaiting(firstWaiter);
+ waitUntilWaiting(secondWaiter);
+
+ releaseVersionOneRpc.countDown();
+ join(owner);
+ Assertions.assertTrue(versionTwoRpcStarted.await(5, TimeUnit.SECONDS));
+ releaseVersionTwoRpc.countDown();
+ join(firstWaiter);
+ join(secondWaiter);
+
+ Assertions.assertNull(ownerFailure.get());
+ Assertions.assertNull(firstWaiterFailure.get());
+ Assertions.assertNull(secondWaiterFailure.get());
+ Assertions.assertSame(versionOneTable, ownerResult.get());
+ Assertions.assertSame(versionTwoTable, firstWaiterResult.get());
+ Assertions.assertSame(versionTwoTable, secondWaiterResult.get());
+ verifyRefreshCount(2);
+ }
+
+ @Test
+ public void testFailedRefreshCanRetry() {
+ RuntimeException failure = new RuntimeException("refresh failed");
+ whenRefreshCalled().thenThrow(failure).thenReturn(versionOneTable);
+
+ AnalysisException exception =
+ Assertions.assertThrows(AnalysisException.class,
table::getOlapTable);
+ Assertions.assertSame(failure, exception.getCause());
+
+ Assertions.assertSame(versionOneTable, table.getOlapTable());
+ verifyRefreshCount(2);
+ }
+
+ @Test
+ public void testInterruptedWaiterDoesNotBlockLaterRefresh() throws
Exception {
+ CountDownLatch snapshotTaken = new CountDownLatch(1);
+ CountDownLatch releaseRpc = new CountDownLatch(1);
+ whenRefreshCalled()
+ .thenAnswer(invocation -> {
+ snapshotTaken.countDown();
+ await(releaseRpc);
+ return versionOneTable;
+ })
+ .thenReturn(versionTwoTable);
+
+ AtomicReference<OlapTable> ownerResult = new AtomicReference<>();
+ AtomicReference<OlapTable> waiterResult = new AtomicReference<>();
+ AtomicReference<Throwable> ownerFailure = new AtomicReference<>();
+ AtomicReference<Throwable> waiterFailure = new AtomicReference<>();
+
+ Thread owner = startRefresh(ownerResult, ownerFailure);
+ Assertions.assertTrue(snapshotTaken.await(5, TimeUnit.SECONDS));
+ Thread waiter = startRefresh(waiterResult, waiterFailure);
+ waitUntilWaiting(waiter);
+
+ waiter.interrupt();
+ join(waiter);
+ Assertions.assertInstanceOf(AnalysisException.class,
waiterFailure.get());
+
+ releaseRpc.countDown();
+ join(owner);
+ Assertions.assertNull(ownerFailure.get());
+ Assertions.assertSame(versionOneTable, ownerResult.get());
+
+ Assertions.assertSame(versionTwoTable, table.getOlapTable());
+ verifyRefreshCount(2);
+ }
+
+ @Test
+ public void testInterruptedWaiterDoesNotOrphanSharedSuccessor() throws
Exception {
+ CountDownLatch firstSnapshotTaken = new CountDownLatch(1);
+ CountDownLatch releaseFirstRpc = new CountDownLatch(1);
+ CountDownLatch successorRpcStarted = new CountDownLatch(1);
+ whenRefreshCalled()
+ .thenAnswer(invocation -> {
+ firstSnapshotTaken.countDown();
+ await(releaseFirstRpc);
+ return versionOneTable;
+ })
+ .thenAnswer(invocation -> {
+ successorRpcStarted.countDown();
+ return versionTwoTable;
+ });
+
+ AtomicReference<OlapTable> ownerResult = new AtomicReference<>();
+ AtomicReference<OlapTable> interruptedResult = new AtomicReference<>();
+ AtomicReference<OlapTable> survivingResult = new AtomicReference<>();
+ AtomicReference<Throwable> ownerFailure = new AtomicReference<>();
+ AtomicReference<Throwable> interruptedFailure = new
AtomicReference<>();
+ AtomicReference<Throwable> survivingFailure = new AtomicReference<>();
+
+ Thread owner = startRefresh(ownerResult, ownerFailure);
+ Assertions.assertTrue(firstSnapshotTaken.await(5, TimeUnit.SECONDS));
+ Thread interruptedWaiter = startRefresh(interruptedResult,
interruptedFailure);
+ Thread survivingWaiter = startRefresh(survivingResult,
survivingFailure);
+ waitUntilWaiting(interruptedWaiter);
+ waitUntilWaiting(survivingWaiter);
+
+ interruptedWaiter.interrupt();
+ join(interruptedWaiter);
+ Assertions.assertInstanceOf(AnalysisException.class,
interruptedFailure.get());
+
+ releaseFirstRpc.countDown();
+ join(owner);
+ Assertions.assertTrue(successorRpcStarted.await(5, TimeUnit.SECONDS));
+ join(survivingWaiter);
+
+ Assertions.assertNull(ownerFailure.get());
+ Assertions.assertNull(survivingFailure.get());
+ Assertions.assertSame(versionOneTable, ownerResult.get());
+ Assertions.assertSame(versionTwoTable, survivingResult.get());
+ verifyRefreshCount(2);
+ }
+
+ private RemoteOlapTable createRemoteOlapTable(long tableId) {
+ RemoteOlapTable olapTable = Mockito.mock(RemoteOlapTable.class);
+ TempPartitions tempPartitions = Mockito.mock(TempPartitions.class);
+ Mockito.when(olapTable.getId()).thenReturn(tableId);
+
Mockito.when(olapTable.getPartitions()).thenReturn(Collections.emptyList());
+ Mockito.when(olapTable.getTempPartitions()).thenReturn(tempPartitions);
+
Mockito.when(tempPartitions.getPartitions()).thenReturn(Collections.emptyList());
+ return olapTable;
+ }
+
+ private OngoingStubbing<RemoteOlapTable> whenRefreshCalled() {
+ return Mockito.when(client.getOlapTable(
+ ArgumentMatchers.eq(DB_NAME),
ArgumentMatchers.eq(REMOTE_TABLE_NAME),
+ ArgumentMatchers.anyLong(), ArgumentMatchers.anyList(),
ArgumentMatchers.anyList()));
+ }
+
+ private void verifyRefreshCount(int count) {
+ Mockito.verify(client, Mockito.times(count)).getOlapTable(
+ ArgumentMatchers.eq(DB_NAME),
ArgumentMatchers.eq(REMOTE_TABLE_NAME),
+ ArgumentMatchers.anyLong(), ArgumentMatchers.anyList(),
ArgumentMatchers.anyList());
+ }
+
+ private Thread startRefresh(AtomicReference<OlapTable> result,
AtomicReference<Throwable> failure) {
+ Thread thread = new Thread(() -> {
+ try {
+ result.set(table.getOlapTable());
+ } catch (Throwable t) {
+ failure.set(t);
+ }
+ });
+ thread.start();
+ return thread;
+ }
+
+ private static void await(CountDownLatch latch) throws
InterruptedException {
+ if (!latch.await(5, TimeUnit.SECONDS)) {
+ throw new AssertionError("timed out waiting for test latch");
+ }
+ }
+
+ private static void waitUntilWaiting(Thread thread) throws
InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (System.nanoTime() < deadline) {
+ Thread.State state = thread.getState();
+ if (state == Thread.State.WAITING || state ==
Thread.State.TIMED_WAITING) {
+ return;
+ }
+ if (!thread.isAlive()) {
+ throw new AssertionError("thread exited before waiting");
+ }
+ Thread.sleep(10);
+ }
+ throw new AssertionError("thread did not enter waiting state");
+ }
+
+ private static void join(Thread thread) throws InterruptedException {
+ thread.join(TimeUnit.SECONDS.toMillis(5));
+ Assertions.assertFalse(thread.isAlive(), "test thread did not finish");
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]