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

wuchong 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 6320400ff [server] Reject KV leader promotion under disk write lock 
(#3539)
6320400ff is described below

commit 6320400ff662ae36ac76fbb755748d246b44ea28
Author: yunhong <[email protected]>
AuthorDate: Tue Jul 7 10:15:25 2026 +0800

    [server] Reject KV leader promotion under disk write lock (#3539)
---
 .../org/apache/fluss/config/ConfigOptions.java     |  11 +
 .../server/coordinator/AutoPartitionManager.java   |   2 +
 .../server/coordinator/CoordinatorContext.java     |  34 +++
 .../coordinator/CoordinatorEventProcessor.java     | 109 +++++++++
 .../server/coordinator/CoordinatorServer.java      |  22 ++
 .../coordinator/LakeTableTieringManager.java       |   2 +
 .../coordinator/TableLifecycleThrottler.java       |   2 +
 .../coordinator/event/RetryOfflineLeaderEvent.java |  21 ++
 .../coordinator/lease/KvSnapshotLeaseManager.java  |   3 +
 .../producer/ProducerOffsetsManager.java           |   2 +
 .../coordinator/rebalance/RebalanceManager.java    |   2 +
 .../fluss/server/replica/ReplicaManager.java       |  17 +-
 .../server/coordinator/CoordinatorContextTest.java |  44 ++++
 .../coordinator/CoordinatorEventProcessorTest.java | 270 ++++++++++++++++++++-
 .../rebalance/RebalanceManagerTest.java            |  10 +
 .../statemachine/TableBucketStateMachineTest.java  |  15 ++
 .../fluss/server/replica/ReplicaManagerTest.java   |  58 +++++
 website/docs/maintenance/configuration.md          |  13 +-
 18 files changed, 628 insertions(+), 9 deletions(-)

diff --git 
a/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java 
b/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
index aca5edaa8..c34c3626f 100644
--- a/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
+++ b/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
@@ -221,6 +221,17 @@ public class ConfigOptions {
                                             + "TableLifecycleThrottler scans 
in-flight drops for "
                                             + "timeouts.");
 
+    public static final ConfigOption<Duration> 
COORDINATOR_OFFLINE_LEADER_RETRY_DELAY =
+            key("coordinator.offline-leader.retry-delay")
+                    .durationType()
+                    .defaultValue(Duration.ofMinutes(1))
+                    .withDescription(
+                            "The delay before the coordinator retries offline 
leaders on live "
+                                    + "tablet servers after they are marked 
offline. This lets a "
+                                    + "leader that was rejected because of 
temporary tablet-server "
+                                    + "conditions, such as disk write 
protection, become electable "
+                                    + "again after recovery.");
+
     public static final ConfigOption<Boolean> LOG_TABLE_ALLOW_CREATION =
             key("allow.create.log.tables")
                     .booleanType()
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/AutoPartitionManager.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/AutoPartitionManager.java
index 7ac0b2fcf..0a817c0e1 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/AutoPartitionManager.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/AutoPartitionManager.java
@@ -122,6 +122,8 @@ public class AutoPartitionManager implements AutoCloseable {
                 remoteDirDynamicLoader,
                 conf,
                 SystemClock.getInstance(),
+                // TODO: Reuse the CoordinatorServer shared scheduler for this 
lightweight
+                // coordinator periodic task instead of creating a 
component-owned scheduler.
                 Executors.newScheduledThreadPool(
                         1, new 
ExecutorThreadFactory("periodic-auto-partition-manager")));
     }
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorContext.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorContext.java
index dc0fa58a3..6a5710ad3 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorContext.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorContext.java
@@ -225,6 +225,40 @@ public class CoordinatorContext {
         replicasOnOffline.remove(serverId);
     }
 
+    /** Removes the offline marker for one table bucket on the given tablet 
server. */
+    public void removeOfflineBucketInServer(TableBucket tableBucket, int 
serverId) {
+        Set<TableBucket> tableBuckets = replicasOnOffline.get(serverId);
+        if (tableBuckets == null) {
+            return;
+        }
+        tableBuckets.remove(tableBucket);
+        if (tableBuckets.isEmpty()) {
+            replicasOnOffline.remove(serverId);
+        }
+    }
+
+    /**
+     * Returns offline replicas that are on live tablet servers.
+     *
+     * <p>The {@code replicasOnOffline} map is part of {@link 
#isReplicaOnline(int, TableBucket)},
+     * so replicas in it are filtered out from leader election even when their 
tablet server is
+     * still live.
+     */
+    public Set<TableBucketReplica> offlineReplicasOnLiveTabletServers() {
+        Set<Integer> liveTabletServers = liveTabletServerSet();
+        Set<TableBucketReplica> offlineReplicas = new HashSet<>();
+        for (Map.Entry<Integer, Set<TableBucket>> entry : 
replicasOnOffline.entrySet()) {
+            int serverId = entry.getKey();
+            if (!liveTabletServers.contains(serverId)) {
+                continue;
+            }
+            for (TableBucket tableBucket : entry.getValue()) {
+                offlineReplicas.add(new TableBucketReplica(tableBucket, 
serverId));
+            }
+        }
+        return offlineReplicas;
+    }
+
     // ---- Pending leader activation tracking (for Cluster Health API) ----
 
     public void addPendingLeaderActivation(TableBucket bucket) {
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 537e2df63..803b657f4 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
@@ -88,6 +88,7 @@ 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.ResumeDropEvent;
+import org.apache.fluss.server.coordinator.event.RetryOfflineLeaderEvent;
 import org.apache.fluss.server.coordinator.event.SchemaChangeEvent;
 import org.apache.fluss.server.coordinator.event.TableRegistrationChangeEvent;
 import 
org.apache.fluss.server.coordinator.event.watcher.CoordinatorChangeWatcher;
@@ -126,6 +127,7 @@ import 
org.apache.fluss.server.zk.data.lake.LakeTableSnapshot;
 import org.apache.fluss.utils.AutoPartitionStrategy;
 import org.apache.fluss.utils.clock.Clock;
 import org.apache.fluss.utils.clock.SystemClock;
+import org.apache.fluss.utils.concurrent.Scheduler;
 import org.apache.fluss.utils.types.Tuple2;
 
 import org.slf4j.Logger;
@@ -146,6 +148,7 @@ import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledFuture;
 import java.util.stream.Collectors;
 
 import static 
org.apache.fluss.server.coordinator.statemachine.BucketState.OfflineBucket;
@@ -188,8 +191,11 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
     private final String internalListenerName;
     private final CoordinatorMetricGroup coordinatorMetricGroup;
     private final RebalanceManager rebalanceManager;
+    private final Scheduler scheduler;
+    private final long offlineLeaderRetryDelayMs;
     private final CompletedSnapshotStoreManager completedSnapshotStoreManager;
     private final LakeTableHelper lakeTableHelper;
+    private ScheduledFuture<?> offlineLeaderRetryTask;
 
     public CoordinatorEventProcessor(
             ZooKeeperClient zooKeeperClient,
@@ -203,6 +209,7 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
             ExecutorService ioExecutor,
             MetadataManager metadataManager,
             KvSnapshotLeaseManager kvSnapshotLeaseManager,
+            Scheduler scheduler,
             Clock clock) {
         this.zooKeeperClient = zooKeeperClient;
         this.serverMetadataCache = serverMetadataCache;
@@ -262,6 +269,16 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
         this.rebalanceManager =
                 new RebalanceManager(
                         this, zooKeeperClient, coordinatorEventManager, 
SystemClock.getInstance());
+        this.offlineLeaderRetryDelayMs =
+                
conf.get(ConfigOptions.COORDINATOR_OFFLINE_LEADER_RETRY_DELAY).toMillis();
+        if (offlineLeaderRetryDelayMs <= 0) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "%s must be positive, but was %d ms.",
+                            
ConfigOptions.COORDINATOR_OFFLINE_LEADER_RETRY_DELAY.key(),
+                            offlineLeaderRetryDelayMs));
+        }
+        this.scheduler = scheduler;
         this.ioExecutor = ioExecutor;
         this.lakeTableHelper =
                 new LakeTableHelper(zooKeeperClient, 
conf.getString(ConfigOptions.REMOTE_DATA_DIR));
@@ -284,6 +301,11 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
         return lifecycleThrottler;
     }
 
