-
Notifications
You must be signed in to change notification settings - Fork 4k
[fix](doris-catalog) avoid metadata refresh lock contention #68042
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
|
@@ -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()); | ||
|
|
||
| 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()); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Preserve fatal
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| 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"); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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.