This is an automated email from the ASF dual-hosted git repository.

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 17be18138ac branch-4.2: [fix](fe) Refresh backend heartbeat 
connections after FQDN IP changes #69450 (#69518)
17be18138ac is described below

commit 17be18138accb13c7d4b91291ffc188b4b633686
Author: bobhan1 <[email protected]>
AuthorDate: Sat Oct 24 10:50:24 2026 +0800

    branch-4.2: [fix](fe) Refresh backend heartbeat connections after FQDN IP 
changes #69450 (#69518)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Related PR: #69450
    
    Backport #69450 (commit `4751c5f593cf7f136f2b193621bd5eabde7dc404`) to
    `branch-4.2`.
    
    In FQDN mode, a BE Pod IP change can leave FE reusing a heartbeat
    connection to the old process, including after an identity error
    returned by a successful Thrift RPC. Resolve the destination through the
    existing DNSCache, key heartbeat connections by the resolved IP, and
    preserve the original backend hostname and ID in the request. Clear the
    previous endpoint's idle connections when the address changes, clean up
    removed backends, and invalidate unsuccessful heartbeat connections
    before retrying. Recovery follows the existing DNSCache refresh policy.
    
    ### 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. Heartbeats switch connections when the cached IP changes and
    discard connections after application errors.
    - Does this need documentation?
        - [x] No.
        - [ ] Yes.
    
    ### Validation
    
    All 36 targeted FE tests pass on branch-4.2 with 0 failures, errors, or
    skips; the repository FE UT script finishes with BUILD SUCCESS. This
    includes all 8 new heartbeat handler tests and both live loopback Thrift
    connection-switching cases.
    
    ```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 using branch-4.2's
    repository configuration. `git diff --check upstream/branch-4.2...HEAD`
    passes. The production-code added/removed lines and the new test file
    match upstream.
    
    ### 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]

Reply via email to