+    @VisibleForTesting
+    boolean hasOfflineLeaderRetryTaskScheduled() {
+        return offlineLeaderRetryTask != null;
+    }
+
     public void startup() {
         
coordinatorContext.setCoordinatorServerInfo(getCoordinatorServerInfo());
         // start watchers first so that we won't miss node in zk;
@@ -325,6 +347,7 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
     }
 
     public void shutdown() {
+        clearOfflineLeaderRetryTask();
         // close the event manager
         coordinatorEventManager.close();
         rebalanceManager.close();
@@ -587,6 +610,39 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
         tabletServerChangeWatcher.stop();
     }
 
+    private void scheduleOfflineLeaderRetryIfNeeded() {
+        if (offlineLeaderRetryTask != null) {
+            return;
+        }
+
+        // Keep CoordinatorContext access on the coordinator event thread. The 
scheduled task only
+        // enqueues a retry event.
+        Set<TableBucketReplica> retryableOfflineReplicas =
+                retryableOfflineReplicasOnLiveTabletServers();
+        if (retryableOfflineReplicas.isEmpty()) {
+            return;
+        }
+
+        offlineLeaderRetryTask =
+                scheduler.scheduleOnce(
+                        "offline-leader-retry",
+                        this::enqueueRetryOfflineLeaderEventSafely,
+                        offlineLeaderRetryDelayMs);
+        LOG.info(
+                "Offline leader retry task scheduled after {} ms for {} 
replicas: {}.",
+                offlineLeaderRetryDelayMs,
+                retryableOfflineReplicas.size(),
+                retryableOfflineReplicas);
+    }
+
+    private void enqueueRetryOfflineLeaderEventSafely() {
+        try {
+            coordinatorEventManager.put(new RetryOfflineLeaderEvent());
+        } catch (Throwable t) {
+            LOG.warn("Failed to enqueue retry offline leader event.", t);
+        }
+    }
+
     @Override
     public void process(CoordinatorEvent event) {
         if (event instanceof CreateTableEvent) {
@@ -605,6 +661,8 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
         } else if (event instanceof NotifyLeaderAndIsrResponseReceivedEvent) {
             processNotifyLeaderAndIsrResponseReceivedEvent(
                     (NotifyLeaderAndIsrResponseReceivedEvent) event);
+        } else if (event instanceof RetryOfflineLeaderEvent) {
+            processRetryOfflineLeader();
         } else if (event instanceof DeleteReplicaResponseReceivedEvent) {
             
processDeleteReplicaResponseReceived((DeleteReplicaResponseReceivedEvent) 
event);
         } else if (event instanceof NewCoordinatorEvent) {
@@ -702,6 +760,56 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
         }
     }
 
