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

swuferhong 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 91bea7cd4 [server] Add per-task timeout for RebalanceManager to 
prevent indefinite blocking (#3429)
91bea7cd4 is described below

commit 91bea7cd48c5f34ee87adfee897af40b67dc21b0
Author: yunhong <[email protected]>
AuthorDate: Sun Jun 7 12:36:52 2026 +0800

    [server] Add per-task timeout for RebalanceManager to prevent indefinite 
blocking (#3429)
    
    * [server] Add per-task timeout for RebalanceManager to prevent indefinite 
blocking
    
    * address hongshun's comments
    
    * address gujian's comments
---
 .../fluss/cluster/rebalance/RebalanceStatus.java   |   5 +-
 .../coordinator/CoordinatorEventProcessor.java     |  14 +-
 .../event/RebalanceTaskTimeoutEvent.java           |  39 +++++
 .../coordinator/rebalance/RebalanceManager.java    | 115 ++++++++++++-
 .../rebalance/RebalanceManagerTest.java            | 189 ++++++++++++++++++++-
 website/docs/maintenance/operations/rebalance.md   |   1 +
 6 files changed, 358 insertions(+), 5 deletions(-)

diff --git 
a/fluss-common/src/main/java/org/apache/fluss/cluster/rebalance/RebalanceStatus.java
 
b/fluss-common/src/main/java/org/apache/fluss/cluster/rebalance/RebalanceStatus.java
index a96b40ddd..5edbafd09 100644
--- 
a/fluss-common/src/main/java/org/apache/fluss/cluster/rebalance/RebalanceStatus.java
+++ 
b/fluss-common/src/main/java/org/apache/fluss/cluster/rebalance/RebalanceStatus.java
@@ -34,10 +34,11 @@ public enum RebalanceStatus {
     REBALANCING(1),
     FAILED(2),
     COMPLETED(3),
-    CANCELED(4);
+    CANCELED(4),
+    TIMEOUT(5);
 
     public static final Set<RebalanceStatus> FINAL_STATUSES =
-            new HashSet<>(Arrays.asList(COMPLETED, CANCELED, FAILED));
+            new HashSet<>(Arrays.asList(COMPLETED, CANCELED, FAILED, TIMEOUT));
 
     private final int code;
 
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
index 00ca807f0..456e7c587 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
@@ -85,6 +85,7 @@ import 
org.apache.fluss.server.coordinator.event.NotifyKvSnapshotOffsetEvent;
 import org.apache.fluss.server.coordinator.event.NotifyLakeTableOffsetEvent;
 import 
org.apache.fluss.server.coordinator.event.NotifyLeaderAndIsrResponseReceivedEvent;
 import org.apache.fluss.server.coordinator.event.RebalanceEvent;
+import org.apache.fluss.server.coordinator.event.RebalanceTaskTimeoutEvent;
 import org.apache.fluss.server.coordinator.event.RemoveServerTagEvent;
 import org.apache.fluss.server.coordinator.event.SchemaChangeEvent;
 import org.apache.fluss.server.coordinator.event.TableRegistrationChangeEvent;
@@ -122,6 +123,7 @@ import org.apache.fluss.server.zk.data.ZkData.TableIdsZNode;
 import org.apache.fluss.server.zk.data.lake.LakeTableHelper;
 import org.apache.fluss.server.zk.data.lake.LakeTableSnapshot;
 import org.apache.fluss.utils.AutoPartitionStrategy;
+import org.apache.fluss.utils.clock.SystemClock;
 import org.apache.fluss.utils.types.Tuple2;
 
 import org.slf4j.Logger;
@@ -249,7 +251,9 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
         this.lakeTableTieringManager = lakeTableTieringManager;
         this.coordinatorMetricGroup = coordinatorMetricGroup;
         this.internalListenerName = 
conf.getString(ConfigOptions.INTERNAL_LISTENER_NAME);
-        this.rebalanceManager = new RebalanceManager(this, zooKeeperClient);
+        this.rebalanceManager =
+                new RebalanceManager(
+                        this, zooKeeperClient, coordinatorEventManager, 
SystemClock.getInstance());
         this.ioExecutor = ioExecutor;
         this.lakeTableHelper =
                 new LakeTableHelper(zooKeeperClient, 
conf.getString(ConfigOptions.REMOTE_DATA_DIR));
@@ -302,6 +306,7 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
 
         // start rebalance manager.
         rebalanceManager.startup();
+        rebalanceManager.start();
     }
 
     public void shutdown() {
@@ -645,6 +650,13 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
             completeFromCallable(
                     cancelRebalanceEvent.getRespCallback(),
                     () -> processCancelRebalance(cancelRebalanceEvent));
+        } else if (event instanceof RebalanceTaskTimeoutEvent) {
+            RebalanceTaskTimeoutEvent timeoutEvent = 
(RebalanceTaskTimeoutEvent) event;
+            LOG.warn(
+                    "Rebalance task for {} timed out. Treating as timeout.",
+                    timeoutEvent.getTableBucket());
+            rebalanceManager.finishRebalanceTask(
+                    timeoutEvent.getTableBucket(), RebalanceStatus.TIMEOUT);
         } else if (event instanceof ListRebalanceProgressEvent) {
             ListRebalanceProgressEvent listRebalanceProgressEvent =
                     (ListRebalanceProgressEvent) event;
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/RebalanceTaskTimeoutEvent.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/RebalanceTaskTimeoutEvent.java
new file mode 100644
index 000000000..c5d962d02
--- /dev/null
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/RebalanceTaskTimeoutEvent.java
@@ -0,0 +1,39 @@
+/*
+ * 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.server.coordinator.event;
+
+import org.apache.fluss.metadata.TableBucket;
+
+/** An event fired when a rebalance task exceeds the timeout without 
completing. */
+public class RebalanceTaskTimeoutEvent implements CoordinatorEvent {
+
+    private final TableBucket tableBucket;
+
+    public RebalanceTaskTimeoutEvent(TableBucket tableBucket) {
+        this.tableBucket = tableBucket;
+    }
+
+    public TableBucket getTableBucket() {
+        return tableBucket;
+    }
+
+    @Override
+    public String toString() {
+        return "RebalanceTaskTimeoutEvent{tableBucket=" + tableBucket + "}";
+    }
+}
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/rebalance/RebalanceManager.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/rebalance/RebalanceManager.java
index 1a722ff4d..9f2f350bd 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/rebalance/RebalanceManager.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/rebalance/RebalanceManager.java
@@ -27,6 +27,8 @@ import 
org.apache.fluss.exception.NoRebalanceInProgressException;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.server.coordinator.CoordinatorContext;
 import org.apache.fluss.server.coordinator.CoordinatorEventProcessor;
+import org.apache.fluss.server.coordinator.event.EventManager;
+import org.apache.fluss.server.coordinator.event.RebalanceTaskTimeoutEvent;
 import org.apache.fluss.server.coordinator.rebalance.goal.Goal;
 import org.apache.fluss.server.coordinator.rebalance.goal.GoalOptimizer;
 import org.apache.fluss.server.coordinator.rebalance.model.ClusterModel;
@@ -36,6 +38,9 @@ import org.apache.fluss.server.metadata.ServerInfo;
 import org.apache.fluss.server.zk.ZooKeeperClient;
 import org.apache.fluss.server.zk.data.LeaderAndIsr;
 import org.apache.fluss.server.zk.data.RebalanceTask;
+import org.apache.fluss.utils.clock.Clock;
+import org.apache.fluss.utils.clock.SystemClock;
+import org.apache.fluss.utils.concurrent.ExecutorThreadFactory;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -53,6 +58,9 @@ import java.util.SortedSet;
 import java.util.TreeSet;
 import java.util.UUID;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
 
 import static org.apache.fluss.cluster.rebalance.RebalanceStatus.CANCELED;
 import static org.apache.fluss.cluster.rebalance.RebalanceStatus.COMPLETED;
@@ -70,8 +78,17 @@ import static 
org.apache.fluss.utils.Preconditions.checkNotNull;
 public class RebalanceManager {
     private static final Logger LOG = 
LoggerFactory.getLogger(RebalanceManager.class);
 
+    /** Hardcoded timeout for an in-flight rebalance task: 2 minutes. */
+    private static final long REBALANCE_TASK_TIMEOUT_MS = 2 * 60 * 1000L;
+
+    /** Hardcoded interval for the periodic timeout check: 30 seconds. */
+    private static final long TIMEOUT_CHECK_INTERVAL_MS = 30 * 1000L;
+
     private final ZooKeeperClient zkClient;
     private final CoordinatorEventProcessor eventProcessor;
+    private final EventManager eventManager;
+    private final Clock clock;
+    private final ScheduledExecutorService timeoutChecker;
 
     /** A queue of in progress table bucket to rebalance. */
     private final Queue<TableBucket> inProgressRebalanceTasksQueue = new 
ArrayDeque<>();
@@ -90,9 +107,46 @@ public class RebalanceManager {
     private volatile @Nullable String currentRebalanceId;
     private volatile boolean isClosed = false;
 
-    public RebalanceManager(CoordinatorEventProcessor eventProcessor, 
ZooKeeperClient zkClient) {
+    /**
+     * Timestamp when the current in-flight task was started, or -1 if idle.
+     *
+     * <p>Write ordering contract (volatile publication idiom): always write 
{@code
+     * inflightTaskStartMs} BEFORE {@code inflightTaskBucket} when setting, 
and clear {@code
+     * inflightTaskBucket} BEFORE {@code inflightTaskStartMs} when resetting. 
The timeout checker
+     * reads in reverse order (bucket first, then startMs), ensuring it never 
observes a stale
+     * startMs paired with a new bucket.
+     */
+    private volatile long inflightTaskStartMs = -1;
+
+    /** The bucket of the current in-flight task, or null if idle. Acts as the 
"gate" variable. */
+    private volatile @Nullable TableBucket inflightTaskBucket;
+
+    public RebalanceManager(
+            CoordinatorEventProcessor eventProcessor,
+            ZooKeeperClient zkClient,
+            EventManager eventManager,
+            Clock clock) {
+        this(
+                eventProcessor,
+                zkClient,
+                eventManager,
+                clock,
+                Executors.newScheduledThreadPool(
+                        1, new ExecutorThreadFactory("rebalance-timeout")));
+    }
+
+    @VisibleForTesting
+    RebalanceManager(
+            CoordinatorEventProcessor eventProcessor,
+            ZooKeeperClient zkClient,
+            EventManager eventManager,
+            Clock clock,
+            ScheduledExecutorService timeoutChecker) {
         this.eventProcessor = eventProcessor;
         this.zkClient = zkClient;
+        this.eventManager = eventManager;
+        this.clock = clock == null ? SystemClock.getInstance() : clock;
+        this.timeoutChecker = timeoutChecker;
         this.goalOptimizer = new GoalOptimizer();
     }
 
@@ -101,6 +155,19 @@ public class RebalanceManager {
         initialize();
     }
 
+    /** Starts the periodic timeout checker. Call after {@link #startup()}. */
+    public void start() {
+        timeoutChecker.scheduleWithFixedDelay(
+                this::checkTimeoutSafely,
+                TIMEOUT_CHECK_INTERVAL_MS,
+                TIMEOUT_CHECK_INTERVAL_MS,
+                TimeUnit.MILLISECONDS);
+        LOG.info(
+                "RebalanceManager timeout checker started: timeoutMs={}, 
checkIntervalMs={}",
+                REBALANCE_TASK_TIMEOUT_MS,
+                TIMEOUT_CHECK_INTERVAL_MS);
+    }
+
     public @Nullable String getRebalanceId() {
         return currentRebalanceId;
     }
@@ -132,6 +199,9 @@ public class RebalanceManager {
         inProgressRebalanceTasks.clear();
         inProgressRebalanceTasksQueue.clear();
         finishedRebalanceTasks.clear();
+        // Clear gate (bucket) first, then data (startMs).
+        inflightTaskBucket = null;
+        inflightTaskStartMs = -1;
 
         currentRebalanceId = rebalanceId;
         if (rebalancePlan.isEmpty()) {
@@ -170,6 +240,9 @@ public class RebalanceManager {
             finishedRebalanceTasks.put(
                     tableBucket,
                     RebalanceResultForBucket.of(resultForBucket.plan(), 
statusForBucket));
+            // Clear gate (bucket) first, then data (startMs).
+            inflightTaskBucket = null;
+            inflightTaskStartMs = -1;
             LOG.info(
                     "Rebalance task {} in progress: {} tasks pending, {} 
completed.",
                     currentRebalanceId,
@@ -250,6 +323,9 @@ public class RebalanceManager {
         rebalanceStatus = CANCELED;
         inProgressRebalanceTasksQueue.clear();
         inProgressRebalanceTasks.clear();
+        // Clear gate (bucket) first, then data (startMs).
+        inflightTaskBucket = null;
+        inflightTaskStartMs = -1;
         // Here, it will not clear finishedRebalanceTasks, because it will be 
used by
         // listRebalanceProgress. It will be cleared when next register.
 
@@ -302,6 +378,9 @@ public class RebalanceManager {
     private void processNewRebalanceTask() {
         TableBucket tableBucket = inProgressRebalanceTasksQueue.peek();
         if (tableBucket != null && 
inProgressRebalanceTasks.containsKey(tableBucket)) {
+            // Write data (startMs) first, then publish gate (bucket).
+            inflightTaskStartMs = clock.milliseconds();
+            inflightTaskBucket = tableBucket;
             RebalanceResultForBucket resultForBucket = 
inProgressRebalanceTasks.get(tableBucket);
             RebalanceResultForBucket rebalanceResultForBucket =
                     RebalanceResultForBucket.of(resultForBucket.plan(), 
REBALANCING);
@@ -400,12 +479,46 @@ public class RebalanceManager {
         return new ClusterModel(servers);
     }
 
+    private void checkTimeoutSafely() {
+        try {
+            checkTimeout();
+        } catch (Throwable t) {
+            LOG.error("Unexpected error in RebalanceManager timeout check.", 
t);
+        }
+    }
+
+    @VisibleForTesting
+    void checkTimeout() {
+        // Read gate (bucket) first, then data (startMs).
+        // If bucket is non-null, happens-before guarantees startMs is at 
least as
+        // fresh as the value written before bucket was published.
+        TableBucket bucket = inflightTaskBucket;
+        long startMs = inflightTaskStartMs;
+        if (bucket == null || startMs < 0) {
+            return;
+        }
+        long elapsed = clock.milliseconds() - startMs;
+        if (elapsed > REBALANCE_TASK_TIMEOUT_MS) {
+            LOG.warn(
+                    "In-flight rebalance task for {} timed out after {}ms. "
+                            + "Treating it as timed out and advancing to the 
next task.",
+                    bucket,
+                    elapsed);
+            // Clear gate (bucket) first, then data (startMs), matching the
+            // publication idiom so the next checkTimeout sees bucket==null.
+            inflightTaskBucket = null;
+            inflightTaskStartMs = -1;
+            eventManager.put(new RebalanceTaskTimeoutEvent(bucket));
+        }
+    }
+
     private void checkNotClosed() {
         checkArgument(!isClosed, "RebalanceManager is already closed.");
     }
 
     public void close() {
         isClosed = true;
+        timeoutChecker.shutdownNow();
     }
 
     @VisibleForTesting
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/rebalance/RebalanceManagerTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/rebalance/RebalanceManagerTest.java
index 725430ede..d550aea1c 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/rebalance/RebalanceManagerTest.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/rebalance/RebalanceManagerTest.java
@@ -17,9 +17,12 @@
 
 package org.apache.fluss.server.coordinator.rebalance;
 
+import org.apache.fluss.cluster.rebalance.RebalancePlanForBucket;
+import org.apache.fluss.cluster.rebalance.RebalanceResultForBucket;
 import org.apache.fluss.cluster.rebalance.RebalanceStatus;
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
+import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.server.coordinator.AutoPartitionManager;
 import org.apache.fluss.server.coordinator.CoordinatorContext;
 import org.apache.fluss.server.coordinator.CoordinatorEventProcessor;
@@ -27,6 +30,9 @@ import 
org.apache.fluss.server.coordinator.LakeCatalogDynamicLoader;
 import org.apache.fluss.server.coordinator.LakeTableTieringManager;
 import org.apache.fluss.server.coordinator.MetadataManager;
 import org.apache.fluss.server.coordinator.TestCoordinatorChannelManager;
+import org.apache.fluss.server.coordinator.event.CoordinatorEvent;
+import org.apache.fluss.server.coordinator.event.EventManager;
+import org.apache.fluss.server.coordinator.event.RebalanceTaskTimeoutEvent;
 import org.apache.fluss.server.coordinator.lease.KvSnapshotLeaseManager;
 import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader;
 import org.apache.fluss.server.metadata.CoordinatorMetadataCache;
@@ -37,6 +43,7 @@ import org.apache.fluss.server.zk.ZooKeeperClient;
 import org.apache.fluss.server.zk.ZooKeeperExtension;
 import org.apache.fluss.server.zk.data.RebalanceTask;
 import org.apache.fluss.testutils.common.AllCallbackWrapper;
+import org.apache.fluss.utils.clock.ManualClock;
 import org.apache.fluss.utils.clock.SystemClock;
 import org.apache.fluss.utils.concurrent.ExecutorThreadFactory;
 
@@ -47,11 +54,17 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 
 import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 
 import static org.apache.fluss.cluster.rebalance.RebalanceStatus.COMPLETED;
 import static org.apache.fluss.cluster.rebalance.RebalanceStatus.NOT_STARTED;
+import static org.apache.fluss.cluster.rebalance.RebalanceStatus.TIMEOUT;
 import static org.assertj.core.api.Assertions.assertThat;
 
 /** Test for {@link RebalanceManager}. */
@@ -107,12 +120,19 @@ public class RebalanceManagerTest {
         lakeTableTieringManager =
                 new 
LakeTableTieringManager(TestingMetricGroups.LAKE_TIERING_METRICS);
         CoordinatorEventProcessor eventProcessor = 
buildCoordinatorEventProcessor(conf);
-        rebalanceManager = new RebalanceManager(eventProcessor, 
zookeeperClient);
+        RecordingEventManager recordingEventManager = new 
RecordingEventManager();
+        rebalanceManager =
+                new RebalanceManager(
+                        eventProcessor,
+                        zookeeperClient,
+                        recordingEventManager,
+                        SystemClock.getInstance());
         rebalanceManager.startup();
     }
 
     @AfterEach
     void afterEach() throws Exception {
+        rebalanceManager.close();
         zookeeperClient.deleteRebalanceTask();
         metadataManager =
                 new MetadataManager(
@@ -142,6 +162,143 @@ public class RebalanceManagerTest {
                 .hasValue(new RebalanceTask(rebalanceId, COMPLETED, new 
HashMap<>()));
     }
 
+    @Test
+    void testTimeoutEnqueuesEvent() throws Exception {
+        ManualClock clock = new ManualClock(0L);
+        RecordingEventManager eventManager = new RecordingEventManager();
+        NoOpScheduledExecutor executor = new NoOpScheduledExecutor();
+        CoordinatorEventProcessor eventProcessor =
+                buildCoordinatorEventProcessor(new Configuration());
+
+        RebalanceManager manager =
+                new RebalanceManager(
+                        eventProcessor, zookeeperClient, eventManager, clock, 
executor);
+        manager.startup();
+
+        TableBucket tb1 = new TableBucket(1L, 0);
+        TableBucket tb2 = new TableBucket(1L, 1);
+        Map<TableBucket, RebalancePlanForBucket> plan = new HashMap<>();
+        plan.put(
+                tb1,
+                new RebalancePlanForBucket(
+                        tb1, 0, 0, Arrays.asList(0, 1, 2), Arrays.asList(0, 1, 
2)));
+        plan.put(
+                tb2,
+                new RebalancePlanForBucket(
+                        tb2, 0, 0, Arrays.asList(0, 1, 2), Arrays.asList(0, 1, 
2)));
+
+        zookeeperClient.registerRebalanceTask(new 
RebalanceTask("timeout-test", NOT_STARTED, plan));
+        manager.registerRebalance("timeout-test", plan, NOT_STARTED);
+
+        // Not yet timed out.
+        clock.advanceTime(Duration.ofMillis(100_000));
+        manager.checkTimeout();
+        assertThat(eventManager.events).isEmpty();
+
+        // Cross the 2-minute boundary.
+        clock.advanceTime(Duration.ofMillis(30_000));
+        manager.checkTimeout();
+
+        assertThat(eventManager.events).hasSize(1);
+        
assertThat(eventManager.events.get(0)).isInstanceOf(RebalanceTaskTimeoutEvent.class);
+        RebalanceTaskTimeoutEvent timeoutEvent =
+                (RebalanceTaskTimeoutEvent) eventManager.events.get(0);
+        assertThat(timeoutEvent.getTableBucket()).isEqualTo(tb1);
+
+        // A second checkTimeout() should NOT enqueue another event because the
+        // inflight state was cleared after the first timeout.
+        clock.advanceTime(Duration.ofMillis(30_000));
+        manager.checkTimeout();
+        assertThat(eventManager.events).hasSize(1);
+
+        manager.close();
+    }
+
+    @Test
+    void testTimeoutAfterCompletionIsNoOp() throws Exception {
+        ManualClock clock = new ManualClock(0L);
+        RecordingEventManager eventManager = new RecordingEventManager();
+        NoOpScheduledExecutor executor = new NoOpScheduledExecutor();
+        CoordinatorEventProcessor eventProcessor =
+                buildCoordinatorEventProcessor(new Configuration());
+
+        RebalanceManager manager =
+                new RebalanceManager(
+                        eventProcessor, zookeeperClient, eventManager, clock, 
executor);
+        manager.startup();
+
+        TableBucket tb1 = new TableBucket(1L, 0);
+        Map<TableBucket, RebalancePlanForBucket> plan = new HashMap<>();
+        plan.put(
+                tb1,
+                new RebalancePlanForBucket(
+                        tb1, 0, 0, Arrays.asList(0, 1, 2), Arrays.asList(0, 1, 
2)));
+
+        zookeeperClient.registerRebalanceTask(
+                new RebalanceTask("completion-test", NOT_STARTED, plan));
+        manager.registerRebalance("completion-test", plan, NOT_STARTED);
+
+        // The task completes normally before timeout.
+        manager.finishRebalanceTask(tb1, COMPLETED);
+
+        // Now the timeout fires, but the task is already done.
+        clock.advanceTime(Duration.ofMillis(130_000));
+        manager.checkTimeout();
+
+        // No timeout event should be enqueued because inflightTaskStartMs was 
cleared.
+        assertThat(eventManager.events).isEmpty();
+
+        manager.close();
+    }
+
+    @Test
+    void testTimeoutTreatsTaskAsCompleted() throws Exception {
+        ManualClock clock = new ManualClock(0L);
+        RecordingEventManager eventManager = new RecordingEventManager();
+        NoOpScheduledExecutor executor = new NoOpScheduledExecutor();
+        CoordinatorEventProcessor eventProcessor =
+                buildCoordinatorEventProcessor(new Configuration());
+
+        RebalanceManager manager =
+                new RebalanceManager(
+                        eventProcessor, zookeeperClient, eventManager, clock, 
executor);
+        manager.startup();
+
+        TableBucket tb1 = new TableBucket(1L, 0);
+        TableBucket tb2 = new TableBucket(1L, 1);
+        Map<TableBucket, RebalancePlanForBucket> plan = new HashMap<>();
+        plan.put(
+                tb1,
+                new RebalancePlanForBucket(
+                        tb1, 0, 0, Arrays.asList(0, 1, 2), Arrays.asList(0, 1, 
2)));
+        plan.put(
+                tb2,
+                new RebalancePlanForBucket(
+                        tb2, 0, 0, Arrays.asList(0, 1, 2), Arrays.asList(0, 1, 
2)));
+
+        zookeeperClient.registerRebalanceTask(
+                new RebalanceTask("completed-test", NOT_STARTED, plan));
+        manager.registerRebalance("completed-test", plan, NOT_STARTED);
+
+        // Timeout fires.
+        clock.advanceTime(Duration.ofMillis(130_000));
+        manager.checkTimeout();
+
+        // Simulate the coordinator event thread processing the timeout event.
+        assertThat(eventManager.events).hasSize(1);
+        RebalanceTaskTimeoutEvent timeoutEvent =
+                (RebalanceTaskTimeoutEvent) eventManager.events.get(0);
+        manager.finishRebalanceTask(timeoutEvent.getTableBucket(), TIMEOUT);
+
+        // The timed-out task should be in finishedRebalanceTasks as TIMEOUT.
+        assertThat(manager.hasInProgressRebalance()).isTrue();
+        RebalanceResultForBucket result =
+                
manager.listRebalanceProgress(null).progressForBucketMap().get(tb1);
+        assertThat(result.status()).isEqualTo(TIMEOUT);
+
+        manager.close();
+    }
+
     private CoordinatorEventProcessor 
buildCoordinatorEventProcessor(Configuration conf) {
         return new CoordinatorEventProcessor(
                 zookeeperClient,
@@ -156,4 +313,34 @@ public class RebalanceManagerTest {
                 metadataManager,
                 kvSnapshotLeaseManager);
     }
+
+    /** Records events put into the coordinator event queue. */
+    private static final class RecordingEventManager implements EventManager {
+        final List<CoordinatorEvent> events = new ArrayList<>();
+
+        @Override
+        public void put(CoordinatorEvent event) {
+            events.add(event);
+        }
+    }
+
+    /**
+     * A scheduled executor that never actually runs scheduled tasks, so tests 
retain full control
+     * over when {@link RebalanceManager#checkTimeout()} is invoked.
+     */
+    private static final class NoOpScheduledExecutor extends 
ScheduledThreadPoolExecutor {
+
+        NoOpScheduledExecutor() {
+            super(0);
+        }
+
+        @Override
+        public java.util.concurrent.ScheduledFuture<?> scheduleWithFixedDelay(
+                Runnable command,
+                long initialDelay,
+                long delay,
+                java.util.concurrent.TimeUnit unit) {
+            return null;
+        }
+    }
 }
diff --git a/website/docs/maintenance/operations/rebalance.md 
b/website/docs/maintenance/operations/rebalance.md
index 48d89ac40..418b7c585 100644
--- a/website/docs/maintenance/operations/rebalance.md
+++ b/website/docs/maintenance/operations/rebalance.md
@@ -110,6 +110,7 @@ Rebalance statuses:
 - **COMPLETED**: The rebalance has successfully completed
 - **FAILED**: The rebalance has failed
 - **CANCELED**: The rebalance has been canceled
+- **TIMEOUT**: The rebalance task timed out (e.g., ISR could not converge 
within the timeout period)
 
 ### 4. Cancel Rebalance (If Needed)
 

Reply via email to