This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 72072a30a2b branch-4.1: [fix](fe) Refresh backend heartbeat
connections after FQDN IP changes (#69496)
72072a30a2b is described below
commit 72072a30a2bda5c7f9e055211e34020a081bde42
Author: bobhan1 <[email protected]>
AuthorDate: Mon Oct 26 09:58:42 2026 +0800
branch-4.1: [fix](fe) Refresh backend heartbeat connections after FQDN IP
changes (#69496)
### What problem does this PR solve?
Issue Number: N/A
Related PR: #69450
Problem Summary:
Backport #69450 to `branch-4.1`, cherry-picked with `-x` from
`4751c5f593cf7f136f2b193621bd5eabde7dc404`.
In FQDN mode, BE Pod IP reuse can leave FE reusing a heartbeat socket
connected to the wrong process even after DNS converges. Resolve
heartbeat destinations through FE DNSCache, switch and clean up endpoint
pools when cached addresses change or backends are removed, and
invalidate connections after unsuccessful heartbeat responses. Preserve
the configured backend hostname and ID in the heartbeat request.
Recovery follows the existing DNSCache refresh policy.
The member-declaration conflict is adapted to the 4.1 HeartbeatMgr
layout. Both changed files preserve all additions and deletions from the
upstream fix.
### Release note
Fix persistent backend heartbeat failures after BE Pod IP changes in
FQDN mode.
### Check List (For Author)
- Test
- [ ] Regression test
- [x] Unit Test
- [ ] Manual test
- [ ] No need to test or manual test.
- Behavior changed:
- [ ] No.
- [x] Yes. Heartbeat connections follow cached IP changes and are
discarded after application errors.
- Does this need documentation?
- [x] No.
- [ ] Yes.
Validation:
All 36 targeted FE tests pass on branch-4.1, with 0 failures, errors, or
skips. The FE UT script finishes with BUILD SUCCESS. This includes all 8
BackendHeartbeatHandlerTest cases, including the two live Thrift
connection tests.
```bash
./run-fe-ut.sh --run
org.apache.doris.system.BackendHeartbeatHandlerTest,org.apache.doris.system.HeartbeatMgrTest,org.apache.doris.common.GenericPoolTest,org.apache.doris.common.DNSCacheTest,org.apache.doris.rpc.BackendServiceProxyTest
```
Both changed Java files pass Checkstyle 9.3 with the repository
configuration. `git diff --check upstream/branch-4.1...HEAD` passes.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
.../java/org/apache/doris/system/HeartbeatMgr.java | 34 ++-
.../doris/system/BackendHeartbeatHandlerTest.java | 309 +++++++++++++++++++++
2 files changed, 341 insertions(+), 2 deletions(-)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/system/HeartbeatMgr.java
b/fe/fe-core/src/main/java/org/apache/doris/system/HeartbeatMgr.java
index 71e053995c1..1f646622e68 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/system/HeartbeatMgr.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/system/HeartbeatMgr.java
@@ -60,6 +60,7 @@ import com.google.common.collect.Maps;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@@ -80,6 +81,7 @@ public class HeartbeatMgr extends MasterDaemon {
private SystemInfoService nodeMgr;
private HeartbeatFlags heartbeatFlags;
private final ExecutorService abortTxnExecutor;
+ private final Map<Long, TNetworkAddress> backendHeartbeatAddresses =
Maps.newConcurrentMap();
private static volatile AtomicReference<TMasterInfo> masterInfo = new
AtomicReference<>();
@@ -128,6 +130,7 @@ public class HeartbeatMgr extends MasterDaemon {
LOG.warn("can not get backends", e);
return;
}
+ clearRemovedBackendHeartbeatPools();
for (Backend backend : bes) {
BackendHeartbeatHandler handler = new
BackendHeartbeatHandler(backend, feInfos);
hbResponses.add(executor.submit(handler));
@@ -270,8 +273,19 @@ public class HeartbeatMgr extends MasterDaemon {
}
}
+ void clearRemovedBackendHeartbeatPools() {
+ backendHeartbeatAddresses.entrySet().removeIf(entry -> {
+ // Removed BEs will never borrow again, so their idle connections
must be closed here.
+ if (nodeMgr.getBackend(entry.getKey()) == null) {
+ ClientPool.backendHeartbeatPool.clearPool(entry.getValue());
+ return true;
+ }
+ return false;
+ });
+ }
+
// backend heartbeat
- private class BackendHeartbeatHandler implements
Callable<HeartbeatResponse> {
+ class BackendHeartbeatHandler implements Callable<HeartbeatResponse> {
private Backend backend;
private List<TFrontendInfo> feInfos;
@@ -319,6 +333,20 @@ public class HeartbeatMgr extends MasterDaemon {
}
THeartbeatResult result;
if (!FeConstants.runningUnitTest) {
+ if (Config.enable_fqdn_mode) {
+ String realIp =
Env.getCurrentEnv().getDnsCache().get(backend.getHost());
+ // DNSCache returns an empty address when the first
lookup fails.
+ if (realIp.isEmpty()) {
+ throw new UnknownHostException("Failed to resolve
hostname: " + backend.getHost());
+ }
+ beAddr = new TNetworkAddress(realIp,
backend.getHeartbeatPort());
+ }
+ TNetworkAddress previousAddress =
backendHeartbeatAddresses.put(backendId, beAddr);
+ // A reused Pod IP can lead to a healthy connection to the
wrong BE, even when
+ // its heartbeat succeeds. Switch pools as soon as
DNSCache observes the new IP.
+ if (previousAddress != null &&
!previousAddress.equals(beAddr)) {
+
ClientPool.backendHeartbeatPool.clearPool(previousAddress);
+ }
client =
ClientPool.backendHeartbeatPool.borrowObject(beAddr);
result = client.heartbeat(copiedMasterInfo);
} else {
@@ -343,7 +371,6 @@ public class HeartbeatMgr extends MasterDaemon {
result.getStatus().addToErrorMsgs("debug point
HeartbeatMgr.deadBeIds set dead be");
}
- ok = true;
if (result.getStatus().getStatusCode() == TStatusCode.OK) {
TBackendInfo tBackendInfo = result.getBackendInfo();
int bePort = tBackendInfo.getBePort();
@@ -379,6 +406,7 @@ public class HeartbeatMgr extends MasterDaemon {
fragmentNum, lastFragmentUpdateTime, isShutDown,
arrowFlightSqlPort, beMemory);
response.setPaimonRustReaderSupported(tBackendInfo.isSetSupportsPaimonRustReader()
&& tBackendInfo.isSupportsPaimonRustReader());
+ ok = true;
return response;
} else {
return new BackendHbResponse(backendId, backend.getHost(),
backend.getLastUpdateMs(),
@@ -394,6 +422,8 @@ public class HeartbeatMgr extends MasterDaemon {
if (ok) {
ClientPool.backendHeartbeatPool.returnObject(beAddr,
client);
} else {
+ // Identity errors are normal RPC replies. Reusing
that socket would keep
+ // contacting the wrong BE after DNS has converged.
ClientPool.backendHeartbeatPool.invalidateObject(beAddr, client);
}
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/system/BackendHeartbeatHandlerTest.java
b/fe/fe-core/src/test/java/org/apache/doris/system/BackendHeartbeatHandlerTest.java
new file mode 100644
index 00000000000..81592c7c213
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/system/BackendHeartbeatHandlerTest.java
@@ -0,0 +1,309 @@
+// 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.system;
+
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.TokenManager;
+import org.apache.doris.common.ClientPool;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.DNSCache;
+import org.apache.doris.common.FeConstants;
+import org.apache.doris.common.GenericPool;
+import org.apache.doris.service.FrontendOptions;
+import org.apache.doris.system.HeartbeatResponse.HbStatus;
+import org.apache.doris.thrift.HeartbeatService;
+import org.apache.doris.thrift.TBackendInfo;
+import org.apache.doris.thrift.THeartbeatResult;
+import org.apache.doris.thrift.TMasterInfo;
+import org.apache.doris.thrift.TNetworkAddress;
+import org.apache.doris.thrift.TStatus;
+import org.apache.doris.thrift.TStatusCode;
+
+import org.apache.commons.pool2.impl.GenericKeyedObjectPoolConfig;
+import org.apache.thrift.TException;
+import org.apache.thrift.server.ServerContext;
+import org.apache.thrift.server.TServerEventHandler;
+import org.apache.thrift.server.TThreadPoolServer;
+import org.apache.thrift.transport.TServerSocket;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+class BackendHeartbeatHandlerTest {
+ private static final String HOST = "be-0.be-service.test";
+ private static final String OLD_IP = "127.0.0.1";
+ private static final String NEW_IP = "127.0.0.2";
+
+ private final DNSCache dnsCache = Mockito.mock(DNSCache.class);
+ private final SystemInfoService nodeMgr =
Mockito.mock(SystemInfoService.class);
+ @SuppressWarnings("unchecked")
+ private final GenericPool<HeartbeatService.Client> pool =
Mockito.mock(GenericPool.class);
+ private final HeartbeatService.Client client =
Mockito.mock(HeartbeatService.Client.class);
+ private GenericPool<HeartbeatService.Client> originalPool;
+ private boolean originalFqdnMode;
+ private boolean originalRunningUnitTest;
+ private AtomicReference<TMasterInfo> masterInfo;
+ private TMasterInfo originalMasterInfo;
+ private MockedStatic<Env> mockedEnv;
+ private MockedStatic<FrontendOptions> mockedFrontendOptions;
+ private HeartbeatMgr mgr;
+ private Backend backend;
+
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ void setUp() throws Exception {
+ originalPool = ClientPool.backendHeartbeatPool;
+ originalFqdnMode = Config.enable_fqdn_mode;
+ originalRunningUnitTest = FeConstants.runningUnitTest;
+ ClientPool.backendHeartbeatPool = pool;
+ Config.enable_fqdn_mode = true;
+ FeConstants.runningUnitTest = false;
+
+ Field field = HeartbeatMgr.class.getDeclaredField("masterInfo");
+ field.setAccessible(true);
+ masterInfo = (AtomicReference<TMasterInfo>) field.get(null);
+ originalMasterInfo = masterInfo.get();
+
+ Env env = Mockito.mock(Env.class);
+ TokenManager tokenManager = Mockito.mock(TokenManager.class);
+ Mockito.when(tokenManager.acquireToken()).thenReturn("auth-token");
+ Mockito.when(env.getTokenManager()).thenReturn(tokenManager);
+ Mockito.when(env.getDnsCache()).thenReturn(dnsCache);
+ mockedEnv = Mockito.mockStatic(Env.class);
+ mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+ mockedFrontendOptions = Mockito.mockStatic(FrontendOptions.class);
+
mockedFrontendOptions.when(FrontendOptions::getLocalHostAddress).thenReturn(OLD_IP);
+
+ Mockito.when(dnsCache.get(HOST)).thenReturn(OLD_IP);
+
Mockito.when(pool.borrowObject(Mockito.any(TNetworkAddress.class))).thenReturn(client);
+
Mockito.when(client.heartbeat(Mockito.any(TMasterInfo.class))).thenReturn(success(123L));
+ backend = new Backend(1L, HOST, 9050);
+ Mockito.when(nodeMgr.getBackend(backend.getId())).thenReturn(backend);
+ mgr = new HeartbeatMgr(nodeMgr, false);
+ mgr.setMaster(1, "token", 1L);
+ }
+
+ @AfterEach
+ void tearDown() {
+ ClientPool.backendHeartbeatPool = originalPool;
+ Config.enable_fqdn_mode = originalFqdnMode;
+ FeConstants.runningUnitTest = originalRunningUnitTest;
+ masterInfo.set(originalMasterInfo);
+ mockedFrontendOptions.close();
+ mockedEnv.close();
+ }
+
+ private BackendHbResponse heartbeat() {
+ return (BackendHbResponse) mgr.new BackendHeartbeatHandler(backend,
Collections.emptyList()).call();
+ }
+
+ private static THeartbeatResult success(long startTime) {
+ TBackendInfo info = new TBackendInfo();
+ info.setBePort(9060);
+ info.setHttpPort(8040);
+ info.setBeStartTime(startTime);
+ return new THeartbeatResult(new TStatus(TStatusCode.OK), info);
+ }
+
+ @Test
+ void testStableAddressReusesPoolAndPreservesFqdnIdentity() throws
Exception {
+ Assertions.assertEquals(HbStatus.OK, heartbeat().getStatus());
+ Assertions.assertEquals(HbStatus.OK, heartbeat().getStatus());
+ TNetworkAddress address = new TNetworkAddress(OLD_IP, 9050);
+ Mockito.verify(pool, Mockito.times(2)).borrowObject(address);
+ Mockito.verify(pool, Mockito.times(2)).returnObject(address, client);
+ Mockito.verify(pool, Mockito.never()).clearPool(Mockito.any());
+ ArgumentCaptor<TMasterInfo> requests =
ArgumentCaptor.forClass(TMasterInfo.class);
+ Mockito.verify(client, Mockito.times(2)).heartbeat(requests.capture());
+ for (TMasterInfo request : requests.getAllValues()) {
+ Assertions.assertEquals(HOST, request.getBackendIp());
+ Assertions.assertEquals(backend.getId(), request.getBackendId());
+ }
+ }
+
+ @Test
+ void testRejectedHeartbeatInvalidatesConnectionOnBothAttempts() throws
Exception {
+ TStatus status = new TStatus(TStatusCode.INTERNAL_ERROR);
+ status.addToErrorMsgs("the host recorded in master does not map to
this BE");
+ Mockito.when(client.heartbeat(Mockito.any())).thenReturn(new
THeartbeatResult(status, new TBackendInfo()));
+ Assertions.assertEquals(HbStatus.BAD, heartbeat().getStatus());
+ Mockito.verify(pool, Mockito.times(2)).invalidateObject(new
TNetworkAddress(OLD_IP, 9050), client);
+ Mockito.verify(pool, Mockito.never()).returnObject(Mockito.any(),
Mockito.any());
+ }
+
+ @Test
+ void testRpcExceptionInvalidatesConnectionBeforeRetry() throws Exception {
+ Mockito.when(client.heartbeat(Mockito.any())).thenThrow(new
TException("connection reset"))
+ .thenReturn(success(123L));
+ Assertions.assertEquals(HbStatus.OK, heartbeat().getStatus());
+ TNetworkAddress address = new TNetworkAddress(OLD_IP, 9050);
+ Mockito.verify(pool).invalidateObject(address, client);
+ Mockito.verify(pool).returnObject(address, client);
+ }
+
+ @Test
+ void testFailedDnsLookupDoesNotBorrowConnection() {
+ Mockito.when(dnsCache.get(HOST)).thenReturn("");
+ BackendHbResponse response = heartbeat();
+ Assertions.assertEquals(HbStatus.BAD, response.getStatus());
+ Assertions.assertTrue(response.getMsg().contains("Failed to resolve
hostname"));
+ Mockito.verifyNoInteractions(pool);
+ }
+
+ @Test
+ void testIpModeDoesNotUseDnsCache() throws Exception {
+ Config.enable_fqdn_mode = false;
+ backend = new Backend(1L, OLD_IP, 9050);
+ Assertions.assertEquals(HbStatus.OK, heartbeat().getStatus());
+ Mockito.verify(pool).borrowObject(new TNetworkAddress(OLD_IP, 9050));
+ Mockito.verifyNoInteractions(dnsCache);
+ }
+
+ @Test
+ void testRemovedBackendClosesItsIdlePool() {
+ Assertions.assertEquals(HbStatus.OK, heartbeat().getStatus());
+ Mockito.when(nodeMgr.getBackend(backend.getId())).thenReturn(null);
+ mgr.clearRemovedBackendHeartbeatPools();
+ mgr.clearRemovedBackendHeartbeatPools();
+ Mockito.verify(pool).clearPool(new TNetworkAddress(OLD_IP, 9050));
+ }
+
+ @Test
+ void testDnsChangeClosesHealthyConnectionToOldProcess() throws Exception {
+ // Both processes keep their sockets healthy and accept the heartbeat.
Only the DNS
+ // change can move the next heartbeat away from the old process.
+ try (HeartbeatEndpoint oldEndpoint = new HeartbeatEndpoint(OLD_IP, 0,
request -> success(111L));
+ HeartbeatEndpoint newEndpoint = new HeartbeatEndpoint(
+ NEW_IP, oldEndpoint.port(), request -> success(222L)))
{
+ useRealPool(oldEndpoint.port());
+ try {
+ Assertions.assertEquals(111L, heartbeat().getBeStartTime());
+ Assertions.assertEquals(111L, heartbeat().getBeStartTime());
+ Mockito.when(dnsCache.get(HOST)).thenReturn(NEW_IP);
+ Assertions.assertEquals(222L, heartbeat().getBeStartTime());
+ Assertions.assertTrue(oldEndpoint.disconnected.await(5,
TimeUnit.SECONDS));
+ Assertions.assertEquals(2, oldEndpoint.calls.get());
+ Assertions.assertEquals(1, newEndpoint.calls.get());
+ } finally {
+ clearRealPool(oldEndpoint.port());
+ }
+ }
+ }
+
+ @Test
+ void testIdentityMismatchReconnectsToNewProcessWithinRetry() throws
Exception {
+ AtomicReference<String> resolvedIp = new AtomicReference<>(OLD_IP);
+ Mockito.when(dnsCache.get(HOST)).thenAnswer(invocation ->
resolvedIp.get());
+ try (HeartbeatEndpoint wrongEndpoint = new HeartbeatEndpoint(OLD_IP,
0, request -> {
+ // Simulate DNS converging after a stale lookup reached the wrong
BE. The wrong
+ // process stays alive, so the client must close the rejected
socket itself.
+ resolvedIp.set(NEW_IP);
+ TStatus status = new TStatus(TStatusCode.INTERNAL_ERROR);
+ status.addToErrorMsgs("the host recorded in master does not map to
this BE");
+ return new THeartbeatResult(status, new TBackendInfo());
+ }); HeartbeatEndpoint rightEndpoint = new HeartbeatEndpoint(
+ NEW_IP, wrongEndpoint.port(), request -> success(222L))) {
+ useRealPool(wrongEndpoint.port());
+ try {
+ BackendHbResponse response = heartbeat();
+ Assertions.assertEquals(HbStatus.OK, response.getStatus());
+ Assertions.assertEquals(222L, response.getBeStartTime());
+ Assertions.assertTrue(wrongEndpoint.disconnected.await(5,
TimeUnit.SECONDS));
+ Assertions.assertEquals(1, wrongEndpoint.calls.get());
+ Assertions.assertEquals(1, rightEndpoint.calls.get());
+ } finally {
+ clearRealPool(wrongEndpoint.port());
+ }
+ }
+ }
+
+ private void useRealPool(int port) {
+ ClientPool.backendHeartbeatPool = new GenericPool<>(
+ "HeartbeatService", new GenericKeyedObjectPoolConfig(), 2000);
+ backend = new Backend(1L, HOST, port);
+ }
+
+ private void clearRealPool(int port) {
+ ClientPool.backendHeartbeatPool.clearPool(new TNetworkAddress(OLD_IP,
port));
+ ClientPool.backendHeartbeatPool.clearPool(new TNetworkAddress(NEW_IP,
port));
+ }
+
+ private static class HeartbeatEndpoint implements AutoCloseable {
+ private final TServerSocket socket;
+ private final TThreadPoolServer server;
+ private final ExecutorService serving =
Executors.newSingleThreadExecutor();
+ private final ExecutorService workers =
Executors.newSingleThreadExecutor();
+ private final AtomicInteger calls = new AtomicInteger();
+ private final CountDownLatch disconnected = new CountDownLatch(1);
+
+ HeartbeatEndpoint(String ip, int port, HeartbeatService.Iface handler)
throws Exception {
+ socket = new TServerSocket(new ServerSocket(port, 50,
InetAddress.getByName(ip)));
+ HeartbeatService.Iface checkedHandler = request -> {
+ Assertions.assertEquals(HOST, request.getBackendIp());
+ calls.incrementAndGet();
+ return handler.heartbeat(request);
+ };
+ server = new TThreadPoolServer(new TThreadPoolServer.Args(socket)
+ .processor(new
HeartbeatService.Processor<>(checkedHandler)).executorService(workers));
+ CountDownLatch ready = new CountDownLatch(1);
+ TServerEventHandler events =
Mockito.mock(TServerEventHandler.class);
+ Mockito.when(events.createContext(Mockito.any(), Mockito.any()))
+ .thenAnswer(invocation ->
Mockito.mock(ServerContext.class));
+ Mockito.doAnswer(invocation -> {
+ ready.countDown();
+ return null;
+ }).when(events).preServe();
+ Mockito.doAnswer(invocation -> {
+ disconnected.countDown();
+ return null;
+ }).when(events).deleteContext(Mockito.any(), Mockito.any(),
Mockito.any());
+ server.setServerEventHandler(events);
+ serving.submit(server::serve);
+ Assertions.assertTrue(ready.await(5, TimeUnit.SECONDS));
+ }
+
+ int port() {
+ return socket.getServerSocket().getLocalPort();
+ }
+
+ @Override
+ public void close() throws InterruptedException {
+ server.stop();
+ socket.close();
+ workers.shutdownNow();
+ serving.shutdownNow();
+ Assertions.assertTrue(workers.awaitTermination(5,
TimeUnit.SECONDS));
+ Assertions.assertTrue(serving.awaitTermination(5,
TimeUnit.SECONDS));
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]