Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@

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 FutureTask<RemoteOlapTable> currentRefreshTask;

public RemoteDorisExternalTable(long id, String name, String remoteName,
RemoteDorisExternalCatalog catalog, ExternalDatabase db) {
Expand All @@ -62,62 +62,58 @@ protected synchronized void makeSureInitialized() {
}

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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve the post-commit metadata boundary

isDone() lets a caller that starts after a remote transaction becomes visible join a refresh whose server-side snapshot predates that commit. FrontendServiceImpl.getOlapTableMeta() snapshots table/partition metadata under the remote table read lock; after that lock is released, V+1 can become visible while this task is still transporting or rebuilding a large response. A later query then selects this unfinished task and plans with version V, so committed rows can be invisible to a subsequent query. The removed monitor scope forced that later caller to wait and issue a new post-commit RPC, and the existing Remote Doris regression suite explicitly expects a select after insert to fetch the new partition version. Please preserve a generation/invalidation boundary that prevents post-snapshot callers from reusing earlier metadata.

This coordination protocol also has no deterministic upstream test: existing command tests mock getOlapTable(), and the cited manual concurrency run was not performed on this head. Please add latch/barrier-based FE coverage for one RPC/result within a shareable cohort, a fresh successor after completion or invalidation (including the V-to-V+1 schedule above), retry after exceptional completion, and interruption of one waiter without cancelling the shared task.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review. I do not consider the previous locking behavior a
post-commit consistency boundary.

The old implementation explicitly intended concurrent callers to reuse an
in-flight refresh through isSyncOlapTable, remoteOlapTable, and wait/notifyAll.
The reuse failed because makeSureInitialized() and the refresh section competed
for the same monitor. Although makeSureInitialized() released the monitor before
calling getDorisOlapTable(), the same running thread could immediately reacquire
it before the other awakened threads were scheduled. It then started another
long refresh and blocked those threads again. This monitor barging caused the
repeated serial refreshes observed in production; it was not an intentional
metadata-generation mechanism.

This PR restores the original single-flight intent: the monitor only selects a
FutureTask, while RPC and metadata reconstruction run outside it. Callers that
overlap an unfinished refresh share its result. Completed results are not
cached, so the next non-overlapping request always starts a new refresh.

A normal sequential INSERT followed by SELECT remains correct: the metadata
refresh used during INSERT planning finishes before execution and commit, so the
following SELECT sees a completed task and starts a new refresh. The reported
V-to-V+1 case requires an unrelated refresh and commit to overlap. Remote Doris
Catalog does not currently guarantee linearizable metadata snapshots across
such concurrent operations or remote FE nodes. Such a guarantee would require an
explicit metadata version or invalidation protocol, not reliance on accidental
monitor scheduling.

Therefore, I do not plan to add the proposed generation boundary or encode that
new consistency guarantee in this PR.


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;
}
FutureTask<RemoteOlapTable> refreshTask;
boolean shouldRun;
synchronized (this) {
if (currentRefreshTask == null || currentRefreshTask.isDone()) {
currentRefreshTask = new FutureTask<>(this::loadDorisOlapTable);
shouldRun = true;
} else {
shouldRun = false;
}
refreshTask = currentRefreshTask;
}

synchronized (this) {
while (isSyncOlapTable) {
try {
this.wait();
} catch (InterruptedException e) {
throw new AnalysisException("interrupted while getting doris olap table", e);
}
}
if (shouldRun) {
refreshTask.run();
}
return getRefreshResult(refreshTask);
}

// 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");
}
return remoteOlapTable;
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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve fatal Error propagation

FutureTask.run() captures every Throwable, so an OutOfMemoryError while deserializing/copying the large partition set (or another JVM Error) reaches this branch as the ExecutionException cause. Wrapping it unconditionally in Nereids AnalysisException turns a fatal VM/invariant failure into an ordinary query-analysis error; on the direct query path that materially changes control flow, and the old catch (Exception) implementation did not catch the creator's Error. Please rethrow Error causes unchanged before wrapping expected refresh exceptions, and add a focused test for this boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous implementation did not explicitly handle Error either, nor did it provide consistent Error propagation across the refreshing and waiting threads.

More importantly, Errors such as OutOfMemoryError or StackOverflowError indicate a JVM-level failure rather than a recoverable metadata-refresh failure. Once such an Error occurs, the health of the entire FE process is already in question; preserving a specific propagation path in this method does not provide a meaningful correctness guarantee for this API.

This PR targets lock contention during normal metadata refreshes. It does not change metadata correctness or recoverable exception handling. Therefore, JVM-fatal Error propagation is outside the scope of this fix and should not be classified as a P1 issue.

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);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// 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.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 remoteOlapTable;

@BeforeEach
public void setUp() throws Exception {
RemoteDorisExternalCatalog catalog = Mockito.mock(RemoteDorisExternalCatalog.class);
RemoteDorisExternalDatabase db = Mockito.mock(RemoteDorisExternalDatabase.class);
client = Mockito.mock(FeServiceClient.class);
remoteOlapTable = Mockito.mock(RemoteOlapTable.class);
TempPartitions tempPartitions = Mockito.mock(TempPartitions.class);

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);
Mockito.when(remoteOlapTable.getId()).thenReturn(3L);
Mockito.when(remoteOlapTable.getPartitions()).thenReturn(Collections.emptyList());
Mockito.when(remoteOlapTable.getTempPartitions()).thenReturn(tempPartitions);
Mockito.when(tempPartitions.getPartitions()).thenReturn(Collections.emptyList());

table = new RemoteDorisExternalTable(
4L, "test_table", REMOTE_TABLE_NAME, catalog, db);
}

@Test
public void testConcurrentRefreshSharesInFlightTask() throws Exception {
CountDownLatch rpcStarted = new CountDownLatch(1);
CountDownLatch releaseRpc = new CountDownLatch(1);
whenRefreshCalled()
.thenAnswer(invocation -> {
rpcStarted.countDown();
await(releaseRpc);
return remoteOlapTable;
})
.thenReturn(remoteOlapTable);

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(rpcStarted.await(5, TimeUnit.SECONDS));
Thread waiter = startRefresh(waiterResult, waiterFailure);
waitUntilWaiting(waiter);

releaseRpc.countDown();
join(owner);
join(waiter);

Assertions.assertNull(ownerFailure.get());
Assertions.assertNull(waiterFailure.get());
Assertions.assertSame(remoteOlapTable, ownerResult.get());
Assertions.assertSame(remoteOlapTable, waiterResult.get());
verifyRefreshCount(1);

Assertions.assertSame(remoteOlapTable, table.getOlapTable());
verifyRefreshCount(2);
}

@Test
public void testFailedRefreshCanRetry() {
RuntimeException failure = new RuntimeException("refresh failed");
whenRefreshCalled().thenThrow(failure).thenReturn(remoteOlapTable);

AnalysisException exception =
Assertions.assertThrows(AnalysisException.class, table::getOlapTable);
Assertions.assertSame(failure, exception.getCause());

Assertions.assertSame(remoteOlapTable, table.getOlapTable());
verifyRefreshCount(2);
}

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");
}
}
Loading