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

fresh-borzoni pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git


The following commit(s) were added to refs/heads/main by this push:
     new 2f77b22a6 [client] Fix stale metadata on readOnlyGateway by adding 
RetryableGatewayClientProxy (#3390)
2f77b22a6 is described below

commit 2f77b22a6d070164355739bb7a738cd51a066a21
Author: Hongshun Wang <[email protected]>
AuthorDate: Wed Jun 3 17:40:55 2026 +0800

    [client] Fix stale metadata on readOnlyGateway by adding 
RetryableGatewayClientProxy (#3390)
    
    * [client] Fix stale metadata on readOnlyGateway by adding 
RetryableGatewayClientProxy
    
    * modified based on advice.
    
    * modified based on advice.
---
 .../org/apache/fluss/client/admin/FlussAdmin.java  |  30 ++-
 .../fluss/client/metadata/MetadataUpdater.java     |  60 +++++
 .../fluss/client/admin/FlussAdminITCase.java       |  27 +++
 .../fluss/rpc/RetryableGatewayClientProxy.java     | 215 ++++++++++++++++
 .../fluss/rpc/RetryableGatewayClientProxyTest.java | 269 +++++++++++++++++++++
 5 files changed, 599 insertions(+), 2 deletions(-)

diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java 
b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java
index a1d429c99..954247850 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java
@@ -48,6 +48,7 @@ import org.apache.fluss.metadata.TableInfo;
 import org.apache.fluss.metadata.TablePath;
 import org.apache.fluss.metadata.TableStats;
 import org.apache.fluss.rpc.GatewayClientProxy;
+import org.apache.fluss.rpc.RetryableGatewayClientProxy;
 import org.apache.fluss.rpc.RpcClient;
 import org.apache.fluss.rpc.gateway.AdminGateway;
 import org.apache.fluss.rpc.gateway.AdminReadOnlyGateway;
@@ -98,6 +99,7 @@ import org.apache.fluss.rpc.messages.TableExistsResponse;
 import org.apache.fluss.rpc.protocol.ApiError;
 import org.apache.fluss.security.acl.AclBinding;
 import org.apache.fluss.security.acl.AclBindingFilter;
+import org.apache.fluss.utils.concurrent.ExecutorThreadFactory;
 import org.apache.fluss.utils.concurrent.FutureUtils;
 
 import javax.annotation.Nullable;
@@ -111,6 +113,8 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 
 import static 
org.apache.fluss.client.utils.ClientRpcMessageUtils.makeAlterDatabaseRequest;
 import static 
org.apache.fluss.client.utils.ClientRpcMessageUtils.makeAlterTableRequest;
@@ -139,13 +143,33 @@ public class FlussAdmin implements Admin {
     private final AdminReadOnlyGateway readOnlyGateway;
     private final MetadataUpdater metadataUpdater;
 
+    /**
+     * Single-thread executor that runs the metadata-refresh callback for 
{@link
+     * RetryableGatewayClientProxy}.
+     */
+    private final ExecutorService refreshExecutor =
+            Executors.newFixedThreadPool(
+                    1, new 
ExecutorThreadFactory("fluss-admin-metadata-refresh"));
+
     public FlussAdmin(RpcClient client, MetadataUpdater metadataUpdater) {
+        // TODO: AdminGateway includes non-idempotent write operations 
(createTable, dropTable,
+        //  createDatabase, etc.). Wrapping it with 
RetryableGatewayClientProxy is unsafe because
+        //  a request may succeed on the server while the response is lost 
(surfacing as a
+        //  RetriableException), causing a duplicate mutation on retry. A 
future phase should
+        //  introduce idempotent retry semantics (e.g., request-id 
deduplication) before enabling
+        //  retry on the write gateway.
         this.gateway =
                 GatewayClientProxy.createGatewayProxy(
                         metadataUpdater::getCoordinatorServer, client, 
AdminGateway.class);
-        this.readOnlyGateway =
+        AdminGateway rawReadOnlyGateway =
                 GatewayClientProxy.createGatewayProxy(
                         metadataUpdater::getRandomTabletServer, client, 
AdminGateway.class);
+        this.readOnlyGateway =
+                RetryableGatewayClientProxy.createRetryableGatewayProxy(
+                        rawReadOnlyGateway,
+                        metadataUpdater::refreshClusterUntilAvailable,
+                        refreshExecutor,
+                        AdminGateway.class);
         this.metadataUpdater = metadataUpdater;
     }
 
@@ -739,7 +763,9 @@ public class FlussAdmin implements Admin {
 
     @Override
     public void close() {
-        // nothing to do yet
+        // Stop the metadata-refresh executor; any in-flight refresh will be 
interrupted, which
+        // refreshClusterUntilAvailable handles by surfacing the 
InterruptedException upward.
+        refreshExecutor.shutdownNow();
     }
 
     private static Map<Integer, GetTableStatsRequest> 
prepareTableStatsRequests(
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/metadata/MetadataUpdater.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/metadata/MetadataUpdater.java
index 2b7c22a31..eb65f9f91 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/metadata/MetadataUpdater.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/metadata/MetadataUpdater.java
@@ -293,6 +293,66 @@ public class MetadataUpdater {
         }
     }
 
+    /**
+     * Refreshes the cluster node list until a live server is reached, falling 
back to a bootstrap
+     * re-initialization when no tablet server is available.
+     *
+     * <p>This is the recovery entry point for stale server addresses (e.g., 
during rolling upgrades
+     * where every tablet server IP changes). Unlike {@link #updateMetadata}, 
which only probes a
+     * single server per call, this method loops: each failed server is marked 
unavailable so the
+     * next iteration picks a different one, and once all known servers are 
exhausted it
+     * re-initializes the cluster from the bootstrap servers. The loop is 
therefore bounded by the
+     * number of known servers (at most N attempts + 1 bootstrap) and always 
converges.
+     *
+     * <p>No additional backoff is introduced here on purpose: each failed 
attempt already pays a
+     * connection timeout, and the terminal bootstrap path ({@link
+     * #tryToInitializeClusterWithRetries}) carries its own exponential 
backoff. Keeping this method
+     * a plain "loop until available or bootstrap" avoids over-engineering and 
stacking redundant
+     * throttling layers.
+     */
+    public void refreshClusterUntilAvailable() {
+        while (true) {
+            ServerNode serverNode =
+                    getOneAvailableTabletServerNode(cluster, 
unavailableTabletServerIds);
+            if (serverNode == null) {
+                // All known tablet servers are unavailable, re-initialize the 
cluster from the
+                // bootstrap servers as the terminal recovery step and stop.
+                synchronized (this) {
+                    LOG.info(
+                            "No available tablet server to refresh metadata, 
re-initializing cluster using bootstrap server.");
+                    cluster = initializeCluster(conf, rpcClient);
+                }
+                
unavailableTabletServerIds.removeIf(cluster.getAliveTabletServers()::containsKey);
+                return;
+            }
+
+            try {
+                synchronized (this) {
+                    cluster =
+                            sendMetadataRequestAndRebuildCluster(
+                                    cluster, rpcClient, null, null, null, 
serverNode);
+                }
+                // Refresh succeeded against a live server, the cluster node 
list is now up to date.
+                
unavailableTabletServerIds.removeIf(cluster.getAliveTabletServers()::containsKey);
+                return;
+            } catch (Exception e) {
+                Throwable t = stripExecutionException(e);
+                if (t instanceof RetriableException || t instanceof 
TimeoutException) {
+                    unavailableTabletServerIds.add(serverNode.id());
+                    LOG.warn(
+                            "tabletServer {} is unavailable for refreshing 
metadata for retriable exception, trying the next server. unavailable 
tabletServer set {}",
+                            serverNode,
+                            unavailableTabletServerIds,
+                            t);
+                    // Continue the loop to try the next available server, or 
fall back to
+                    // bootstrap when none remain.
+                } else {
+                    throw new FlussRuntimeException("Failed to refresh 
metadata", t);
+                }
+            }
+        }
+    }
+
     /**
      * Initialize Cluster. This step just to get the coordinator server 
address and alive tablet
      * servers according to the config {@link ConfigOptions#BOOTSTRAP_SERVERS}.
diff --git 
a/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
 
b/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
index da737dd07..162f3c158 100644
--- 
a/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
+++ 
b/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
@@ -117,6 +117,7 @@ import static 
org.apache.fluss.config.ConfigOptions.DATALAKE_FORMAT;
 import static org.apache.fluss.config.ConfigOptions.TABLE_DATALAKE_ENABLED;
 import static org.apache.fluss.config.ConfigOptions.TABLE_DATALAKE_FORMAT;
 import static org.apache.fluss.metadata.DataLakeFormat.PAIMON;
+import static 
org.apache.fluss.record.TestData.DATA1_PARTITIONED_TABLE_DESCRIPTOR;
 import static org.apache.fluss.record.TestData.DATA1_SCHEMA;
 import static org.apache.fluss.testutils.DataTestUtils.row;
 import static org.apache.fluss.testutils.common.CommonTestUtils.waitUntil;
@@ -1044,6 +1045,32 @@ class FlussAdminITCase extends ClientToServerITCaseBase {
         }
     }
 
+    @Test
+    void testListPartitionInfosAfterTabletServerRestart() throws Exception {
+        String dbName = DEFAULT_TABLE_PATH.getDatabaseName();
+        TablePath partitionedTablePath = TablePath.of(dbName, 
"test_retry_partitioned_table");
+        admin.createTable(partitionedTablePath, 
DATA1_PARTITIONED_TABLE_DESCRIPTOR, true).get();
+        
FLUSS_CLUSTER_EXTENSION.waitUntilPartitionAllReady(partitionedTablePath);
+
+        // First query should succeed.
+        List<PartitionInfo> partitionInfosBefore =
+                admin.listPartitionInfos(partitionedTablePath).get();
+        assertThat(partitionInfosBefore).isNotEmpty();
+
+        // Restart all tablet servers (they bind to new ports, making cached 
addresses stale).
+        for (int i = 0; i < 
FLUSS_CLUSTER_EXTENSION.getTabletServerNodes().size(); i++) {
+            FLUSS_CLUSTER_EXTENSION.stopTabletServer(i);
+            FLUSS_CLUSTER_EXTENSION.startTabletServer(i);
+        }
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllGatewayHasSameMetadata();
+
+        // Second query using the same admin client should succeed after retry 
with metadata
+        // refresh (verifies RetryableGatewayClientProxy convergence on stale 
addresses).
+        List<PartitionInfo> partitionInfosAfter =
+                admin.listPartitionInfos(partitionedTablePath).get();
+        assertThat(partitionInfosAfter).hasSize(partitionInfosBefore.size());
+    }
+
     @Test
     void testListPartitionInfosByPartitionSpec() throws Exception {
         String dbName = DEFAULT_TABLE_PATH.getDatabaseName();
diff --git 
a/fluss-rpc/src/main/java/org/apache/fluss/rpc/RetryableGatewayClientProxy.java 
b/fluss-rpc/src/main/java/org/apache/fluss/rpc/RetryableGatewayClientProxy.java
new file mode 100644
index 000000000..cc368e05e
--- /dev/null
+++ 
b/fluss-rpc/src/main/java/org/apache/fluss/rpc/RetryableGatewayClientProxy.java
@@ -0,0 +1,215 @@
+/*
+ * 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.fluss.rpc;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.exception.RetriableException;
+import org.apache.fluss.utils.ExceptionUtils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * A proxy that wraps an existing {@link RpcGateway} proxy and adds automatic 
retry with metadata
+ * refresh on retriable (network) errors.
+ *
+ * <p>This is designed to solve the stale metadata problem where cached server 
addresses become
+ * invalid (e.g., during rolling upgrades in Kubernetes). When an RPC call 
fails with a {@link
+ * RetriableException}, this proxy triggers a metadata refresh callback and 
retries the request with
+ * potentially updated server addresses.
+ *
+ * <p>The retry flow for a cluster with stale tablet servers:
+ *
+ * <ol>
+ *   <li>RPC fails with {@link RetriableException} (e.g., connection refused 
to stale IP)
+ *   <li>Metadata refresh is triggered, which loops until a live server is 
reached or the cluster is
+ *       re-initialized from bootstrap servers
+ *   <li>The RPC is retried once with the refreshed server addresses
+ * </ol>
+ *
+ * <p>A single retry is sufficient because {@code metadataRefreshAction} is 
expected to fully
+ * recover the cluster node list on its own; the retry only validates the 
refreshed addresses and
+ * absorbs transient errors right after a server/leader switch.
+ *
+ * <p>Concurrent retriers share a single in-flight refresh: when many RPCs 
fail at once (e.g.,
+ * during a rolling upgrade), they all piggyback on the first refresh future 
instead of each
+ * scheduling its own redundant refresh. This avoids piling up N refresh tasks 
behind the metadata
+ * updater's lock and keeps the data plane's table/partition refreshes from 
queueing behind admin
+ * retries.
+ */
+@Internal
+public class RetryableGatewayClientProxy implements InvocationHandler {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(RetryableGatewayClientProxy.class);
+
+    private final Object delegate;
+    private final Runnable metadataRefreshAction;
+    private final Executor refreshExecutor;
+
+    /**
+     * Holds the currently in-flight metadata refresh, if any. Concurrent 
retriers piggyback on this
+     * future to coalesce duplicate refreshes; once the future completes the 
reference is cleared so
+     * subsequent failures trigger a fresh refresh.
+     */
+    private final AtomicReference<CompletableFuture<Void>> inFlightRefresh =
+            new AtomicReference<>();
+
+    RetryableGatewayClientProxy(
+            Object delegate, Runnable metadataRefreshAction, Executor 
refreshExecutor) {
+        this.delegate = delegate;
+        this.metadataRefreshAction = metadataRefreshAction;
+        this.refreshExecutor = refreshExecutor;
+    }
+
+    /**
+     * Creates a retryable proxy wrapping an existing gateway proxy. On {@link 
RetriableException},
+     * the proxy will invoke {@code metadataRefreshAction} and retry the 
failed RPC call once.
+     *
+     * @param delegate the underlying gateway proxy to wrap
+     * @param metadataRefreshAction callback to refresh metadata (e.g., update 
cluster info)
+     * @param refreshExecutor executor on which {@code metadataRefreshAction} 
is run; must NOT be a
+     *     Netty event loop and ideally should be a dedicated, single-thread 
executor (the in-flight
+     *     refresh is already coalesced to at most one concurrent task)
+     * @param gatewayClass the gateway interface class
+     * @param <T> the gateway type
+     * @return a retryable gateway proxy
+     */
+    public static <T extends RpcGateway> T createRetryableGatewayProxy(
+            T delegate,
+            Runnable metadataRefreshAction,
+            Executor refreshExecutor,
+            Class<T> gatewayClass) {
+        ClassLoader classLoader = gatewayClass.getClassLoader();
+
+        @SuppressWarnings("unchecked")
+        T proxy =
+                (T)
+                        Proxy.newProxyInstance(
+                                classLoader,
+                                new Class<?>[] {gatewayClass},
+                                new RetryableGatewayClientProxy(
+                                        delegate, metadataRefreshAction, 
refreshExecutor));
+        return proxy;
+    }
+
+    @Override
+    public Object invoke(Object proxy, Method method, Object[] args) throws 
Throwable {
+        return invokeWithRetry(method, args, true);
+    }
+
+    @SuppressWarnings("unchecked")
+    private <T> CompletableFuture<T> invokeWithRetry(Method method, Object[] 
args, boolean retry) {
+        CompletableFuture<T> future;
+        try {
+            future = (CompletableFuture<T>) method.invoke(delegate, args);
+        } catch (InvocationTargetException e) {
+            CompletableFuture<T> failed = new CompletableFuture<>();
+            failed.completeExceptionally(e.getCause());
+            return failed;
+        } catch (Exception e) {
+            CompletableFuture<T> failed = new CompletableFuture<>();
+            failed.completeExceptionally(e);
+            return failed;
+        }
+
+        CompletableFuture<T> resultFuture = new CompletableFuture<>();
+        future.whenComplete(
+                (result, throwable) -> {
+                    if (throwable == null) {
+                        resultFuture.complete(result);
+                        return;
+                    }
+                    Throwable cause = 
ExceptionUtils.stripCompletionException(throwable);
+                    if (!(cause instanceof RetriableException) || !retry) {
+                        resultFuture.completeExceptionally(cause);
+                        return;
+                    }
+                    LOG.warn(
+                            "RPC call {} failed with retriable error, "
+                                    + "refreshing metadata and retrying once.",
+                            method.getName(),
+                            cause);
+                    // Coalesce concurrent refreshes so N parallel failing 
calls trigger only one
+                    // metadata refresh (and one round of MetadataUpdater lock 
contention).
+                    coalescedRefresh()
+                            .thenCompose(
+                                    ignored ->
+                                            
RetryableGatewayClientProxy.this.<T>invokeWithRetry(
+                                                    method, args, false))
+                            .whenComplete(
+                                    (retryResult, retryError) -> {
+                                        if (retryError != null) {
+                                            resultFuture.completeExceptionally(
+                                                    
ExceptionUtils.stripCompletionException(
+                                                            retryError));
+                                        } else {
+                                            resultFuture.complete(retryResult);
+                                        }
+                                    });
+                });
+        return resultFuture;
+    }
+
+    /**
+     * Returns a future that completes when a metadata refresh has finished. 
Concurrent callers that
+     * arrive while a refresh is in flight all receive the same future and 
therefore wait on a
+     * single shared refresh, instead of each running their own.
+     */
+    private CompletableFuture<Void> coalescedRefresh() {
+        while (true) {
+            CompletableFuture<Void> existing = inFlightRefresh.get();
+            if (existing != null && !existing.isDone()) {
+                return existing;
+            }
+            CompletableFuture<Void> mine = new CompletableFuture<>();
+            if (inFlightRefresh.compareAndSet(existing, mine)) {
+                // Run the metadata refresh on the dedicated executor: the 
failed future is
+                // typically completed on a Netty EventLoop (see 
ServerConnection#close on a
+                // connection reset), and refreshClusterUntilAvailable can 
take the
+                // MetadataUpdater lock, issue further RPCs, and back off with 
sleeps in the
+                // bootstrap path -- all of which would freeze every 
connection sharing that
+                // EventLoop if run inline. ForkJoinPool.commonPool() is also 
unsuitable because
+                // commonPool workers are sized for non-blocking CPU work and 
may even fall back
+                // to the caller thread on small containers.
+                CompletableFuture.runAsync(
+                        () -> {
+                            try {
+                                metadataRefreshAction.run();
+                            } catch (Exception e) {
+                                LOG.warn("Failed to refresh metadata during 
retry", e);
+                            } finally {
+                                // Complete first so piggybackers proceed, 
then clear the slot so
+                                // future failures start a fresh refresh round.
+                                mine.complete(null);
+                                inFlightRefresh.compareAndSet(mine, null);
+                            }
+                        },
+                        refreshExecutor);
+                return mine;
+            }
+        }
+    }
+}
diff --git 
a/fluss-rpc/src/test/java/org/apache/fluss/rpc/RetryableGatewayClientProxyTest.java
 
b/fluss-rpc/src/test/java/org/apache/fluss/rpc/RetryableGatewayClientProxyTest.java
new file mode 100644
index 000000000..d4c8f9dc3
--- /dev/null
+++ 
b/fluss-rpc/src/test/java/org/apache/fluss/rpc/RetryableGatewayClientProxyTest.java
@@ -0,0 +1,269 @@
+/*
+ * 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.fluss.rpc;
+
+import org.apache.fluss.exception.NetworkException;
+import org.apache.fluss.exception.TableNotExistException;
+import org.apache.fluss.rpc.messages.ApiVersionsRequest;
+import org.apache.fluss.rpc.messages.ApiVersionsResponse;
+import org.apache.fluss.rpc.messages.AuthenticateRequest;
+import org.apache.fluss.rpc.messages.AuthenticateResponse;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ForkJoinPool;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Unit tests for {@link RetryableGatewayClientProxy}. */
+class RetryableGatewayClientProxyTest {
+
+    private static final Executor REFRESH_EXECUTOR = ForkJoinPool.commonPool();
+
+    @Test
+    void testSuccessfulCallWithoutRetry() throws Exception {
+        AtomicInteger callCount = new AtomicInteger(0);
+        AtomicInteger refreshCount = new AtomicInteger(0);
+
+        RpcGateway delegate = createGateway(callCount, 0);
+        RpcGateway proxy =
+                RetryableGatewayClientProxy.createRetryableGatewayProxy(
+                        delegate,
+                        refreshCount::incrementAndGet,
+                        REFRESH_EXECUTOR,
+                        RpcGateway.class);
+
+        CompletableFuture<ApiVersionsResponse> result = proxy.apiVersions(new 
ApiVersionsRequest());
+        assertThat(result.get()).isNotNull();
+        assertThat(callCount.get()).isEqualTo(1);
+        assertThat(refreshCount.get()).isEqualTo(0);
+    }
+
+    @Test
+    void testRetryOnNetworkExceptionThenSuccess() throws Exception {
+        AtomicInteger callCount = new AtomicInteger(0);
+        AtomicInteger refreshCount = new AtomicInteger(0);
+
+        // Fail with NetworkException for the first call, then succeed on the 
single retry
+        RpcGateway delegate = createGateway(callCount, 1);
+        RpcGateway proxy =
+                RetryableGatewayClientProxy.createRetryableGatewayProxy(
+                        delegate,
+                        refreshCount::incrementAndGet,
+                        REFRESH_EXECUTOR,
+                        RpcGateway.class);
+
+        CompletableFuture<ApiVersionsResponse> result = proxy.apiVersions(new 
ApiVersionsRequest());
+        assertThat(result.get()).isNotNull();
+        // Initial call + 1 retry = 2 total calls
+        assertThat(callCount.get()).isEqualTo(2);
+        // Metadata refresh should be called once before the retry
+        assertThat(refreshCount.get()).isEqualTo(1);
+    }
+
+    @Test
+    void testExhaustsRetriesAndPropagatesError() {
+        AtomicInteger callCount = new AtomicInteger(0);
+        AtomicInteger refreshCount = new AtomicInteger(0);
+
+        // Always fail with NetworkException (more failures than the single 
retry can recover)
+        RpcGateway delegate = createGateway(callCount, Integer.MAX_VALUE);
+        RpcGateway proxy =
+                RetryableGatewayClientProxy.createRetryableGatewayProxy(
+                        delegate,
+                        refreshCount::incrementAndGet,
+                        REFRESH_EXECUTOR,
+                        RpcGateway.class);
+
+        CompletableFuture<ApiVersionsResponse> result = proxy.apiVersions(new 
ApiVersionsRequest());
+        assertThatThrownBy(result::get)
+                .isInstanceOf(ExecutionException.class)
+                .rootCause()
+                .isInstanceOf(NetworkException.class)
+                .hasMessageContaining("Simulated network error");
+        // Initial call + 1 retry = 2 total calls
+        assertThat(callCount.get()).isEqualTo(2);
+        // Metadata refresh should be called once before the single retry
+        assertThat(refreshCount.get()).isEqualTo(1);
+    }
+
+    @Test
+    void testNonRetriableExceptionNotRetried() {
+        AtomicInteger callCount = new AtomicInteger(0);
+        AtomicInteger refreshCount = new AtomicInteger(0);
+
+        // Fail with a non-retriable exception
+        RpcGateway delegate =
+                new TestRpcGateway() {
+                    @Override
+                    public CompletableFuture<ApiVersionsResponse> apiVersions(
+                            ApiVersionsRequest request) {
+                        callCount.incrementAndGet();
+                        CompletableFuture<ApiVersionsResponse> future = new 
CompletableFuture<>();
+                        future.completeExceptionally(
+                                new TableNotExistException("table does not 
exist"));
+                        return future;
+                    }
+                };
+
+        RpcGateway proxy =
+                RetryableGatewayClientProxy.createRetryableGatewayProxy(
+                        delegate,
+                        refreshCount::incrementAndGet,
+                        REFRESH_EXECUTOR,
+                        RpcGateway.class);
+
+        CompletableFuture<ApiVersionsResponse> result = proxy.apiVersions(new 
ApiVersionsRequest());
+        assertThatThrownBy(result::get)
+                .isInstanceOf(ExecutionException.class)
+                .rootCause()
+                .isInstanceOf(TableNotExistException.class)
+                .hasMessageContaining("table does not exist");
+        // Should only be called once - no retries for non-retriable exceptions
+        assertThat(callCount.get()).isEqualTo(1);
+        assertThat(refreshCount.get()).isEqualTo(0);
+    }
+
+    @Test
+    void testMetadataRefreshFailureDoesNotPreventRetry() throws Exception {
+        AtomicInteger callCount = new AtomicInteger(0);
+        AtomicInteger refreshCount = new AtomicInteger(0);
+
+        // Fail first call, succeed second call
+        RpcGateway delegate = createGateway(callCount, 1);
+
+        // Metadata refresh throws exception
+        Runnable failingRefresh =
+                () -> {
+                    refreshCount.incrementAndGet();
+                    throw new RuntimeException("Simulated refresh failure");
+                };
+
+        RpcGateway proxy =
+                RetryableGatewayClientProxy.createRetryableGatewayProxy(
+                        delegate, failingRefresh, REFRESH_EXECUTOR, 
RpcGateway.class);
+
+        // Should still succeed because the retry goes through even if refresh 
fails
+        CompletableFuture<ApiVersionsResponse> result = proxy.apiVersions(new 
ApiVersionsRequest());
+        assertThat(result.get()).isNotNull();
+        assertThat(callCount.get()).isEqualTo(2);
+        assertThat(refreshCount.get()).isEqualTo(1);
+    }
+
+    /**
+     * Verifies that when many failing RPCs run concurrently, only a single 
metadata refresh is
+     * issued: all retriers piggyback on the same in-flight refresh future. 
This prevents the
+     * pile-up of N redundant refresh tasks behind the metadata updater's lock.
+     */
+    @Test
+    void testConcurrentFailingCallsShareSingleRefresh() throws Exception {
+        int n = 8;
+        AtomicInteger callCount = new AtomicInteger(0);
+        AtomicInteger refreshCount = new AtomicInteger(0);
+        CountDownLatch refreshGate = new CountDownLatch(1);
+
+        // First n calls fail, subsequent calls succeed (so each retrier's 
retry will succeed).
+        RpcGateway delegate = createGateway(callCount, n);
+
+        // Block the refresh until the test releases it, so all retriers have 
a chance to arrive
+        // and either start or piggyback on the in-flight refresh.
+        Runnable blockingRefresh =
+                () -> {
+                    refreshCount.incrementAndGet();
+                    try {
+                        refreshGate.await();
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                };
+
+        RpcGateway proxy =
+                RetryableGatewayClientProxy.createRetryableGatewayProxy(
+                        delegate, blockingRefresh, REFRESH_EXECUTOR, 
RpcGateway.class);
+
+        // Fire n calls; each fails on its initial invocation and queues for a 
refresh.
+        List<CompletableFuture<ApiVersionsResponse>> futures = new 
ArrayList<>();
+        for (int i = 0; i < n; i++) {
+            futures.add(proxy.apiVersions(new ApiVersionsRequest()));
+        }
+
+        // Wait until the (single) refresh task has actually started.
+        long deadline = System.currentTimeMillis() + 5000;
+        while (refreshCount.get() == 0 && System.currentTimeMillis() < 
deadline) {
+            Thread.sleep(10);
+        }
+        assertThat(refreshCount.get()).as("exactly one refresh should have 
started").isEqualTo(1);
+
+        // Release the refresh; all retriers should now proceed with their 
single retry.
+        refreshGate.countDown();
+        for (CompletableFuture<ApiVersionsResponse> f : futures) {
+            assertThat(f.get(5, TimeUnit.SECONDS)).isNotNull();
+        }
+
+        // Initial n failing calls + n successful retries = 2n total 
invocations.
+        assertThat(callCount.get()).isEqualTo(2 * n);
+        // The critical assertion: n concurrent failures triggered only ONE 
refresh.
+        assertThat(refreshCount.get())
+                .as("concurrent retriers must share a single refresh")
+                .isEqualTo(1);
+    }
+
+    /**
+     * Creates a test gateway that fails with {@link NetworkException} for the 
first {@code
+     * failCount} invocations, then returns a successful response.
+     */
+    private static RpcGateway createGateway(AtomicInteger callCount, int 
failCount) {
+        return new TestRpcGateway() {
+            @Override
+            public CompletableFuture<ApiVersionsResponse> 
apiVersions(ApiVersionsRequest request) {
+                int count = callCount.incrementAndGet();
+                CompletableFuture<ApiVersionsResponse> future = new 
CompletableFuture<>();
+                if (count <= failCount) {
+                    future.completeExceptionally(
+                            new NetworkException("Simulated network error on 
call " + count));
+                } else {
+                    future.complete(new ApiVersionsResponse());
+                }
+                return future;
+            }
+        };
+    }
+
+    /** Base test implementation of {@link RpcGateway} that throws on 
unimplemented methods. */
+    private abstract static class TestRpcGateway implements RpcGateway {
+
+        @Override
+        public CompletableFuture<ApiVersionsResponse> 
apiVersions(ApiVersionsRequest request) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public CompletableFuture<AuthenticateResponse> 
authenticate(AuthenticateRequest request) {
+            throw new UnsupportedOperationException();
+        }
+    }
+}


Reply via email to