+    private void processRetryOfflineLeader() {
+        // This event consumes the currently scheduled one-shot retry. If the 
NotifyLeaderAndIsr
+        // request sent below is rejected by the target tablet server again, 
its response handler
+        // will call onReplicaBecomeOffline(), put the offline marker back, 
and schedule the next
+        // one-shot retry.
+        clearOfflineLeaderRetryTask();
+        Set<TableBucketReplica> offlineReplicas = 
retryableOfflineReplicasOnLiveTabletServers();
+
+        if (offlineReplicas.isEmpty()) {
+            return;
+        }
+
+        LOG.info(
+                "Retrying {} offline replicas on live tablet servers before 
triggering "
+                        + "offline leader election: {}.",
+                offlineReplicas.size(),
+                offlineReplicas);
+
+        // replicasOnOffline is checked by CoordinatorContext#isReplicaOnline 
and therefore also
+        // by leader election. Remove the marker before probing the live 
server again; if the
+        // server still cannot become leader, NotifyLeaderAndIsr will fail and 
the replica will be
+        // put back to OfflineReplica. TODO: once standby replicas maintain 
local KV snapshots,
+        // distinguish standby promotion from fresh snapshot download when 
retrying KV leaders.
+        for (TableBucketReplica offlineReplica : offlineReplicas) {
+            coordinatorContext.removeOfflineBucketInServer(
+                    offlineReplica.getTableBucket(), 
offlineReplica.getReplica());
+        }
+
+        replicaStateMachine.handleStateChanges(offlineReplicas, OnlineReplica);
+        tableBucketStateMachine.triggerOnlineBucketStateChange();
+    }
+
+    private Set<TableBucketReplica> 
retryableOfflineReplicasOnLiveTabletServers() {
+        // offlineReplicasOnLiveTabletServers() returns replicas from 
replicasOnOffline, which is
+        // only a leader-election exclusion marker. The replica state may have 
changed after the
+        // marker was added, for example because deletion or migration 
started. Retry only replicas
+        // that are still OfflineReplica in the state machine.
+        return coordinatorContext.offlineReplicasOnLiveTabletServers().stream()
+                .filter(replica -> 
!coordinatorContext.isToBeDeleted(replica.getTableBucket()))
+                .filter(replica -> coordinatorContext.getReplicaState(replica) 
== OfflineReplica)
+                .collect(Collectors.toSet());
+    }
+
+    private void clearOfflineLeaderRetryTask() {
+        if (offlineLeaderRetryTask != null) {
+            offlineLeaderRetryTask.cancel(false);
+            offlineLeaderRetryTask = null;
+        }
+    }
+
     private void processCreateTable(CreateTableEvent createTableEvent) {
         long tableId = createTableEvent.getTableInfo().getTableId();
         // skip the table if it already exists
@@ -1114,6 +1222,7 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
         // kafka, todo: but we may need to select another tablet server to put
         // replica
         replicaStateMachine.handleStateChanges(offlineReplicas, 
OfflineReplica);
+        scheduleOfflineLeaderRetryIfNeeded();
     }
 
     private void processNewCoordinator(NewCoordinatorEvent 
newCoordinatorEvent) {
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorServer.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorServer.java
index 2c4fcf5e4..549eaf657 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorServer.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorServer.java
@@ -51,7 +51,9 @@ import org.apache.fluss.utils.ExecutorUtils;
 import org.apache.fluss.utils.clock.Clock;
 import org.apache.fluss.utils.clock.SystemClock;
 import org.apache.fluss.utils.concurrent.ExecutorThreadFactory;
+import org.apache.fluss.utils.concurrent.FlussScheduler;
 import org.apache.fluss.utils.concurrent.FutureUtils;
+import org.apache.fluss.utils.concurrent.Scheduler;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -69,6 +71,7 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 
+import static org.apache.fluss.config.ConfigOptions.BACKGROUND_THREADS;
 import static 
org.apache.fluss.config.FlussConfigUtils.validateCoordinatorConfigs;
 
 /**
@@ -136,6 +139,10 @@ public class CoordinatorServer extends ServerBase {
     @GuardedBy("lock")
     private LakeTableTieringManager lakeTableTieringManager;
 
+    /** Shared scheduler for lightweight coordinator background tasks. */
+    @GuardedBy("lock")
+    private Scheduler scheduler;
+
     @GuardedBy("lock")
     private ExecutorService ioExecutor;
 
@@ -214,6 +221,9 @@ public class CoordinatorServer extends ServerBase {
             LOG.info("Initializing Coordinator services as standby.");
             List<Endpoint> endpoints = Endpoint.loadBindEndpoints(conf, 
ServerType.COORDINATOR);
 
+            this.scheduler = new FlussScheduler(conf.get(BACKGROUND_THREADS));
+            scheduler.startup();
+
             // for metrics
             this.metricRegistry = MetricRegistry.create(conf, pluginManager);
             this.serverMetricGroup =
@@ -337,6 +347,7 @@ public class CoordinatorServer extends ServerBase {
                             ioExecutor,
                             metadataManager,
                             kvSnapshotLeaseManager,
+                            scheduler,
                             clock);
             coordinatorEventProcessor.startup();
 
@@ -539,6 +550,17 @@ public class CoordinatorServer extends ServerBase {
         synchronized (lock) {
             Throwable exception = null;
 
+            try {
+                // We must shut down the scheduler early because otherwise, 
the scheduler could
+                // touch other resources that might have been shutdown and 
cause exceptions.
+                if (scheduler != null) {
+                    scheduler.shutdown();
+                    scheduler = null;
+                }
+            } catch (Throwable t) {
+                exception = ExceptionUtils.firstOrSuppressed(t, exception);
+            }
+
             try {
                 if (serverMetricGroup != null) {
                     serverMetricGroup.close();
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/LakeTableTieringManager.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/LakeTableTieringManager.java
index 60589a999..e281381de 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/LakeTableTieringManager.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/LakeTableTieringManager.java
@@ -158,6 +158,8 @@ public class LakeTableTieringManager implements 
AutoCloseable {
     public LakeTableTieringManager(LakeTieringMetricGroup 
lakeTieringMetricGroup) {
         this(
                 new DefaultTimer("delay lake tiering", 1_000, 20),
+                // TODO: Reuse the CoordinatorServer shared scheduler for this 
lightweight
+                // coordinator timeout checker instead of creating a 
component-owned scheduler.
                 Executors.newSingleThreadScheduledExecutor(
                         new 
ExecutorThreadFactory("fluss-lake-tiering-timeout-checker")),
                 SystemClock.getInstance(),
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/TableLifecycleThrottler.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/TableLifecycleThrottler.java
index d7b6a84dd..00e4c8e0d 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/TableLifecycleThrottler.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/TableLifecycleThrottler.java
@@ -107,6 +107,8 @@ public class TableLifecycleThrottler implements 
AutoCloseable {
         this(
                 eventManager,
                 clock,
+                // TODO: Reuse the CoordinatorServer shared scheduler for this 
lightweight
+                // coordinator timeout checker instead of creating a 
component-owned scheduler.
                 Executors.newScheduledThreadPool(
                         1, new 
ExecutorThreadFactory("lifecycle-throttler-timeout")),
                 
conf.get(ConfigOptions.COORDINATOR_LIFECYCLE_THROTTLER_INFLIGHT_TIMEOUT).toMillis(),
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/RetryOfflineLeaderEvent.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/RetryOfflineLeaderEvent.java
new file mode 100644
index 000000000..89d8d2858
--- /dev/null
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/RetryOfflineLeaderEvent.java
@@ -0,0 +1,21 @@
+/*
+ * 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;
+
+/** An event for retrying offline leaders on live tablet servers. */
+public class RetryOfflineLeaderEvent implements CoordinatorEvent {}
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/lease/KvSnapshotLeaseManager.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/lease/KvSnapshotLeaseManager.java
index 6c79a668e..99a1c28b1 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/lease/KvSnapshotLeaseManager.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/lease/KvSnapshotLeaseManager.java
@@ -92,6 +92,9 @@ public class KvSnapshotLeaseManager {
                 leaseExpirationCheckInterval,
                 zkClient,
                 remoteDataDir,
+                // TODO: Reuse the CoordinatorServer shared scheduler for this 
lightweight
+                // coordinator lease expiration task instead of creating a 
component-owned
+                // scheduler.
                 Executors.newScheduledThreadPool(
                         1, new 
ExecutorThreadFactory("kv-snapshot-lease-cleaner")),
                 clock,
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/producer/ProducerOffsetsManager.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/producer/ProducerOffsetsManager.java
index e40fcb24c..68a69d527 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/producer/ProducerOffsetsManager.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/producer/ProducerOffsetsManager.java
@@ -88,6 +88,8 @@ public class ProducerOffsetsManager implements AutoCloseable {
         this.offsetsStore = offsetsStore;
         this.defaultTtlMs = defaultTtlMs;
         this.cleanupIntervalMs = cleanupIntervalMs;
+        // TODO: Reuse the CoordinatorServer shared scheduler for this 
lightweight coordinator
+        // cleanup task instead of creating a component-owned scheduler.
         this.cleanupScheduler =
                 Executors.newSingleThreadScheduledExecutor(
                         new 
ExecutorThreadFactory("producer-snapshot-cleanup"));
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 9f2f350bd..cc29b982b 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
@@ -131,6 +131,8 @@ public class RebalanceManager {
                 zkClient,
                 eventManager,
                 clock,
+                // TODO: Reuse the CoordinatorServer shared scheduler for this 
lightweight
+                // coordinator timeout checker instead of creating a 
component-owned scheduler.
                 Executors.newScheduledThreadPool(
                         1, new ExecutorThreadFactory("rebalance-timeout")));
     }
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java
index 280e34124..29adf341d 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java
@@ -1824,7 +1824,11 @@ public class ReplicaManager implements 
ServerReconfigurable {
         if (requestLeaderEpoch >= currentLeaderEpoch) {
             if (data.getReplicas().contains(serverId)) {
                 int leaderId = data.getLeader();
-                return leaderId == serverId;
+                boolean becomeLeader = leaderId == serverId;
+                if (becomeLeader) {
+                    ensureWritableForNewKvLeader(replica, requestLeaderEpoch);
+                }
+                return becomeLeader;
             } else {
                 String errorMessage =
                         String.format(
@@ -1845,6 +1849,17 @@ public class ReplicaManager implements 
ServerReconfigurable {
         }
     }
 
+    private void ensureWritableForNewKvLeader(Replica replica, int 
requestLeaderEpoch) {
+        if (!replica.isKvTable() || requestLeaderEpoch <= 
replica.getLeaderEpoch()) {
+            return;
+        }
+
+        // TODO: Once standby replicas maintain local KV snapshots, allow 
promoting a standby
+        // replica while disk write protection is active because it should not 
need to download a
+        // large remote snapshot during make-leader.
+        localDiskManager.ensureWritable();
+    }
+
     /**
      * If all the following conditions are true, we need to put a delayed 
write operation into the
      * delayed write manager and wait for replication to complete.
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorContextTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorContextTest.java
index b69fe5fa3..557d75a6a 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorContextTest.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorContextTest.java
@@ -22,6 +22,7 @@ import org.apache.fluss.cluster.ServerType;
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.metadata.Schema;
 import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableBucketReplica;
 import org.apache.fluss.metadata.TableDescriptor;
 import org.apache.fluss.metadata.TableInfo;
 import org.apache.fluss.metadata.TablePath;
@@ -78,6 +79,41 @@ class CoordinatorContextTest {
         assertThat(context.getLakeTableCount()).isEqualTo(2);
     }
 
+    @Test
+    void testOfflineReplicasOnLiveTabletServersOnlyReturnsLiveServers() {
+        CoordinatorContext context = new 
CoordinatorContext(ZkEpoch.INITIAL_EPOCH);
+        TableBucket liveBucket = new TableBucket(1L, 0);
+        TableBucket deadBucket = new TableBucket(1L, 1);
+
+        context.setLiveTabletServers(Arrays.asList(createTabletServer(0), 
createTabletServer(1)));
+        context.addOfflineBucketInServer(liveBucket, 0);
+        context.addOfflineBucketInServer(deadBucket, 2);
+
+        assertThat(context.offlineReplicasOnLiveTabletServers())
+                .containsExactly(new TableBucketReplica(liveBucket, 0));
+    }
+
+    @Test
+    void testRemoveOfflineBucketInServerForBucket() {
+        CoordinatorContext context = new 
CoordinatorContext(ZkEpoch.INITIAL_EPOCH);
+        TableBucket tb1 = new TableBucket(1L, 0);
+        TableBucket tb2 = new TableBucket(1L, 1);
+
+        
context.setLiveTabletServers(Collections.singletonList(createTabletServer(0)));
+        context.addOfflineBucketInServer(tb1, 0);
+        context.addOfflineBucketInServer(tb2, 0);
+
+        context.removeOfflineBucketInServer(tb1, 0);
+        assertThat(context.isReplicaOnline(0, tb1)).isTrue();
+        assertThat(context.isReplicaOnline(0, tb2)).isFalse();
+        assertThat(context.offlineReplicasOnLiveTabletServers())
+                .containsExactly(new TableBucketReplica(tb2, 0));
+
+        context.removeOfflineBucketInServer(tb2, 0);
+        assertThat(context.isReplicaOnline(0, tb2)).isTrue();
+        assertThat(context.offlineReplicasOnLiveTabletServers()).isEmpty();
+    }
+
     // ---- Pending Leader Activation Tracking Tests ----
 
     @Test
@@ -215,4 +251,12 @@ class CoordinatorContextTest {
                 System.currentTimeMillis(),
                 System.currentTimeMillis());
     }
+
+    private ServerInfo createTabletServer(int serverId) {
+        return new ServerInfo(
+                serverId,
+                "RACK" + serverId,
+                Endpoint.fromListenersString("CLIENT://host" + serverId + 
":9124"),
+                ServerType.TABLET_SERVER);
+    }
 }
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java
index 001a90f74..d92953cc2 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java
@@ -46,12 +46,16 @@ import 
org.apache.fluss.rpc.messages.NotifyLeaderAndIsrRequest;
 import org.apache.fluss.rpc.messages.NotifyLeaderAndIsrResponse;
 import org.apache.fluss.rpc.messages.NotifyRemoteLogOffsetsRequest;
 import org.apache.fluss.rpc.messages.UpdateMetadataRequest;
+import org.apache.fluss.rpc.protocol.ApiError;
 import org.apache.fluss.rpc.protocol.ApiKeys;
+import org.apache.fluss.rpc.protocol.Errors;
 import org.apache.fluss.server.coordinator.event.AccessContextEvent;
 import org.apache.fluss.server.coordinator.event.AdjustIsrReceivedEvent;
 import org.apache.fluss.server.coordinator.event.CommitKvSnapshotEvent;
 import org.apache.fluss.server.coordinator.event.CommitRemoteLogManifestEvent;
 import org.apache.fluss.server.coordinator.event.CoordinatorEventManager;
+import 
org.apache.fluss.server.coordinator.event.NotifyLeaderAndIsrResponseReceivedEvent;
+import org.apache.fluss.server.coordinator.event.RetryOfflineLeaderEvent;
 import org.apache.fluss.server.coordinator.lease.KvSnapshotLeaseManager;
 import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader;
 import org.apache.fluss.server.coordinator.statemachine.BucketState;
@@ -59,6 +63,7 @@ import 
org.apache.fluss.server.coordinator.statemachine.ReplicaState;
 import org.apache.fluss.server.entity.AdjustIsrResultForBucket;
 import org.apache.fluss.server.entity.CommitKvSnapshotData;
 import org.apache.fluss.server.entity.CommitRemoteLogManifestData;
+import org.apache.fluss.server.entity.NotifyLeaderAndIsrResultForBucket;
 import org.apache.fluss.server.entity.TablePropertyChanges;
 import org.apache.fluss.server.kv.snapshot.CompletedSnapshot;
 import 
org.apache.fluss.server.kv.snapshot.ZooKeeperCompletedSnapshotHandleStore;
@@ -87,6 +92,8 @@ import org.apache.fluss.types.DataTypes;
 import org.apache.fluss.utils.ExceptionUtils;
 import org.apache.fluss.utils.clock.SystemClock;
 import org.apache.fluss.utils.concurrent.ExecutorThreadFactory;
+import org.apache.fluss.utils.concurrent.FlussScheduler;
+import org.apache.fluss.utils.concurrent.Scheduler;
 import org.apache.fluss.utils.types.Tuple2;
 
 import org.junit.jupiter.api.AfterEach;
@@ -109,6 +116,7 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentLinkedDeque;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.stream.Collectors;
@@ -165,6 +173,7 @@ class CoordinatorEventProcessorTest {
     private CompletedSnapshotStoreManager completedSnapshotStoreManager;
     private CoordinatorMetadataCache serverMetadataCache;
     private KvSnapshotLeaseManager kvSnapshotLeaseManager;
+    private Scheduler scheduler;
     private String remoteDataDir;
 
     @BeforeAll
@@ -222,6 +231,9 @@ class CoordinatorEventProcessorTest {
                         TestingMetricGroups.COORDINATOR_METRICS);
         kvSnapshotLeaseManager.start();
 
+        scheduler = new FlussScheduler(1);
+        scheduler.startup();
+
         eventProcessor = buildCoordinatorEventProcessor();
         eventProcessor.startup();
         metadataManager.createDatabase(
@@ -230,8 +242,13 @@ class CoordinatorEventProcessorTest {
     }
 
     @AfterEach
-    void afterEach() {
-        eventProcessor.shutdown();
+    void afterEach() throws Exception {
+        if (eventProcessor != null) {
+            eventProcessor.shutdown();
+        }
+        if (scheduler != null) {
+            scheduler.shutdown();
+        }
         metadataManager.dropDatabase(defaultDatabase, false, true);
         // clear the assignment info for all tables;
         
ZOO_KEEPER_EXTENSION_WRAPPER.getCustomExtension().cleanupPath(TableIdsZNode.path());
@@ -1112,6 +1129,234 @@ class CoordinatorEventProcessorTest {
         
assertThat(resultForBucketMap.values()).allMatch(AdjustIsrResultForBucket::succeeded);
     }
 
+    @Test
+    void testDiskWriteLockedNotifyLeaderResponseMarksReplicaOffline() throws 
Exception {
+        initCoordinatorChannel();
+        TablePath tablePath =
+                TablePath.of(defaultDatabase, 
"disk_write_locked_notify_leader_response");
+        int nBuckets = 3;
+        int replicationFactor = 3;
+        TableAssignment tableAssignment =
+                generateAssignment(
+                        nBuckets,
+                        replicationFactor,
+                        new TabletServerInfo[] {
+                            new TabletServerInfo(0, "rack0"),
+                            new TabletServerInfo(1, "rack1"),
+                            new TabletServerInfo(2, "rack2")
+                        });
+        long tableId =
+                metadataManager.createTable(
+                        tablePath, remoteDataDir, TEST_TABLE, tableAssignment, 
false);
+        verifyTableCreated(tableId, tableAssignment, nBuckets, 
replicationFactor);
+
+        TableBucket tableBucket = new TableBucket(tableId, 0);
+        int leader =
+                
tableAssignment.getBucketAssignment(tableBucket.getBucket()).getReplicas().get(0);
+        TableBucketReplica tableBucketReplica = new 
TableBucketReplica(tableBucket, leader);
+
+        eventProcessor
+                .getCoordinatorEventManager()
+                .put(
+                        new NotifyLeaderAndIsrResponseReceivedEvent(
+                                Collections.singletonList(
+                                        new NotifyLeaderAndIsrResultForBucket(
+                                                tableBucket,
+                                                new ApiError(
+                                                        
Errors.DISK_WRITE_LOCKED,
+                                                        "disk write locked"))),
+                                leader));
+
+        fromCtx(
+                ctx -> {
+                    
assertThat(ctx.getReplicaState(tableBucketReplica)).isEqualTo(OfflineReplica);
+                    assertThat(ctx.isReplicaOnline(leader, 
tableBucket)).isFalse();
+                    return null;
+                });
+    }
+
+    @Test
+    void testRetryOfflineLeaderEventRetriesOfflineReplicaOnLiveServer() throws 
Exception {
+        
assertThat(eventProcessor.hasOfflineLeaderRetryTaskScheduled()).isFalse();
+        initCoordinatorChannel();
+        TablePath tablePath = TablePath.of(defaultDatabase, 
"retry_offline_leader_on_live_server");
+        int nBuckets = 3;
+        int replicationFactor = 1;
+        TableAssignment tableAssignment =
+                generateAssignment(
+                        nBuckets,
+                        replicationFactor,
+                        new TabletServerInfo[] {new TabletServerInfo(0, 
"rack0")});
+        long tableId =
+                metadataManager.createTable(
+                        tablePath,
+                        remoteDataDir,
+                        TEST_TABLE.withReplicationFactor(replicationFactor),
+                        tableAssignment,
+                        false);
+        verifyTableCreated(tableId, tableAssignment, nBuckets, 
replicationFactor);
+
+        TableBucket tableBucket = new TableBucket(tableId, 0);
+        int leader =
+                
tableAssignment.getBucketAssignment(tableBucket.getBucket()).getReplicas().get(0);
+        TableBucketReplica tableBucketReplica = new 
TableBucketReplica(tableBucket, leader);
+
+        eventProcessor
+                .getCoordinatorEventManager()
+                .put(
+                        new NotifyLeaderAndIsrResponseReceivedEvent(
+                                Collections.singletonList(
+                                        new NotifyLeaderAndIsrResultForBucket(
+                                                tableBucket,
+                                                new ApiError(
+                                                        
Errors.DISK_WRITE_LOCKED,
+                                                        "disk write locked"))),
+                                leader));
+
+        retryVerifyContext(
+                ctx -> {
+                    
assertThat(ctx.getReplicaState(tableBucketReplica)).isEqualTo(OfflineReplica);
+                    
assertThat(ctx.getBucketState(tableBucket)).isEqualTo(OfflineBucket);
+                    assertThat(ctx.isReplicaOnline(leader, 
tableBucket)).isFalse();
+                });
+        
assertThat(eventProcessor.hasOfflineLeaderRetryTaskScheduled()).isTrue();
+
+        eventProcessor.getCoordinatorEventManager().put(new 
RetryOfflineLeaderEvent());
+
+        retryVerifyContext(
+                ctx -> {
+                    assertThat(ctx.isReplicaOnline(leader, 
tableBucket)).isTrue();
+                    
assertThat(ctx.getReplicaState(tableBucketReplica)).isEqualTo(OnlineReplica);
+                    
assertThat(ctx.getBucketState(tableBucket)).isEqualTo(OnlineBucket);
+                    
assertThat(ctx.getBucketLeaderAndIsr(tableBucket).get().leader())
+                            .isEqualTo(leader);
+                });
+        
assertThat(eventProcessor.hasOfflineLeaderRetryTaskScheduled()).isFalse();
+    }
+
+    @Test
+    void testRetryOfflineLeaderEventKeepsReplicaOfflineWhenRetryStillFails() 
throws Exception {
+        initCoordinatorChannel();
+        TablePath tablePath = TablePath.of(defaultDatabase, 
"retry_offline_leader_still_fails");
+        int nBuckets = 3;
+        int replicationFactor = 1;
+        TableAssignment tableAssignment =
+                generateAssignment(
+                        nBuckets,
+                        replicationFactor,
+                        new TabletServerInfo[] {new TabletServerInfo(0, 
"rack0")});
+        long tableId =
+                metadataManager.createTable(
+                        tablePath,
+                        remoteDataDir,
+                        TEST_TABLE.withReplicationFactor(replicationFactor),
+                        tableAssignment,
+                        false);
+        verifyTableCreated(tableId, tableAssignment, nBuckets, 
replicationFactor);
+
+        TableBucket tableBucket = new TableBucket(tableId, 0);
+        int leader =
+                
tableAssignment.getBucketAssignment(tableBucket.getBucket()).getReplicas().get(0);
+        TableBucketReplica tableBucketReplica = new 
TableBucketReplica(tableBucket, leader);
+
+        eventProcessor
+                .getCoordinatorEventManager()
+                .put(
+                        new NotifyLeaderAndIsrResponseReceivedEvent(
+                                Collections.singletonList(
+                                        new NotifyLeaderAndIsrResultForBucket(
+                                                tableBucket,
+                                                new ApiError(
+                                                        
Errors.DISK_WRITE_LOCKED,
+                                                        "disk write locked"))),
+                                leader));
+
+        retryVerifyContext(
+                ctx -> {
+                    
assertThat(ctx.getReplicaState(tableBucketReplica)).isEqualTo(OfflineReplica);
+                    assertThat(ctx.isReplicaOnline(leader, 
tableBucket)).isFalse();
+                });
+        
assertThat(eventProcessor.hasOfflineLeaderRetryTaskScheduled()).isTrue();
+
+        CountingFailingNotifyGateway failingGateway = new 
CountingFailingNotifyGateway();
+        
testCoordinatorChannelManager.setGateways(Collections.singletonMap(leader, 
failingGateway));
+
+        eventProcessor.getCoordinatorEventManager().put(new 
RetryOfflineLeaderEvent());
+
+        retry(
+                Duration.ofMinutes(1),
+                () -> 
assertThat(failingGateway.getNotifyLeaderAndIsrCount()).isGreaterThan(0));
+        retryVerifyContext(
+                ctx -> {
+                    
assertThat(ctx.getReplicaState(tableBucketReplica)).isEqualTo(OfflineReplica);
+                    
assertThat(ctx.getBucketState(tableBucket)).isEqualTo(OfflineBucket);
+                    assertThat(ctx.isReplicaOnline(leader, 
tableBucket)).isFalse();
+                });
+        
assertThat(eventProcessor.hasOfflineLeaderRetryTaskScheduled()).isTrue();
+    }
+
+    @Test
+    void testRetryOfflineLeaderEventSkipsDeletedBucket() throws Exception {
+        initCoordinatorChannel();
+        TablePath tablePath = TablePath.of(defaultDatabase, 
"retry_offline_leader_deleted_bucket");
+        int nBuckets = 3;
+        int replicationFactor = 1;
+        TableAssignment tableAssignment =
+                generateAssignment(
+                        nBuckets,
+                        replicationFactor,
+                        new TabletServerInfo[] {new TabletServerInfo(0, 
"rack0")});
+        long tableId =
+                metadataManager.createTable(
+                        tablePath,
+                        remoteDataDir,
+                        TEST_TABLE.withReplicationFactor(replicationFactor),
+                        tableAssignment,
+                        false);
+        verifyTableCreated(tableId, tableAssignment, nBuckets, 
replicationFactor);
+
+        TableBucket tableBucket = new TableBucket(tableId, 0);
+        int leader =
+                
tableAssignment.getBucketAssignment(tableBucket.getBucket()).getReplicas().get(0);
+        TableBucketReplica tableBucketReplica = new 
TableBucketReplica(tableBucket, leader);
+
+        eventProcessor
+                .getCoordinatorEventManager()
+                .put(
+                        new NotifyLeaderAndIsrResponseReceivedEvent(
+                                Collections.singletonList(
+                                        new NotifyLeaderAndIsrResultForBucket(
+                                                tableBucket,
+                                                new ApiError(
+                                                        
Errors.DISK_WRITE_LOCKED,
+                                                        "disk write locked"))),
+                                leader));
+
+        retryVerifyContext(
+                ctx -> {
+                    
assertThat(ctx.getReplicaState(tableBucketReplica)).isEqualTo(OfflineReplica);
+                    assertThat(ctx.isReplicaOnline(leader, 
tableBucket)).isFalse();
+                });
+
+        fromCtx(
+                ctx -> {
+                    ctx.queueTableDeletion(Collections.singleton(tableId));
+                    return null;
+                });
+
+        eventProcessor.getCoordinatorEventManager().put(new 
RetryOfflineLeaderEvent());
+
+        fromCtx(
+                ctx -> {
+                    
assertThat(ctx.getReplicaState(tableBucketReplica)).isEqualTo(OfflineReplica);
+                    assertThat(ctx.isReplicaOnline(leader, 
tableBucket)).isFalse();
+                    assertThat(ctx.offlineReplicasOnLiveTabletServers())
+                            .contains(tableBucketReplica);
+                    return null;
+                });
+        
assertThat(eventProcessor.hasOfflineLeaderRetryTaskScheduled()).isFalse();
+    }
+
     @Test
     void testSchemaChange() throws Exception {
         // make sure all request to gateway should be successful
@@ -1604,6 +1849,7 @@ class CoordinatorEventProcessorTest {
     private CoordinatorEventProcessor buildCoordinatorEventProcessor() {
         Configuration conf = new Configuration();
         conf.set(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir);
+        conf.set(ConfigOptions.COORDINATOR_OFFLINE_LEADER_RETRY_DELAY, 
Duration.ofDays(1));
         return new CoordinatorEventProcessor(
                 zookeeperClient,
                 serverMetadataCache,
@@ -1616,6 +1862,7 @@ class CoordinatorEventProcessorTest {
                 Executors.newFixedThreadPool(1, new 
ExecutorThreadFactory("test-coordinator-io")),
                 metadataManager,
                 kvSnapshotLeaseManager,
+                scheduler,
                 SystemClock.getInstance());
     }
 
@@ -2001,6 +2248,25 @@ class CoordinatorEventProcessorTest {
         return count;
     }
 
+    private static class CountingFailingNotifyGateway extends 
TestTabletServerGateway {
+        private final AtomicInteger notifyLeaderAndIsrCount = new 
AtomicInteger();
+
+        CountingFailingNotifyGateway() {
+            super(true, Collections.emptySet());
+        }
+
+        int getNotifyLeaderAndIsrCount() {
+            return notifyLeaderAndIsrCount.get();
+        }
+
+        @Override
+        public CompletableFuture<NotifyLeaderAndIsrResponse> 
notifyLeaderAndIsr(
+                NotifyLeaderAndIsrRequest request) {
+            notifyLeaderAndIsrCount.incrementAndGet();
+            return super.notifyLeaderAndIsr(request);
+        }
+    }
+
     /**
      * A gateway that intercepts NotifyLeaderAndIsr calls for verifying 
sequential execution of
      * leader migrations. In pass-through mode, it delegates to the parent. In 
controlled mode, it
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 0360b6a4e..dfe335fbe 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
@@ -46,6 +46,8 @@ 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;
+import org.apache.fluss.utils.concurrent.FlussScheduler;
+import org.apache.fluss.utils.concurrent.Scheduler;
 
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
@@ -84,6 +86,7 @@ public class RebalanceManagerTest {
     private LakeTableTieringManager lakeTableTieringManager;
     private RebalanceManager rebalanceManager;
     private KvSnapshotLeaseManager kvSnapshotLeaseManager;
+    private Scheduler scheduler;
 
     @BeforeAll
     static void baseBeforeAll() throws Exception {
@@ -111,6 +114,9 @@ public class RebalanceManagerTest {
                         TestingMetricGroups.COORDINATOR_METRICS);
         kvSnapshotLeaseManager.start();
 
+        scheduler = new FlussScheduler(1);
+        scheduler.startup();
+
         autoPartitionManager =
                 new AutoPartitionManager(
                         serverMetadataCache,
@@ -133,6 +139,9 @@ public class RebalanceManagerTest {
     @AfterEach
     void afterEach() throws Exception {
         rebalanceManager.close();
+        if (scheduler != null) {
+            scheduler.shutdown();
+        }
         zookeeperClient.deleteRebalanceTask();
         metadataManager =
                 new MetadataManager(
@@ -312,6 +321,7 @@ public class RebalanceManagerTest {
                 Executors.newFixedThreadPool(1, new 
ExecutorThreadFactory("test-coordinator-io")),
                 metadataManager,
                 kvSnapshotLeaseManager,
+                scheduler,
                 SystemClock.getInstance());
     }
 
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/statemachine/TableBucketStateMachineTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/statemachine/TableBucketStateMachineTest.java
index 2d3177b2a..630128546 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/statemachine/TableBucketStateMachineTest.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/statemachine/TableBucketStateMachineTest.java
@@ -51,7 +51,10 @@ import 
org.apache.fluss.shaded.guava32.com.google.common.collect.Sets;
 import org.apache.fluss.testutils.common.AllCallbackWrapper;
 import org.apache.fluss.utils.clock.SystemClock;
 import org.apache.fluss.utils.concurrent.ExecutorThreadFactory;
+import org.apache.fluss.utils.concurrent.FlussScheduler;
+import org.apache.fluss.utils.concurrent.Scheduler;
 
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -94,6 +97,7 @@ class TableBucketStateMachineTest {
     private LakeTableTieringManager lakeTableTieringManager;
     private CoordinatorMetadataCache serverMetadataCache;
     private KvSnapshotLeaseManager kvSnapshotLeaseManager;
+    private Scheduler scheduler;
 
     @BeforeAll
     static void baseBeforeAll() throws Exception {
@@ -140,6 +144,16 @@ class TableBucketStateMachineTest {
                         SystemClock.getInstance(),
                         TestingMetricGroups.COORDINATOR_METRICS);
         kvSnapshotLeaseManager.start();
+
+        scheduler = new FlussScheduler(1);
+        scheduler.startup();
+    }
+
+    @AfterEach
+    void afterEach() throws Exception {
+        if (scheduler != null) {
+            scheduler.shutdown();
+        }
     }
 
     @Test
@@ -313,6 +327,7 @@ class TableBucketStateMachineTest {
                                 new Configuration(),
                                 new LakeCatalogDynamicLoader(new 
Configuration(), null, true)),
                         kvSnapshotLeaseManager,
+                        scheduler,
                         SystemClock.getInstance());
         CoordinatorEventManager eventManager =
                 new CoordinatorEventManager(
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
index ff6b0246e..6dd9ce7fe 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
@@ -555,6 +555,64 @@ class ReplicaManagerTest extends ReplicaTestBase {
         replicaManager.getDiskUsageMonitor().update(0.10);
     }
 
+    @Test
+    void testNewKvLeaderRejectedWhenDiskLocked() throws Exception {
+        TableBucket kvTb = new TableBucket(DATA1_TABLE_ID_PK, 1);
+        TableBucket logTb = new TableBucket(DATA1_TABLE_ID, 1);
+
+        replicaManager.getDiskUsageMonitor().update(0.99);
+        assertThat(replicaManager.isDiskWriteLocked()).isTrue();
+
+        CompletableFuture<List<NotifyLeaderAndIsrResultForBucket>> future =
+                new CompletableFuture<>();
+        replicaManager.becomeLeaderOrFollower(
+                INITIAL_COORDINATOR_EPOCH,
+                Collections.singletonList(
+                        new NotifyLeaderAndIsrData(
+                                PhysicalTablePath.of(DATA1_TABLE_PATH_PK),
+                                kvTb,
+                                Collections.singletonList(TABLET_SERVER_ID),
+                                new LeaderAndIsr(
+                                        TABLET_SERVER_ID,
+                                        INITIAL_LEADER_EPOCH,
+                                        
Collections.singletonList(TABLET_SERVER_ID),
+                                        Collections.emptyList(),
+                                        INITIAL_COORDINATOR_EPOCH,
+                                        INITIAL_BUCKET_EPOCH))),
+                future::complete);
+
+        List<NotifyLeaderAndIsrResultForBucket> results = future.get();
+        assertThat(results).hasSize(1);
+        NotifyLeaderAndIsrResultForBucket result = results.get(0);
+        
assertThat(result.getError().error()).isEqualTo(Errors.DISK_WRITE_LOCKED);
+        assertThat(result.getError().messageWithFallback()).contains("data 
disk usage");
+        Replica kvReplica = replicaManager.getReplicaOrException(kvTb);
+        assertThat(kvReplica.isLeader()).isFalse();
+        assertThat(kvReplica.getKvTablet()).isNull();
+
+        future = new CompletableFuture<>();
+        replicaManager.becomeLeaderOrFollower(
+                INITIAL_COORDINATOR_EPOCH,
+                Collections.singletonList(
+                        new NotifyLeaderAndIsrData(
+                                PhysicalTablePath.of(DATA1_TABLE_PATH),
+                                logTb,
+                                Collections.singletonList(TABLET_SERVER_ID),
+                                new LeaderAndIsr(
+                                        TABLET_SERVER_ID,
+                                        INITIAL_LEADER_EPOCH,
+                                        
Collections.singletonList(TABLET_SERVER_ID),
+                                        Collections.emptyList(),
+                                        INITIAL_COORDINATOR_EPOCH,
+                                        INITIAL_BUCKET_EPOCH))),
+                future::complete);
+
+        assertThat(future.get()).containsOnly(new 
NotifyLeaderAndIsrResultForBucket(logTb));
+        
assertThat(replicaManager.getReplicaOrException(logTb).isLeader()).isTrue();
+
+        replicaManager.getDiskUsageMonitor().update(0.10);
+    }
+
     @Test
     void testPutKv() throws Exception {
         TableBucket tb = new TableBucket(DATA1_TABLE_ID_PK, 1);
diff --git a/website/docs/maintenance/configuration.md 
b/website/docs/maintenance/configuration.md
index 605bada74..011ae2440 100644
--- a/website/docs/maintenance/configuration.md
+++ b/website/docs/maintenance/configuration.md
@@ -56,13 +56,14 @@ during the Fluss cluster working.
 
 ## CoordinatorServer
 
-| Option                                             | Type       | Default   
| Description                                                                   
                                                                                
                                                                                
                                                                                
                                                                                
               [...]
-|----------------------------------------------------|------------|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 [...]
-| coordinator.io-pool.size                           | Integer    | 10        
| **Deprecated**: This option is deprecated. Please use `server.io-pool.size` 
instead. The size of the IO thread pool to run blocking operations for 
coordinator server. This includes discard unnecessary snapshot files. Increase 
this value if you experience slow unnecessary snapshot files clean. The default 
value is 10.                                                                    
                           [...]
-| coordinator.producer-offsets.ttl                   | Duration   | 24h       
| The TTL (time-to-live) for producer offsets. Producer offsets older than this 
TTL will be automatically cleaned up by the coordinator server. Producer 
offsets are used for undo recovery when a Flink job fails over before 
completing its first checkpoint. The default value is 24 hours.                 
                                                                                
                                [...]
-| coordinator.producer-offsets.cleanup-interval      | Duration   | 1h        
| The interval for cleaning up expired producer offsets and orphan files in 
remote storage. The cleanup task runs periodically to remove expired offsets 
and any orphan files that may have been left behind due to incomplete 
operations. The default value is 1 hour.                                        
                                                                                
                                [...]
-| coordinator.lifecycle-throttler.inflight-timeout       | Duration   | 3min   
   | The timeout for an in-flight drop event in the coordinator's 
TableLifecycleThrottler. If a drop event has been admitted but the 
corresponding completion callback has not arrived within this timeout, the 
throttler abandons tracking of that drop and continues admitting the next 
pending drop.                                                                   
                                                    [...]
+| Option                                                 | Type       | 
Default   | Description                                                         
                                                                                
                                                                                
                                                                                
                                                                                
                     [...]
+|--------------------------------------------------------|------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 [...]
+| coordinator.io-pool.size                               | Integer    | 10     
   | **Deprecated**: This option is deprecated. Please use 
`server.io-pool.size` instead. The size of the IO thread pool to run blocking 
operations for coordinator server. This includes discard unnecessary snapshot 
files. Increase this value if you experience slow unnecessary snapshot files 
clean. The default value is 10.                                                 
                                          [...]
+| coordinator.producer-offsets.ttl                       | Duration   | 24h    
   | The TTL (time-to-live) for producer offsets. Producer offsets older than 
this TTL will be automatically cleaned up by the coordinator server. Producer 
offsets are used for undo recovery when a Flink job fails over before 
completing its first checkpoint. The default value is 24 hours.                 
                                                                                
                            [...]
+| coordinator.producer-offsets.cleanup-interval          | Duration   | 1h     
   | The interval for cleaning up expired producer offsets and orphan files in 
remote storage. The cleanup task runs periodically to remove expired offsets 
and any orphan files that may have been left behind due to incomplete 
operations. The default value is 1 hour.                                        
                                                                                
                            [...]
+| coordinator.lifecycle-throttler.inflight-timeout       | Duration   | 3min   
   | The timeout for an in-flight drop event in the coordinator's 
TableLifecycleThrottler. If a drop event has been admitted but the 
corresponding completion callback has not arrived within this timeout, the 
throttler abandons tracking of that drop and continues admitting the next 
pending drop.                                                                   
                                                    [...]
 | coordinator.lifecycle-throttler.timeout-check-interval | Duration   | 1min   
   | The periodic interval at which the coordinator's TableLifecycleThrottler 
scans in-flight drops for timeouts.                                             
                                                                                
                                                                                
                                                                                
                [...]
+| coordinator.offline-leader.retry-delay                 | Duration   | 1min   
   | The delay before the coordinator retries offline leaders on live tablet 
servers after they are marked offline. This lets a leader that was rejected 
because of temporary tablet-server conditions, such as disk write protection, 
become electable again after recovery.                                          
                                                                                
                       [...]
 
 ## TabletServer
 

Reply via email to