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 fa091e876 [server] Add TableLifecycleThrottler to throttle drops in
coordinator queue (#3174)
fa091e876 is described below
commit fa091e876d2cad97ddf889f1fbbf33d479b2eb91
Author: yunhong <[email protected]>
AuthorDate: Thu Jun 11 09:08:39 2026 +0800
[server] Add TableLifecycleThrottler to throttle drops in coordinator queue
(#3174)
* [server] Add ReplicaCleanupManager to throttle drops in coordinator queue
* address copilot's comments
* address yuxia's comments
* Fix unstable it case
* address jark's comments
* fix failed test case
* improve code
---------
Co-authored-by: Jark Wu <[email protected]>
---
.../org/apache/fluss/config/ConfigOptions.java | 21 +
.../server/coordinator/AutoPartitionManager.java | 6 +-
.../coordinator/CoordinatorEventProcessor.java | 37 +-
.../server/coordinator/CoordinatorServer.java | 3 +-
.../coordinator/TableLifecycleThrottler.java | 553 +++++++++++++++++++++
.../fluss/server/coordinator/TableManager.java | 48 +-
.../server/coordinator/event/ResumeDropEvent.java | 73 +++
.../event/watcher/TableChangeWatcher.java | 37 +-
.../coordinator/AutoPartitionManagerTest.java | 56 +++
.../coordinator/CoordinatorEventProcessorTest.java | 201 +++++++-
.../coordinator/TableLifecycleThrottlerTest.java | 430 ++++++++++++++++
.../fluss/server/coordinator/TableManagerTest.java | 34 +-
.../event/watcher/TableChangeWatcherTest.java | 40 +-
.../rebalance/RebalanceManagerTest.java | 3 +-
.../statemachine/TableBucketStateMachineTest.java | 3 +-
.../server/testutils/FlussClusterExtension.java | 8 +
website/docs/maintenance/configuration.md | 12 +-
17 files changed, 1507 insertions(+), 58 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 3cb61db27..fafcc2905 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
@@ -200,6 +200,27 @@ public class ConfigOptions {
"The interval of auto partition check. "
+ "The default value is 10 minutes.");
+ public static final ConfigOption<Duration>
COORDINATOR_LIFECYCLE_THROTTLER_INFLIGHT_TIMEOUT =
+ key("coordinator.lifecycle-throttler.inflight-timeout")
+ .durationType()
+ .defaultValue(Duration.ofMinutes(3))
+ .withDescription(
+ "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.");
+
+ public static final ConfigOption<Duration>
+ COORDINATOR_LIFECYCLE_THROTTLER_TIMEOUT_CHECK_INTERVAL =
+
key("coordinator.lifecycle-throttler.timeout-check-interval")
+ .durationType()
+ .defaultValue(Duration.ofMinutes(1))
+ .withDescription(
+ "The periodic interval at which the
coordinator's "
+ + "TableLifecycleThrottler scans
in-flight drops for "
+ + "timeouts.");
+
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 ae3ceef33..33f9513a2 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
@@ -76,6 +76,10 @@ import static
org.apache.fluss.utils.concurrent.LockUtils.inLock;
* An auto partition manager which will trigger auto partition for the tables
in cluster
* periodically. It'll use a {@link ScheduledExecutorService} to schedule the
auto partition which
* will trigger auto partition for them.
+ *
+ * <p>TODO: migrate the jittered partition-creation throttling logic into
{@link
+ * TableLifecycleThrottler} so that partition creation and deletion share the
same lifecycle gate.
+ * Tracked by https://github.com/apache/fluss/issues/3457.
*/
public class AutoPartitionManager implements AutoCloseable {
@@ -344,7 +348,7 @@ public class AutoPartitionManager implements AutoCloseable {
dropPartitions(
tablePath,
tableInfo.getPartitionKeys(),
- createPartitionInstant,
+ now,
tableInfo.getTableConfig().getAutoPartitionStrategy(),
currentPartitions);
createPartitions(tableInfo, createPartitionInstant,
currentPartitions);
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 13eaba282..6c68a5583 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
@@ -87,6 +87,7 @@ import
org.apache.fluss.server.coordinator.event.NotifyLeaderAndIsrResponseRecei
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.SchemaChangeEvent;
import org.apache.fluss.server.coordinator.event.TableRegistrationChangeEvent;
import
org.apache.fluss.server.coordinator.event.watcher.CoordinatorChangeWatcher;
@@ -123,6 +124,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.Clock;
import org.apache.fluss.utils.clock.SystemClock;
import org.apache.fluss.utils.types.Tuple2;
@@ -174,6 +176,7 @@ public class CoordinatorEventProcessor implements
EventProcessor {
private final CoordinatorEventManager coordinatorEventManager;
private final MetadataManager metadataManager;
private final TableManager tableManager;
+ private final TableLifecycleThrottler lifecycleThrottler;
private final AutoPartitionManager autoPartitionManager;
private final LakeTableTieringManager lakeTableTieringManager;
private final TableChangeWatcher tableChangeWatcher;
@@ -199,7 +202,8 @@ public class CoordinatorEventProcessor implements
EventProcessor {
Configuration conf,
ExecutorService ioExecutor,
MetadataManager metadataManager,
- KvSnapshotLeaseManager kvSnapshotLeaseManager) {
+ KvSnapshotLeaseManager kvSnapshotLeaseManager,
+ Clock clock) {
this.zooKeeperClient = zooKeeperClient;
this.serverMetadataCache = serverMetadataCache;
this.coordinatorChannelManager = coordinatorChannelManager;
@@ -223,6 +227,7 @@ public class CoordinatorEventProcessor implements
EventProcessor {
zooKeeperClient);
this.metadataManager = metadataManager;
+ this.lifecycleThrottler = new
TableLifecycleThrottler(coordinatorEventManager, clock, conf);
this.tableManager =
new TableManager(
metadataManager,
@@ -230,10 +235,13 @@ public class CoordinatorEventProcessor implements
EventProcessor {
replicaStateMachine,
tableBucketStateMachine,
new RemoteStorageCleaner(conf, ioExecutor),
- ioExecutor);
+ ioExecutor,
+ lifecycleThrottler);
this.coordinatorChangeWatcher =
new CoordinatorChangeWatcher(zooKeeperClient,
coordinatorEventManager);
- this.tableChangeWatcher = new TableChangeWatcher(zooKeeperClient,
coordinatorEventManager);
+ this.tableChangeWatcher =
+ new TableChangeWatcher(
+ zooKeeperClient, coordinatorEventManager,
lifecycleThrottler);
this.tabletServerChangeWatcher =
new TabletServerChangeWatcher(zooKeeperClient,
coordinatorEventManager);
this.coordinatorRequestBatch =
@@ -271,6 +279,11 @@ public class CoordinatorEventProcessor implements
EventProcessor {
return coordinatorContext;
}
+ @VisibleForTesting
+ TableLifecycleThrottler getLifecycleThrottler() {
+ return lifecycleThrottler;
+ }
+
public void startup() {
coordinatorContext.setCoordinatorServerInfo(getCoordinatorServerInfo());
// start watchers first so that we won't miss node in zk;
@@ -298,6 +311,8 @@ public class CoordinatorEventProcessor implements
EventProcessor {
coordinatorContext.getServerTags());
updateTabletServerMetadataCacheWhenStartup(tabletServerInfoList);
+ lifecycleThrottler.start();
+
// start table manager
tableManager.startup();
@@ -563,6 +578,9 @@ public class CoordinatorEventProcessor implements
EventProcessor {
// first shutdown table manager
tableManager.shutdown();
+ // shut down lifecycle throttler timeout checker
+ lifecycleThrottler.close();
+
// then stop watchers
coordinatorChangeWatcher.stop();
tableChangeWatcher.stop();
@@ -657,6 +675,19 @@ public class CoordinatorEventProcessor implements
EventProcessor {
timeoutEvent.getTableBucket());
rebalanceManager.finishRebalanceTask(
timeoutEvent.getTableBucket(), RebalanceStatus.TIMEOUT);
+ } else if (event instanceof ResumeDropEvent) {
+ // Resume-mode reconciliation queued by TableLifecycleThrottler:
dispatch on the event
+ // thread so the @NotThreadSafe CoordinatorContext / state
machines are only mutated
+ // here. The event carries identity-only (kind + ids); the handler
chosen below is
+ // determined by the event-thread code path, not by the
(potentially non-event-thread)
+ // submitter, so callers cannot smuggle in unsafe lambdas.
+ ResumeDropEvent resumeDropEvent = (ResumeDropEvent) event;
+ if (resumeDropEvent.getPartitionId() == null) {
+ tableManager.onDeleteTable(resumeDropEvent.getTableId());
+ } else {
+ tableManager.onDeletePartition(
+ resumeDropEvent.getTableId(),
resumeDropEvent.getPartitionId());
+ }
} else if (event instanceof ListRebalanceProgressEvent) {
ListRebalanceProgressEvent listRebalanceProgressEvent =
(ListRebalanceProgressEvent) event;
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 6914e8207..2c4fcf5e4 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
@@ -336,7 +336,8 @@ public class CoordinatorServer extends ServerBase {
conf,
ioExecutor,
metadataManager,
- kvSnapshotLeaseManager);
+ kvSnapshotLeaseManager,
+ clock);
coordinatorEventProcessor.startup();
createDefaultDatabase();
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
new file mode 100644
index 000000000..d7b6a84dd
--- /dev/null
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/TableLifecycleThrottler.java
@@ -0,0 +1,553 @@
+/*
+ * 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;
+
+import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.metadata.TablePartition;
+import org.apache.fluss.server.coordinator.event.DropPartitionEvent;
+import org.apache.fluss.server.coordinator.event.DropTableEvent;
+import org.apache.fluss.server.coordinator.event.EventManager;
+import org.apache.fluss.server.coordinator.event.ResumeDropEvent;
+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;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import static org.apache.fluss.utils.concurrent.LockUtils.inLock;
+
+/**
+ * Coordinator-wide throttler that gates the rate at which {@link
DropPartitionEvent} / {@link
+ * DropTableEvent} are admitted into the coordinator event queue.
+ *
+ * <p>Why pre-queue throttling: a cascading {@code DROP TABLE} or
auto-partition expiration can
+ * delete N partition znodes from ZooKeeper synchronously, which fans out into
N {@code
+ * NODE_DELETED} watcher callbacks. If each callback directly enqueued a
{@code DropPartitionEvent},
+ * the coordinator event queue would be flooded with N drop events ahead of
unrelated work like
+ * leader election, heartbeat handling and metadata changes.
+ *
+ * <p>This throttler intercepts that step. Watchers (and other drop sources)
call {@link
+ * #submitPartitionDrop} / {@link #submitTableDrop} which buffer a lightweight
{@code PendingDrop}
+ * in an in-memory FIFO queue. The throttler admits <b>one</b> drop at a time
into the coordinator
+ * event queue. The next drop is admitted only after the current in-flight
drop completes (all
+ * replicas reach {@code DeletionSuccessful}) or times out.
+ *
+ * <p>Coordinator startup also routes through this throttler via {@link
+ * #submitPartitionDropForResume} / {@link #submitTableDropForResume}: stale
tables/partitions
+ * detected by {@code initCoordinatorContext} (whose {@code TableInfo} is
already gone, so a regular
+ * {@code DropTableEvent} is not viable). At admission time the throttler
enqueues a {@link
+ * ResumeDropEvent} carrying only identity (kind + ids); the coordinator
event-thread handler routes
+ * it to {@code TableManager#onDeleteTable} / {@code
TableManager#onDeletePartition}. This API
+ * deliberately accepts no {@link Runnable} from the submitter so the
(potentially non-event-thread)
+ * caller cannot smuggle in logic that would mutate the {@code @NotThreadSafe}
{@code
+ * CoordinatorContext} off the event thread.
+ *
+ * <p>Timeout-based abandon: the in-flight drop is timestamped at admission
time. A periodic task
+ * checks whether the completion callback has not arrived within the
configured timeout and abandons
+ * the drop with a WARN log. Abandonment only releases the throttler's
in-memory tracking; any
+ * residual replica state machine entries are reconciled on the next
coordinator startup via {@link
+ * TableManager#resumeDeletions()}.
+ */
+public class TableLifecycleThrottler implements AutoCloseable {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(TableLifecycleThrottler.class);
+
+ private final long inflightTimeoutMs;
+ private final long timeoutCheckIntervalMs;
+
+ private final EventManager eventManager;
+ private final Clock clock;
+ private final ScheduledExecutorService timeoutChecker;
+
+ private final AtomicBoolean started = new AtomicBoolean(false);
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ private final Lock lock = new ReentrantLock();
+
+ /** Pending drops waiting to be admitted into the coordinator event queue.
*/
+ @GuardedBy("lock")
+ private final Deque<PendingDrop> pendingDrops = new ArrayDeque<>();
+
+ /** The currently in-flight drop, or {@code null} if nothing is in-flight.
*/
+ @GuardedBy("lock")
+ @Nullable
+ private InflightDrop currentInflight;
+
+ public TableLifecycleThrottler(EventManager eventManager, Clock clock,
Configuration conf) {
+ this(
+ eventManager,
+ clock,
+ Executors.newScheduledThreadPool(
+ 1, new
ExecutorThreadFactory("lifecycle-throttler-timeout")),
+
conf.get(ConfigOptions.COORDINATOR_LIFECYCLE_THROTTLER_INFLIGHT_TIMEOUT).toMillis(),
+
conf.get(ConfigOptions.COORDINATOR_LIFECYCLE_THROTTLER_TIMEOUT_CHECK_INTERVAL)
+ .toMillis());
+ }
+
+ @VisibleForTesting
+ TableLifecycleThrottler(
+ EventManager eventManager,
+ Clock clock,
+ ScheduledExecutorService timeoutChecker,
+ long inflightTimeoutMs,
+ long timeoutCheckIntervalMs) {
+ this.eventManager = eventManager;
+ this.clock = clock == null ? SystemClock.getInstance() : clock;
+ this.timeoutChecker = timeoutChecker;
+ this.inflightTimeoutMs = inflightTimeoutMs;
+ this.timeoutCheckIntervalMs = timeoutCheckIntervalMs;
+ }
+
+ /** Starts the periodic timeout checker. Idempotent. */
+ public void start() {
+ if (closed.get()) {
+ throw new IllegalStateException("TableLifecycleThrottler is
already closed.");
+ }
+ if (started.compareAndSet(false, true)) {
+ timeoutChecker.scheduleWithFixedDelay(
+ this::checkTimeoutsSafely,
+ timeoutCheckIntervalMs,
+ timeoutCheckIntervalMs,
+ TimeUnit.MILLISECONDS);
+ LOG.info(
+ "TableLifecycleThrottler timeout checker started: "
+ + "inflightTimeoutMs={},
timeoutCheckIntervalMs={}",
+ inflightTimeoutMs,
+ timeoutCheckIntervalMs);
+ }
+ }
+
+ /**
+ * Submits a partition drop request. The partition's ZK metadata is
expected to have been
+ * removed by the caller prior to this submission; the throttler only
governs how fast the
+ * resulting {@link DropPartitionEvent} is admitted into the coordinator
event queue.
+ */
+ public void submitPartitionDrop(long tableId, long partitionId, String
partitionName) {
+ admit(new PendingPartitionDrop(tableId, partitionId, partitionName,
false));
+ }
+
+ /**
+ * Submits a partition drop request that, when admitted, enqueues a {@link
ResumeDropEvent} (so
+ * the event-thread handler in {@code CoordinatorEventProcessor} can call
{@code
+ * TableManager#onDeletePartition}) instead of a {@link
DropPartitionEvent}. Used by coordinator
+ * startup to reconcile stale partitions whose {@code TableInfo} is
already gone.
+ */
+ public void submitPartitionDropForResume(long tableId, long partitionId,
String partitionName) {
+ admit(new PendingPartitionDrop(tableId, partitionId, partitionName,
true));
+ }
+
+ /**
+ * Submits a table drop request. The table's ZK metadata is expected to
have been removed by the
+ * caller prior to this submission.
+ */
+ public void submitTableDrop(
+ long tableId,
+ boolean isPartitionedTable,
+ boolean isAutoPartitionTable,
+ boolean isDataLakeEnabled) {
+ admit(
+ new PendingTableDrop(
+ tableId,
+ isPartitionedTable,
+ isAutoPartitionTable,
+ isDataLakeEnabled,
+ false));
+ }
+
+ /**
+ * Submits a table drop request that, when admitted, enqueues a {@link
ResumeDropEvent} (so the
+ * event-thread handler in {@code CoordinatorEventProcessor} can call
{@code
+ * TableManager#onDeleteTable}) instead of a {@link DropTableEvent}. Used
by coordinator startup
+ * to reconcile stale tables whose {@code TableInfo} is already gone.
+ */
+ public void submitTableDropForResume(long tableId) {
+ admit(new PendingTableDrop(tableId, false, false, false, true));
+ }
+
+ /**
+ * Called by {@link TableManager} when all replicas of a partition have
reached the {@code
+ * DeletionSuccessful} state. Releases the in-flight slot and admits the
next pending drop.
+ */
+ public void onPartitionDropCompleted(TablePartition tablePartition) {
+ PendingDrop next =
+ inLock(
+ lock,
+ () -> {
+ if (currentInflight == null) {
+ return null;
+ }
+ PendingDrop inflight = currentInflight.drop;
+ if (!(inflight instanceof PendingPartitionDrop)) {
+ return null;
+ }
+ PendingPartitionDrop p = (PendingPartitionDrop)
inflight;
+ if (p.tableId != tablePartition.getTableId()
+ || p.partitionId !=
tablePartition.getPartitionId()) {
+ return null;
+ }
+ LOG.debug(
+ "Partition drop completed: {} after {}ms;
pending={}",
+ tablePartition,
+ clock.milliseconds() -
currentInflight.submittedAtMs,
+ pendingDrops.size());
+ currentInflight = null;
+ return admitNext();
+ });
+ executeUntilBlocked(next);
+ }
+
+ /**
+ * Called by {@link TableManager} when all replicas of a table have
reached the {@code
+ * DeletionSuccessful} state. Releases the in-flight slot and admits the
next pending drop.
+ */
+ public void onTableDropCompleted(long tableId) {
+ PendingDrop next =
+ inLock(
+ lock,
+ () -> {
+ if (currentInflight == null) {
+ return null;
+ }
+ PendingDrop inflight = currentInflight.drop;
+ if (!(inflight instanceof PendingTableDrop)) {
+ return null;
+ }
+ PendingTableDrop t = (PendingTableDrop) inflight;
+ if (t.tableId != tableId) {
+ return null;
+ }
+ LOG.debug(
+ "Table drop completed: tableId={} after
{}ms; pending={}",
+ tableId,
+ clock.milliseconds() -
currentInflight.submittedAtMs,
+ pendingDrops.size());
+ currentInflight = null;
+ return admitNext();
+ });
+ executeUntilBlocked(next);
+ }
+
+ private void admit(PendingDrop drop) {
+ if (closed.get()) {
+ LOG.debug(
+ "Ignoring drop submission ({}) because
TableLifecycleThrottler is closed.",
+ drop);
+ return;
+ }
+ PendingDrop toExecute =
+ inLock(
+ lock,
+ () -> {
+ // Deduplicate: skip if the same target is already
in-flight or pending.
+ // This prevents stale resume drops from
accumulating when
+ // resumeDeletions()
+ // is called while the original watcher-triggered
drop is still in
+ // progress.
+ if (isDuplicate(drop)) {
+ LOG.debug("Skipping duplicate drop submission:
{}", drop);
+ return null;
+ }
+ pendingDrops.add(drop);
+ LOG.debug(
+ "Submitted drop: {}; pending={}
inflight={}",
+ drop,
+ pendingDrops.size(),
+ currentInflight != null);
+ return admitNext();
+ });
+ executeUntilBlocked(toExecute);
+ }
+
+ /**
+ * Returns {@code true} if a drop targeting the same table/partition is
already tracked. Must be
+ * called under lock.
+ */
+ @GuardedBy("lock")
+ private boolean isDuplicate(PendingDrop drop) {
+ if (currentInflight != null &&
currentInflight.drop.hasSameTarget(drop)) {
+ return true;
+ }
+ for (PendingDrop pending : pendingDrops) {
+ if (pending.hasSameTarget(drop)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * If nothing is in-flight, pulls the next pending drop and marks it as
in-flight. Must be
+ * called under lock.
+ *
+ * @return the drop to execute outside the lock, or {@code null} if
nothing was admitted.
+ */
+ @GuardedBy("lock")
+ @Nullable
+ private PendingDrop admitNext() {
+ if (currentInflight != null || pendingDrops.isEmpty()) {
+ return null;
+ }
+ PendingDrop next = pendingDrops.poll();
+ currentInflight = new InflightDrop(next, clock.milliseconds());
+ return next;
+ }
+
+ /** Executes admitted drops until one needs an asynchronous completion
callback. */
+ private void executeUntilBlocked(@Nullable PendingDrop drop) {
+ PendingDrop current = drop;
+ while (current != null) {
+ if (!executeDrop(current)) {
+ return;
+ }
+ current = releaseInflightAndAdmitNext();
+ }
+ }
+
+ private boolean executeDrop(PendingDrop drop) {
+ drop.putEventInto(eventManager);
+ return drop.isFireAndForget();
+ }
+
+ private PendingDrop releaseInflightAndAdmitNext() {
+ return inLock(
+ lock,
+ () -> {
+ currentInflight = null;
+ return admitNext();
+ });
+ }
+
+ private void checkTimeoutsSafely() {
+ try {
+ checkTimeouts();
+ } catch (Throwable t) {
+ LOG.error("Unexpected error in TableLifecycleThrottler timeout
check.", t);
+ }
+ }
+
+ @VisibleForTesting
+ void checkTimeouts() {
+ PendingDrop next =
+ inLock(
+ lock,
+ () -> {
+ if (currentInflight == null) {
+ return null;
+ }
+ long elapsed = clock.milliseconds() -
currentInflight.submittedAtMs;
+ if (elapsed <= inflightTimeoutMs) {
+ return null;
+ }
+ LOG.warn(
+ "In-flight drop {} timed out after {}ms
with no completion callback. "
+ + "Abandoning in-memory tracking;
ZK metadata is already gone, "
+ + "any residual replica state will
be reconciled on next "
+ + "coordinator startup.",
+ currentInflight.drop,
+ elapsed);
+ currentInflight = null;
+ return admitNext();
+ });
+ executeUntilBlocked(next);
+ }
+
+ @VisibleForTesting
+ int getInflightCount() {
+ return inLock(lock, () -> currentInflight != null ? 1 : 0);
+ }
+
+ @VisibleForTesting
+ int getPendingDropCount() {
+ return inLock(lock, pendingDrops::size);
+ }
+
+ @Override
+ public void close() {
+ if (closed.compareAndSet(false, true)) {
+ timeoutChecker.shutdownNow();
+ }
+ }
+
+ //
------------------------------------------------------------------------------------------
+ // PendingDrop hierarchy
+ //
------------------------------------------------------------------------------------------
+
+ /** A unit of work queued for admission into the coordinator event queue.
*/
+ interface PendingDrop {
+ void putEventInto(EventManager eventManager);
+
+ /**
+ * Whether this drop completes immediately without waiting for a
completion callback.
+ * Auto-partition table drops return {@code true} because partitioned
tables have no
+ * table-level replicas, so {@link #onTableDropCompleted} will never
be called.
+ */
+ default boolean isFireAndForget() {
+ return false;
+ }
+
+ /**
+ * Returns {@code true} if this drop targets the same table or
partition as {@code other}.
+ * Used for deduplication: if a drop with the same target is already
in-flight or pending, a
+ * duplicate submission is skipped.
+ */
+ boolean hasSameTarget(PendingDrop other);
+ }
+
+ /** Pending admission of a {@link DropPartitionEvent} for a single
partition. */
+ static final class PendingPartitionDrop implements PendingDrop {
+ final long tableId;
+ final long partitionId;
+ final String partitionName;
+ final boolean forResume;
+
+ PendingPartitionDrop(
+ long tableId, long partitionId, String partitionName, boolean
forResume) {
+ this.tableId = tableId;
+ this.partitionId = partitionId;
+ this.partitionName = partitionName;
+ this.forResume = forResume;
+ }
+
+ @Override
+ public void putEventInto(EventManager eventManager) {
+ if (forResume) {
+ // Resume-mode reconciliation: dispatch identity-only event to
the coordinator
+ // event thread, which mutates the @NotThreadSafe
CoordinatorContext. The
+ // admission point can be a watcher thread or the
timeout-checker thread, so
+ // the handler must NOT run inline here.
+ eventManager.put(ResumeDropEvent.forPartition(tableId,
partitionId));
+ } else {
+ eventManager.put(new DropPartitionEvent(tableId, partitionId,
partitionName));
+ }
+ }
+
+ @Override
+ public boolean hasSameTarget(PendingDrop other) {
+ if (!(other instanceof PendingPartitionDrop)) {
+ return false;
+ }
+ PendingPartitionDrop that = (PendingPartitionDrop) other;
+ return this.tableId == that.tableId && this.partitionId ==
that.partitionId;
+ }
+
+ @Override
+ public String toString() {
+ return "PendingPartitionDrop{tableId="
+ + tableId
+ + ", partitionId="
+ + partitionId
+ + ", partitionName='"
+ + partitionName
+ + "', forResume="
+ + forResume
+ + "}";
+ }
+ }
+
+ /** Pending admission of a {@link DropTableEvent} for a whole table. */
+ static final class PendingTableDrop implements PendingDrop {
+ final long tableId;
+ final boolean isPartitionedTable;
+ final boolean isAutoPartitionTable;
+ final boolean isDataLakeEnabled;
+ final boolean forResume;
+
+ PendingTableDrop(
+ long tableId,
+ boolean isPartitionedTable,
+ boolean isAutoPartitionTable,
+ boolean isDataLakeEnabled,
+ boolean forResume) {
+ this.tableId = tableId;
+ this.isPartitionedTable = isPartitionedTable;
+ this.isAutoPartitionTable = isAutoPartitionTable;
+ this.isDataLakeEnabled = isDataLakeEnabled;
+ this.forResume = forResume;
+ }
+
+ @Override
+ public boolean isFireAndForget() {
+ // Partitioned tables have no table-level replicas, so the
completion callback
+ // (onTableDropCompleted) will never arrive. Skip inflight
tracking.
+ // Resume-mode drops are excluded because the event-thread handler
triggers
+ // onDeleteTable() which may send RPCs for non-vacuously-complete
cases.
+ return isPartitionedTable && !forResume;
+ }
+
+ @Override
+ public void putEventInto(EventManager eventManager) {
+ if (forResume) {
+ // See PendingPartitionDrop#execute.
+ eventManager.put(ResumeDropEvent.forTable(tableId));
+ } else {
+ eventManager.put(
+ new DropTableEvent(tableId, isAutoPartitionTable,
isDataLakeEnabled));
+ }
+ }
+
+ @Override
+ public boolean hasSameTarget(PendingDrop other) {
+ if (!(other instanceof PendingTableDrop)) {
+ return false;
+ }
+ PendingTableDrop that = (PendingTableDrop) other;
+ return this.tableId == that.tableId;
+ }
+
+ @Override
+ public String toString() {
+ return "PendingTableDrop{tableId="
+ + tableId
+ + ", partitioned="
+ + isPartitionedTable
+ + ", autoPartition="
+ + isAutoPartitionTable
+ + ", dataLake="
+ + isDataLakeEnabled
+ + ", forResume="
+ + forResume
+ + "}";
+ }
+ }
+
+ /** Tracks the in-flight drop along with its admission timestamp for
timeout detection. */
+ private static final class InflightDrop {
+ final PendingDrop drop;
+ final long submittedAtMs;
+
+ InflightDrop(PendingDrop drop, long submittedAtMs) {
+ this.drop = drop;
+ this.submittedAtMs = submittedAtMs;
+ }
+ }
+}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/TableManager.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/TableManager.java
index b92a2dad6..978751ec4 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/TableManager.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/TableManager.java
@@ -51,19 +51,33 @@ public class TableManager {
private final TableBucketStateMachine tableBucketStateMachine;
private final ExecutorService ioExecutor;
+ /**
+ * Throttler used to pace the asynchronous replica cleanup that follows
{@link
+ * #onDeleteTable(long)} / {@link #onDeletePartition(long, long)}. {@link
#resumeDeletions()}
+ * routes eligible drops through it (one at a time) instead of invoking
the state-machine
+ * transitions directly, and {@link #completeDeleteTable(long)} / {@link
+ * #completeDeletePartition(TablePartition)} notify it so the next pending
drop can be admitted.
+ *
+ * <p>Wired in via the constructor so that the reference publication is
safe (final field) and
+ * cannot race with the coordinator event thread.
+ */
+ private final TableLifecycleThrottler lifecycleThrottler;
+
public TableManager(
MetadataManager metadataManager,
CoordinatorContext coordinatorContext,
ReplicaStateMachine replicaStateMachine,
TableBucketStateMachine tableBucketStateMachine,
RemoteStorageCleaner remoteStorageCleaner,
- ExecutorService ioExecutor) {
+ ExecutorService ioExecutor,
+ TableLifecycleThrottler lifecycleThrottler) {
this.metadataManager = metadataManager;
this.remoteStorageCleaner = remoteStorageCleaner;
this.coordinatorContext = coordinatorContext;
this.replicaStateMachine = replicaStateMachine;
this.tableBucketStateMachine = tableBucketStateMachine;
this.ioExecutor = ioExecutor;
+ this.lifecycleThrottler = lifecycleThrottler;
}
public void startup() {
@@ -208,22 +222,13 @@ public class TableManager {
private void resumeTableDeletions() {
Set<Long> tablesToBeDeleted = new
HashSet<>(coordinatorContext.getTablesToBeDeleted());
- Set<Long> eligibleTableDeletion = new HashSet<>();
-
for (long tableId : tablesToBeDeleted) {
- // if all replicas are marked as deleted successfully, then table
deletion is done
if (coordinatorContext.areAllReplicasInState(
tableId, ReplicaState.ReplicaDeletionSuccessful)) {
completeDeleteTable(tableId);
LOG.info("Deletion of table with id {} successfully
completed.", tableId);
- }
- if (isEligibleForDeletion(tableId)) {
- eligibleTableDeletion.add(tableId);
- }
- }
- if (!eligibleTableDeletion.isEmpty()) {
- for (long tableId : eligibleTableDeletion) {
- onDeleteTable(tableId);
+ } else if (isEligibleForDeletion(tableId)) {
+ lifecycleThrottler.submitTableDropForResume(tableId);
}
}
}
@@ -231,22 +236,18 @@ public class TableManager {
private void resumePartitionDeletions() {
Set<TablePartition> partitionsToDelete =
new HashSet<>(coordinatorContext.getPartitionsToBeDeleted());
- Set<TablePartition> eligiblePartitionDeletion = new HashSet<>();
-
for (TablePartition partition : partitionsToDelete) {
// if all replicas are marked as deleted successfully, then
partition deletion is done
if (coordinatorContext.areAllReplicasInState(
partition, ReplicaState.ReplicaDeletionSuccessful)) {
completeDeletePartition(partition);
LOG.info("Deletion of partition {} successfully completed.",
partition);
- }
- if (isEligibleForDeletion(partition)) {
- eligiblePartitionDeletion.add(partition);
- }
- }
- if (!eligiblePartitionDeletion.isEmpty()) {
- for (TablePartition partition : eligiblePartitionDeletion) {
- onDeletePartition(partition.getTableId(),
partition.getPartitionId());
+ } else if (isEligibleForDeletion(partition)) {
+ long tableId = partition.getTableId();
+ long partitionId = partition.getPartitionId();
+ String partitionName =
coordinatorContext.getPartitionName(partitionId);
+ lifecycleThrottler.submitPartitionDropForResume(
+ tableId, partitionId, partitionName);
}
}
}
@@ -257,6 +258,8 @@ public class TableManager {
asyncDeleteRemoteDirectory(tableId);
asyncDeleteTableMetadata(tableId);
coordinatorContext.removeTable(tableId);
+ // Release the manager's in-flight tracking so the next batch can be
submitted.
+ lifecycleThrottler.onTableDropCompleted(tableId);
}
private void completeDeletePartition(TablePartition tablePartition) {
@@ -267,6 +270,7 @@ public class TableManager {
asyncDeleteRemoteDirectory(tablePartition);
asyncDeletePartitionMetadata(tablePartition.getPartitionId());
coordinatorContext.removePartition(tablePartition);
+ lifecycleThrottler.onPartitionDropCompleted(tablePartition);
}
private void asyncDeleteRemoteDirectory(long tableId) {
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/ResumeDropEvent.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/ResumeDropEvent.java
new file mode 100644
index 000000000..056cfc154
--- /dev/null
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/ResumeDropEvent.java
@@ -0,0 +1,73 @@
+/*
+ * 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 javax.annotation.Nullable;
+
+/**
+ * Marker event emitted by {@code TableLifecycleThrottler} to drive
coordinator-startup
+ * reconciliation of stale tables/partitions whose {@code TableInfo} is
already gone (i.e. a regular
+ * {@code DropTableEvent} / {@code DropPartitionEvent} would NPE on the
missing {@code TableInfo}).
+ * It carries only identity (tableId + optional partitionId), never a {@link
Runnable}, so the
+ * dispatch target is determined by the event-thread handler and cannot be
influenced by the
+ * (potentially non-event-thread) submitter.
+ *
+ * <p>A {@code null} {@link #getPartitionId()} indicates a whole-table resume;
otherwise the resume
+ * targets the specific partition.
+ *
+ * <p>The event handler in {@code CoordinatorEventProcessor} routes this to
{@code
+ * TableManager#onDeleteTable} / {@code TableManager#onDeletePartition},
ensuring the
+ * {@code @NotThreadSafe} {@code CoordinatorContext} is only ever mutated from
the coordinator event
+ * thread.
+ */
+public class ResumeDropEvent implements CoordinatorEvent {
+
+ private final long tableId;
+ @Nullable private final Long partitionId;
+
+ private ResumeDropEvent(long tableId, @Nullable Long partitionId) {
+ this.tableId = tableId;
+ this.partitionId = partitionId;
+ }
+
+ public static ResumeDropEvent forTable(long tableId) {
+ return new ResumeDropEvent(tableId, null);
+ }
+
+ public static ResumeDropEvent forPartition(long tableId, long partitionId)
{
+ return new ResumeDropEvent(tableId, partitionId);
+ }
+
+ public long getTableId() {
+ return tableId;
+ }
+
+ /** Returns {@code null} when this resume targets the whole table. */
+ @Nullable
+ public Long getPartitionId() {
+ return partitionId;
+ }
+
+ @Override
+ public String toString() {
+ if (partitionId == null) {
+ return "ResumeDropEvent{tableId=" + tableId + "}";
+ }
+ return "ResumeDropEvent{tableId=" + tableId + ", partitionId=" +
partitionId + "}";
+ }
+}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcher.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcher.java
index 862092dfd..5e05877b4 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcher.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcher.java
@@ -23,10 +23,9 @@ import org.apache.fluss.metadata.PhysicalTablePath;
import org.apache.fluss.metadata.SchemaInfo;
import org.apache.fluss.metadata.TableInfo;
import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.server.coordinator.TableLifecycleThrottler;
import org.apache.fluss.server.coordinator.event.CreatePartitionEvent;
import org.apache.fluss.server.coordinator.event.CreateTableEvent;
-import org.apache.fluss.server.coordinator.event.DropPartitionEvent;
-import org.apache.fluss.server.coordinator.event.DropTableEvent;
import org.apache.fluss.server.coordinator.event.EventManager;
import org.apache.fluss.server.coordinator.event.SchemaChangeEvent;
import org.apache.fluss.server.coordinator.event.TableRegistrationChangeEvent;
@@ -58,13 +57,18 @@ public class TableChangeWatcher {
private volatile boolean running;
private final EventManager eventManager;
+ private final TableLifecycleThrottler lifecycleThrottler;
private final ZooKeeperClient zooKeeperClient;
- public TableChangeWatcher(ZooKeeperClient zooKeeperClient, EventManager
eventManager) {
+ public TableChangeWatcher(
+ ZooKeeperClient zooKeeperClient,
+ EventManager eventManager,
+ TableLifecycleThrottler lifecycleThrottler) {
this.zooKeeperClient = zooKeeperClient;
this.curatorCache =
CuratorCache.build(zooKeeperClient.getCuratorClient(),
DatabasesZNode.path());
this.eventManager = eventManager;
+ this.lifecycleThrottler = lifecycleThrottler;
this.curatorCache.listenable().addListener(new
TablePathChangeListener());
}
@@ -166,14 +170,17 @@ public class TableChangeWatcher {
PhysicalTablePath physicalTablePath =
PartitionZNode.parsePath(oldData.getPath());
if (physicalTablePath != null) {
- // it's for deletion of a table partition node
+ // it's for deletion of a table partition node.
Submit to the
+ // replica cleanup manager rather than putting a
DropPartitionEvent
+ // directly: the manager throttles admission into
the coordinator
+ // event queue so that a cascading drop (10k+
partitions) cannot
+ // starve unrelated coordinator work.
PartitionRegistration partition =
PartitionZNode.decode(oldData.getData());
- eventManager.put(
- new DropPartitionEvent(
- partition.getTableId(),
- partition.getPartitionId(),
-
physicalTablePath.getPartitionName()));
+ lifecycleThrottler.submitPartitionDrop(
+ partition.getTableId(),
+ partition.getPartitionId(),
+ physicalTablePath.getPartitionName());
} else {
// maybe table node is deleted
// try to parse the path as a table node
@@ -184,13 +191,11 @@ public class TableChangeWatcher {
TableRegistration table =
TableZNode.decode(oldData.getData());
TableConfig tableConfig =
new
TableConfig(Configuration.fromMap(table.properties));
- eventManager.put(
- new DropTableEvent(
- table.tableId,
- tableConfig
- .getAutoPartitionStrategy()
- .isAutoPartitionEnabled(),
- tableConfig.isDataLakeEnabled()));
+ lifecycleThrottler.submitTableDrop(
+ table.tableId,
+ table.isPartitioned(),
+
tableConfig.getAutoPartitionStrategy().isAutoPartitionEnabled(),
+ tableConfig.isDataLakeEnabled());
}
break;
}
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java
index 9b7b33fed..175634129 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java
@@ -531,6 +531,56 @@ class AutoPartitionManagerTest {
"20250419", "20250420", "20250421", "20250422",
"20250423");
}
+ @Test
+ void testDayPartitionDropShouldNotBeDelayedByJitter() throws Exception {
+ ZonedDateTime startTime =
+
LocalDateTime.parse("2025-04-19T00:00:00").atZone(ZoneId.systemDefault());
+ long startMs = startTime.toInstant().toEpochMilli();
+ ManualClock clock = new ManualClock(startMs);
+ ManuallyTriggeredScheduledExecutorService periodicExecutor =
+ new ManuallyTriggeredScheduledExecutorService();
+ AutoPartitionManager autoPartitionManager =
+ new AutoPartitionManager(
+ new TestingServerMetadataCache(3),
+ metadataManager,
+ remoteDirDynamicLoader,
+ new Configuration(),
+ clock,
+ periodicExecutor);
+ autoPartitionManager.start();
+
+ // Create a DAY-partitioned table: numRetention=2, numPreCreate=4
+ TableInfo table = createPartitionedTable(2, 4,
AutoPartitionTimeUnit.DAY);
+ TablePath tablePath = table.getTablePath();
+ autoPartitionManager.addAutoPartitionTable(table, true);
+ periodicExecutor.triggerNonPeriodicScheduledTasks();
+
+ Map<String, PartitionRegistration> partitions =
+ zookeeperClient.getPartitionRegistrations(tablePath);
+ // Initial: 20250419, 20250420, 20250421, 20250422
+ assertThat(partitions.keySet())
+ .containsExactlyInAnyOrder("20250419", "20250420", "20250421",
"20250422");
+
+ Integer delayMinutes =
+
autoPartitionManager.getAutoCreateDayDelayMinutes(table.getTableId());
+ assertThat(delayMinutes).isNotNull();
+
+ // Advance exactly 3 days to 2025-04-22T00:00:00.
+ // From 'now' perspective: current day is 20250422, retain 2 => keep
20250420, 20250421.
+ // So 20250419 should be dropped.
+ //
+ // With the bug (drop used delayed time): if delay > 0, delayed time
would still
+ // be on 20250421, retain 2 => keep 20250419, 20250420 => 20250419 NOT
dropped.
+ clock.advanceTime(Duration.ofDays(3));
+ periodicExecutor.triggerPeriodicScheduledTasks();
+
+ partitions = zookeeperClient.getPartitionRegistrations(tablePath);
+ // 20250419 must be dropped regardless of jitter delay
+ assertThat(partitions.keySet()).doesNotContain("20250419");
+ // Retained partitions should still exist
+ assertThat(partitions.keySet()).contains("20250420", "20250421",
"20250422");
+ }
+
/**
* Test if AutoPartionManager.createPartition adheres to maxBucketLimit
while adding new
* parition automatically, skip if it breaches limit.
@@ -696,6 +746,12 @@ class AutoPartitionManagerTest {
.containsExactlyInAnyOrder("2024091000", "2024091001",
"2024091002", "2024091003");
}
+ //
---------------------------------------------------------------------------------------
+ // Batch / inflight tests previously housed here have moved to
TableLifecycleThrottlerTest.
+ // The AutoPartitionManager now drops expired partitions synchronously and
the asynchronous
+ // replica cleanup throttling is handled by TableLifecycleThrottler.
+ //
---------------------------------------------------------------------------------------
+
private static class TestParams {
final AutoPartitionTimeUnit timeUnit;
final boolean multiplePartitionKeys;
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 d6813abb0..8f27cdd10 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
@@ -759,6 +759,204 @@ class CoordinatorEventProcessorTest {
replicationFactor);
}
+ @Test
+ void testDropPartitionRoutedThroughCleanupManager() throws Exception {
+ TablePath tablePath = TablePath.of(defaultDatabase,
"test_drop_partition_via_cleanup");
+ initCoordinatorChannel();
+ TableDescriptor partitionedTableDescriptor = getPartitionedTable();
+ long tableId =
+ metadataManager.createTable(
+ tablePath, remoteDataDir, partitionedTableDescriptor,
null, false);
+
+ int nBuckets = 3;
+ int replicationFactor = 3;
+ Map<Integer, BucketAssignment> assignments =
+ generateAssignment(
+ nBuckets,
+ replicationFactor,
+ new TabletServerInfo[] {
+ new TabletServerInfo(0, "rack0"),
+ new TabletServerInfo(1, "rack1"),
+ new TabletServerInfo(2, "rack2")
+ })
+ .getBucketAssignments();
+ PartitionAssignment partitionAssignment = new
PartitionAssignment(tableId, assignments);
+ Tuple2<PartitionIdName, PartitionIdName> partitionIdAndNameTuple2 =
+ preparePartitionAssignment(tablePath, tableId,
partitionAssignment);
+
+ long partition1Id = partitionIdAndNameTuple2.f0.partitionId;
+ String partition1Name = partitionIdAndNameTuple2.f0.partitionName;
+ long partition2Id = partitionIdAndNameTuple2.f1.partitionId;
+
+ verifyPartitionCreated(
+ new TablePartition(tableId, partition1Id),
+ partitionAssignment,
+ nBuckets,
+ replicationFactor);
+ verifyPartitionCreated(
+ new TablePartition(tableId, partition2Id),
+ partitionAssignment,
+ nBuckets,
+ replicationFactor);
+
+ // Drop partition1 via ZK (simulates the watcher path).
+ zookeeperClient.deletePartition(tablePath, partition1Name);
+
+ // Verify the drop entered the lifecycle throttler and partition is
fully deleted.
+ TableLifecycleThrottler throttler =
eventProcessor.getLifecycleThrottler();
+ verifyPartitionDropped(tableId, partition1Id);
+
+ // After completion, the lifecycle throttler should have released
tracking.
+ assertThat(throttler.getInflightCount()).isZero();
+ assertThat(throttler.getPendingDropCount()).isZero();
+
+ // Partition2 should remain online.
+ verifyPartitionCreated(
+ new TablePartition(tableId, partition2Id),
+ partitionAssignment,
+ nBuckets,
+ replicationFactor);
+ }
+
+ @Test
+ void testDropPartitionedTableRoutedThroughCleanupManager() throws
Exception {
+ TablePath tablePath = TablePath.of(defaultDatabase,
"test_drop_table_via_cleanup");
+ initCoordinatorChannel();
+ TableDescriptor partitionedTableDescriptor = getPartitionedTable();
+ long tableId =
+ metadataManager.createTable(
+ tablePath, remoteDataDir, partitionedTableDescriptor,
null, false);
+
+ int nBuckets = 3;
+ int replicationFactor = 3;
+ Map<Integer, BucketAssignment> assignments =
+ generateAssignment(
+ nBuckets,
+ replicationFactor,
+ new TabletServerInfo[] {
+ new TabletServerInfo(0, "rack0"),
+ new TabletServerInfo(1, "rack1"),
+ new TabletServerInfo(2, "rack2")
+ })
+ .getBucketAssignments();
+ PartitionAssignment partitionAssignment = new
PartitionAssignment(tableId, assignments);
+ Tuple2<PartitionIdName, PartitionIdName> partitionIdAndNameTuple2 =
+ preparePartitionAssignment(tablePath, tableId,
partitionAssignment);
+
+ long partition1Id = partitionIdAndNameTuple2.f0.partitionId;
+ long partition2Id = partitionIdAndNameTuple2.f1.partitionId;
+
+ verifyPartitionCreated(
+ new TablePartition(tableId, partition1Id),
+ partitionAssignment,
+ nBuckets,
+ replicationFactor);
+ verifyPartitionCreated(
+ new TablePartition(tableId, partition2Id),
+ partitionAssignment,
+ nBuckets,
+ replicationFactor);
+
+ // Drop the entire table (triggers NODE_DELETED for partitions + table
via watcher).
+ metadataManager.dropTable(tablePath, false);
+
+ // Verify the drops entered the lifecycle throttler.
+ TableLifecycleThrottler cleanupManager =
eventProcessor.getLifecycleThrottler();
+ retry(
+ Duration.ofMinutes(1),
+ () ->
+ assertThat(
+ cleanupManager.getInflightCount() > 0
+ || (!zookeeperClient
+
.getPartitionAssignment(
+
partition1Id)
+ .isPresent()
+ && !zookeeperClient
+
.getPartitionAssignment(
+
partition2Id)
+ .isPresent()))
+ .isTrue());
+
+ // Verify both partitions and the table are fully deleted.
+ verifyPartitionDropped(tableId, partition1Id);
+ verifyPartitionDropped(tableId, partition2Id);
+ verifyTableDropped(tableId);
+
+ // Lifecycle throttler returns to idle state after all completions
propagate.
+ retry(
+ Duration.ofMinutes(1),
+ () -> {
+ assertThat(cleanupManager.getInflightCount()).isZero();
+ assertThat(cleanupManager.getPendingDropCount()).isZero();
+ });
+ }
+
+ @Test
+ void testStartupResumesDropPartitionThroughCleanupManager() throws
Exception {
+ TablePath tablePath = TablePath.of(defaultDatabase,
"test_startup_resume_via_cleanup");
+ initCoordinatorChannel();
+ TableDescriptor partitionedTableDescriptor = getPartitionedTable();
+ long tableId =
+ metadataManager.createTable(
+ tablePath, remoteDataDir, partitionedTableDescriptor,
null, false);
+
+ int nBuckets = 3;
+ int replicationFactor = 3;
+ Map<Integer, BucketAssignment> assignments =
+ generateAssignment(
+ nBuckets,
+ replicationFactor,
+ new TabletServerInfo[] {
+ new TabletServerInfo(0, "rack0"),
+ new TabletServerInfo(1, "rack1"),
+ new TabletServerInfo(2, "rack2")
+ })
+ .getBucketAssignments();
+ PartitionAssignment partitionAssignment = new
PartitionAssignment(tableId, assignments);
+ Tuple2<PartitionIdName, PartitionIdName> partitionIdAndNameTuple2 =
+ preparePartitionAssignment(tablePath, tableId,
partitionAssignment);
+
+ long partition1Id = partitionIdAndNameTuple2.f0.partitionId;
+ String partition2Name = partitionIdAndNameTuple2.f1.partitionName;
+ long partition2Id = partitionIdAndNameTuple2.f1.partitionId;
+
+ verifyPartitionCreated(
+ new TablePartition(tableId, partition1Id),
+ partitionAssignment,
+ nBuckets,
+ replicationFactor);
+ verifyPartitionCreated(
+ new TablePartition(tableId, partition2Id),
+ partitionAssignment,
+ nBuckets,
+ replicationFactor);
+
+ // Shutdown the event processor, then delete partition2 in ZK while
it's down.
+ eventProcessor.shutdown();
+ zookeeperClient.deletePartition(tablePath, partition2Name);
+
+ // Restart the event processor. During startup, the stale partition2
should be
+ // detected and routed through the cleanup manager.
+ eventProcessor = buildCoordinatorEventProcessor();
+ initCoordinatorChannel();
+ eventProcessor.startup();
+
+ // Verify partition2 is fully deleted (routed through lifecycle
throttler on startup).
+ TableLifecycleThrottler cleanupManager =
eventProcessor.getLifecycleThrottler();
+ verifyPartitionDropped(tableId, partition2Id);
+
+ // Partition1 should remain online.
+ verifyPartitionCreated(
+ new TablePartition(tableId, partition1Id),
+ partitionAssignment,
+ nBuckets,
+ replicationFactor);
+
+ // Lifecycle throttler returns to idle.
+ assertThat(cleanupManager.getInflightCount()).isZero();
+ assertThat(cleanupManager.getPendingDropCount()).isZero();
+ }
+
@Test
void testNotifyOffsetsWithShrinkISR(@TempDir Path tempDir) throws
Exception {
initCoordinatorChannel(Collections.singleton(ApiKeys.UPDATE_METADATA));
@@ -1392,7 +1590,8 @@ class CoordinatorEventProcessorTest {
conf,
Executors.newFixedThreadPool(1, new
ExecutorThreadFactory("test-coordinator-io")),
metadataManager,
- kvSnapshotLeaseManager);
+ kvSnapshotLeaseManager,
+ SystemClock.getInstance());
}
private void initCoordinatorChannel() throws Exception {
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableLifecycleThrottlerTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableLifecycleThrottlerTest.java
new file mode 100644
index 000000000..fa5dd9069
--- /dev/null
+++
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableLifecycleThrottlerTest.java
@@ -0,0 +1,430 @@
+/*
+ * 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;
+
+import org.apache.fluss.metadata.TablePartition;
+import org.apache.fluss.server.coordinator.event.CoordinatorEvent;
+import org.apache.fluss.server.coordinator.event.DropPartitionEvent;
+import org.apache.fluss.server.coordinator.event.DropTableEvent;
+import org.apache.fluss.server.coordinator.event.EventManager;
+import org.apache.fluss.server.coordinator.event.ResumeDropEvent;
+import org.apache.fluss.utils.clock.ManualClock;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Unit tests for {@link TableLifecycleThrottler}. */
+class TableLifecycleThrottlerTest {
+
+ private RecordingEventManager eventManager;
+ private ManualClock clock;
+ private NoOpScheduledExecutor timeoutExecutor;
+
+ @BeforeEach
+ void setup() {
+ eventManager = new RecordingEventManager();
+ clock = new ManualClock(0L);
+ timeoutExecutor = new NoOpScheduledExecutor();
+ }
+
+ @AfterEach
+ void tearDown() {
+ timeoutExecutor.shutdownNow();
+ }
+
+ @Test
+ void testSubmitPartitionDropAdmitsImmediately() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.submitPartitionDrop(1L, 100L, "p20240101");
+
+
assertThat(eventManager.summaries()).containsExactly("partition:1:100:p20240101");
+ assertThat(manager.getInflightCount()).isOne();
+ assertThat(manager.getPendingDropCount()).isZero();
+ }
+
+ @Test
+ void testSubmitTableDropAdmitsImmediately() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.submitTableDrop(7L, false, false, false);
+
+
assertThat(eventManager.summaries()).containsExactly("table:7:auto=false:lake=false");
+ assertThat(manager.getInflightCount()).isOne();
+ }
+
+ @Test
+ void testOneAtATimeThrottling() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ // First drop is admitted immediately; subsequent drops pend.
+ manager.submitPartitionDrop(1L, 100L, "p1");
+ manager.submitPartitionDrop(1L, 101L, "p2");
+ manager.submitPartitionDrop(1L, 102L, "p3");
+
+
assertThat(eventManager.summaries()).containsExactly("partition:1:100:p1");
+ assertThat(manager.getInflightCount()).isOne();
+ assertThat(manager.getPendingDropCount()).isEqualTo(2);
+ }
+
+ @Test
+ void testCompletionAdmitsNextPending() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.submitPartitionDrop(1L, 100L, "p1");
+ manager.submitPartitionDrop(1L, 101L, "p2");
+ manager.submitPartitionDrop(1L, 102L, "p3");
+
+
assertThat(eventManager.summaries()).containsExactly("partition:1:100:p1");
+
+ // Complete the first; second is admitted.
+ manager.onPartitionDropCompleted(new TablePartition(1L, 100L));
+ assertThat(eventManager.summaries())
+ .containsExactly("partition:1:100:p1", "partition:1:101:p2");
+ assertThat(manager.getInflightCount()).isOne();
+ assertThat(manager.getPendingDropCount()).isOne();
+
+ // Complete the second; third is admitted.
+ manager.onPartitionDropCompleted(new TablePartition(1L, 101L));
+ assertThat(eventManager.summaries())
+ .containsExactly("partition:1:100:p1", "partition:1:101:p2",
"partition:1:102:p3");
+ assertThat(manager.getInflightCount()).isOne();
+ assertThat(manager.getPendingDropCount()).isZero();
+ }
+
+ @Test
+ void testTableDropCompletionAdmitsNext() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.submitTableDrop(1L, false, false, false);
+ manager.submitPartitionDrop(2L, 200L, "p2");
+
+
assertThat(eventManager.summaries()).containsExactly("table:1:auto=false:lake=false");
+ assertThat(manager.getPendingDropCount()).isOne();
+
+ manager.onTableDropCompleted(1L);
+
+ assertThat(eventManager.summaries())
+ .containsExactly("table:1:auto=false:lake=false",
"partition:2:200:p2");
+ assertThat(manager.getPendingDropCount()).isZero();
+ }
+
+ @Test
+ void testFifoOrder() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.submitPartitionDrop(1L, 10L, "p10");
+ manager.submitPartitionDrop(1L, 11L, "p11");
+ manager.submitPartitionDrop(1L, 12L, "p12");
+
+
assertThat(eventManager.summaries()).containsExactly("partition:1:10:p10");
+
+ manager.onPartitionDropCompleted(new TablePartition(1L, 10L));
+ manager.onPartitionDropCompleted(new TablePartition(1L, 11L));
+
+ assertThat(eventManager.summaries())
+ .containsExactly("partition:1:10:p10", "partition:1:11:p11",
"partition:1:12:p12");
+ }
+
+ @Test
+ void testCompletionForUnknownDropIsNoOp() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.onPartitionDropCompleted(new TablePartition(99L, 999L));
+ manager.onTableDropCompleted(123L);
+
+ assertThat(eventManager.summaries()).isEmpty();
+ assertThat(manager.getInflightCount()).isZero();
+ }
+
+ @Test
+ void testCompletionForMismatchedDropIsNoOp() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.submitPartitionDrop(1L, 100L, "p1");
+
+ // Complete with wrong partition id — should be no-op.
+ manager.onPartitionDropCompleted(new TablePartition(1L, 999L));
+ assertThat(manager.getInflightCount()).isOne();
+
+ // Complete with table callback — should be no-op (inflight is a
partition drop).
+ manager.onTableDropCompleted(1L);
+ assertThat(manager.getInflightCount()).isOne();
+ }
+
+ @Test
+ void testTimeoutAbandonsInflightDrop() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.submitTableDrop(1L, false, false, false);
+ manager.submitPartitionDrop(2L, 200L, "px");
+
+ assertThat(manager.getInflightCount()).isOne();
+ assertThat(manager.getPendingDropCount()).isOne();
+
+ // Not yet expired.
+ clock.advanceTime(Duration.ofMillis(100_000));
+ manager.checkTimeouts();
+ assertThat(manager.getInflightCount()).isOne();
+
+ // Crosses the 3-minute timeout boundary.
+ clock.advanceTime(Duration.ofMillis(90_000));
+ manager.checkTimeouts();
+
+ assertThat(eventManager.summaries())
+ .containsExactly("table:1:auto=false:lake=false",
"partition:2:200:px");
+ assertThat(manager.getPendingDropCount()).isZero();
+ assertThat(manager.getInflightCount()).isOne();
+ }
+
+ @Test
+ void testDuplicateCompletionIsNoOp() {
+ TableLifecycleThrottler manager = newThrottler();
+ TablePartition tp = new TablePartition(1L, 100L);
+
+ manager.submitPartitionDrop(tp.getTableId(), tp.getPartitionId(),
"px");
+ assertThat(manager.getInflightCount()).isOne();
+
+ manager.onPartitionDropCompleted(tp);
+ assertThat(manager.getInflightCount()).isZero();
+
+ // Second completion is a no-op.
+ manager.onPartitionDropCompleted(tp);
+ assertThat(manager.getInflightCount()).isZero();
+ }
+
+ @Test
+ void testStartIsIdempotentAndCloseDisablesStart() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.start();
+ manager.start(); // second start is a no-op
+
+ manager.close();
+
+
assertThatThrownBy(manager::start).isInstanceOf(IllegalStateException.class);
+ }
+
+ @Test
+ void testCloseIsIdempotent() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.close();
+ manager.close(); // must not throw
+ }
+
+ @Test
+ void testSubmitTableDropForResumeEnqueuesResumeEvent() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.submitTableDropForResume(7L);
+
+ // Resume-mode submission enqueues an identity-only ResumeDropEvent
for the event thread.
+ assertThat(eventManager.events).hasSize(1);
+
assertThat(eventManager.events.get(0)).isInstanceOf(ResumeDropEvent.class);
+ ResumeDropEvent resumeEvent = (ResumeDropEvent)
eventManager.events.get(0);
+ assertThat(resumeEvent.getPartitionId()).isNull();
+ assertThat(resumeEvent.getTableId()).isEqualTo(7L);
+ assertThat(manager.getInflightCount()).isOne();
+ }
+
+ @Test
+ void testSubmitPartitionDropForResumeEnqueuesResumeEvent() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.submitPartitionDropForResume(1L, 100L, "p20240101");
+
+ assertThat(eventManager.events).hasSize(1);
+
assertThat(eventManager.events.get(0)).isInstanceOf(ResumeDropEvent.class);
+ ResumeDropEvent resumeEvent = (ResumeDropEvent)
eventManager.events.get(0);
+ assertThat(resumeEvent.getTableId()).isEqualTo(1L);
+ assertThat(resumeEvent.getPartitionId()).isEqualTo(100L);
+ assertThat(manager.getInflightCount()).isOne();
+ }
+
+ @Test
+ void testForResumeDropRespectsOneAtATime() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ manager.submitTableDropForResume(1L);
+ manager.submitPartitionDropForResume(2L, 200L, "px");
+
+ // First admitted (resume event enqueued); second pends.
+ assertThat(eventManager.events).hasSize(1);
+ ResumeDropEvent firstEvent = (ResumeDropEvent)
eventManager.events.get(0);
+ assertThat(firstEvent.getPartitionId()).isNull();
+ assertThat(firstEvent.getTableId()).isEqualTo(1L);
+ assertThat(manager.getInflightCount()).isOne();
+ assertThat(manager.getPendingDropCount()).isOne();
+
+ // Completion of the first admits the second.
+ manager.onTableDropCompleted(1L);
+ assertThat(eventManager.events).hasSize(2);
+ ResumeDropEvent secondEvent = (ResumeDropEvent)
eventManager.events.get(1);
+ assertThat(secondEvent.getTableId()).isEqualTo(2L);
+ assertThat(secondEvent.getPartitionId()).isEqualTo(200L);
+ }
+
+ @Test
+ void testPartitionedTableDropIsFireAndForget() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ // Submit a partition drop first (will be admitted as inflight).
+ manager.submitPartitionDrop(1L, 100L, "p1");
+ // Then submit a partitioned table drop (pending).
+ manager.submitTableDrop(1L, true, true, false);
+ // Then submit another partition drop (pending).
+ manager.submitPartitionDrop(2L, 200L, "p2");
+
+
assertThat(eventManager.summaries()).containsExactly("partition:1:100:p1");
+ assertThat(manager.getInflightCount()).isOne();
+ assertThat(manager.getPendingDropCount()).isEqualTo(2);
+
+ // Complete partition1. The auto-partition table drop is admitted next.
+ // Since it's fire-and-forget, it immediately releases and admits the
next drop.
+ manager.onPartitionDropCompleted(new TablePartition(1L, 100L));
+
+ // Both the table drop AND the following partition drop should have
been admitted.
+ assertThat(eventManager.summaries())
+ .containsExactly(
+ "partition:1:100:p1", "table:1:auto=true:lake=false",
"partition:2:200:p2");
+ // The last partition drop is now inflight (waiting for completion).
+ assertThat(manager.getInflightCount()).isOne();
+ assertThat(manager.getPendingDropCount()).isZero();
+ }
+
+ @Test
+ void testPartitionedTableDropAdmittedFirstIsAlsoFireAndForget() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ // Submit a partitioned table drop as the first drop.
+ manager.submitTableDrop(5L, true, true, false);
+
+ // It should be executed immediately AND released (not tracked as
inflight).
+
assertThat(eventManager.summaries()).containsExactly("table:5:auto=true:lake=false");
+ assertThat(manager.getInflightCount()).isZero();
+ assertThat(manager.getPendingDropCount()).isZero();
+ }
+
+ @Test
+ void testPartitionedTableWithoutAutoPartitionIsAlsoFireAndForget() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ // A partitioned table without auto-partition still has no table-level
replicas.
+ manager.submitTableDrop(5L, true, false, false);
+
+ // It should also be fire-and-forget.
+
assertThat(eventManager.summaries()).containsExactly("table:5:auto=false:lake=false");
+ assertThat(manager.getInflightCount()).isZero();
+ assertThat(manager.getPendingDropCount()).isZero();
+ }
+
+ @Test
+ void testNonPartitionedTableDropIsNotFireAndForget() {
+ TableLifecycleThrottler manager = newThrottler();
+
+ // Non-partitioned table drop should be tracked normally.
+ manager.submitTableDrop(5L, false, false, false);
+
+
assertThat(eventManager.summaries()).containsExactly("table:5:auto=false:lake=false");
+ assertThat(manager.getInflightCount()).isOne();
+ assertThat(manager.getPendingDropCount()).isZero();
+ }
+
+ //
------------------------------------------------------------------------------------------
+ // Helpers
+ //
------------------------------------------------------------------------------------------
+
+ private TableLifecycleThrottler newThrottler() {
+ TableLifecycleThrottler throttler =
+ new TableLifecycleThrottler(
+ eventManager, clock, timeoutExecutor, 3 * 60 * 1000L,
60 * 1000L);
+ throttler.start(); // Activate throttling for unit tests.
+ return throttler;
+ }
+
+ /** Records the order of drop events admitted 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);
+ }
+
+ List<String> summaries() {
+ List<String> out = new ArrayList<>(events.size());
+ for (CoordinatorEvent event : events) {
+ if (event instanceof DropPartitionEvent) {
+ DropPartitionEvent e = (DropPartitionEvent) event;
+ out.add(
+ "partition:"
+ + e.getTableId()
+ + ":"
+ + e.getPartitionId()
+ + ":"
+ + e.getPartitionName());
+ } else if (event instanceof DropTableEvent) {
+ DropTableEvent e = (DropTableEvent) event;
+ out.add(
+ "table:"
+ + e.getTableId()
+ + ":auto="
+ + e.isAutoPartitionTable()
+ + ":lake="
+ + e.isDataLakeEnabled());
+ } else {
+ out.add(event.toString());
+ }
+ }
+ return out;
+ }
+ }
+
+ /**
+ * A scheduled executor that never actually runs scheduled tasks, so tests
retain full control
+ * over when {@link TableLifecycleThrottler#checkTimeouts()} is invoked.
+ */
+ private static final class NoOpScheduledExecutor extends
ScheduledThreadPoolExecutor
+ implements ScheduledExecutorService {
+
+ NoOpScheduledExecutor() {
+ super(0);
+ }
+
+ @Override
+ public java.util.concurrent.ScheduledFuture<?> scheduleWithFixedDelay(
+ Runnable command,
+ long initialDelay,
+ long delay,
+ java.util.concurrent.TimeUnit unit) {
+ // Intentionally drop the schedule request: tests drive
checkTimeouts() directly.
+ return null;
+ }
+ }
+}
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerTest.java
index f5357c549..68b65d80d 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerTest.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerTest.java
@@ -25,6 +25,7 @@ import org.apache.fluss.metadata.TableInfo;
import org.apache.fluss.metadata.TablePartition;
import org.apache.fluss.server.coordinator.event.CoordinatorEvent;
import
org.apache.fluss.server.coordinator.event.DeleteReplicaResponseReceivedEvent;
+import org.apache.fluss.server.coordinator.event.ResumeDropEvent;
import org.apache.fluss.server.coordinator.event.TestingEventManager;
import org.apache.fluss.server.coordinator.statemachine.ReplicaStateMachine;
import
org.apache.fluss.server.coordinator.statemachine.TableBucketStateMachine;
@@ -38,6 +39,7 @@ import org.apache.fluss.server.zk.data.BucketAssignment;
import org.apache.fluss.server.zk.data.PartitionAssignment;
import org.apache.fluss.server.zk.data.TableAssignment;
import org.apache.fluss.testutils.common.AllCallbackWrapper;
+import org.apache.fluss.utils.clock.SystemClock;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
@@ -84,6 +86,7 @@ class TableManagerTest {
private CoordinatorContext coordinatorContext;
private TableManager tableManager;
+ private TableLifecycleThrottler lifecycleThrottler;
private TestingEventManager testingEventManager;
private TestCoordinatorChannelManager testCoordinatorChannelManager;
@@ -107,6 +110,9 @@ class TableManagerTest {
if (tableManager != null) {
tableManager.shutdown();
}
+ if (lifecycleThrottler != null) {
+ lifecycleThrottler.close();
+ }
}
@AfterAll
@@ -134,6 +140,9 @@ class TableManagerTest {
zookeeperClient,
new Configuration(),
new LakeCatalogDynamicLoader(new Configuration(),
null, true));
+ lifecycleThrottler =
+ new TableLifecycleThrottler(
+ testingEventManager, SystemClock.getInstance(), new
Configuration());
tableManager =
new TableManager(
metadataManager,
@@ -141,7 +150,8 @@ class TableManagerTest {
replicaStateMachine,
tableBucketStateMachine,
new RemoteStorageCleaner(conf, ioExecutor),
- ioExecutor);
+ ioExecutor,
+ lifecycleThrottler);
tableManager.startup();
coordinatorContext.setLiveTabletServers(
@@ -261,6 +271,9 @@ class TableManagerTest {
// start table manager, should resume table deletion
tableManager.startup();
+ // TableLifecycleThrottler defers resume actions to the coordinator
event thread by
+ // enqueuing ResumeDropEvent; the unit test has no real event loop so
dispatch them inline.
+ dispatchResumeDropEvents();
checkReplicaDelete(tableId, null, assignment);
}
@@ -412,4 +425,23 @@ class TableManagerTest {
}
return deleteReplicaResponseReceivedEvent;
}
+
+ /**
+ * Drives every {@link ResumeDropEvent} currently in the testing event
manager. Mirrors the
+ * dispatch logic of {@code
CoordinatorEventProcessor#process(CoordinatorEvent)} so unit tests
+ * can trigger the deferred resume reconciliation without spinning up the
real event loop.
+ */
+ private void dispatchResumeDropEvents() {
+ for (CoordinatorEvent event : testingEventManager.getEvents()) {
+ if (event instanceof ResumeDropEvent) {
+ ResumeDropEvent resumeEvent = (ResumeDropEvent) event;
+ if (resumeEvent.getPartitionId() == null) {
+ tableManager.onDeleteTable(resumeEvent.getTableId());
+ } else {
+ tableManager.onDeletePartition(
+ resumeEvent.getTableId(),
resumeEvent.getPartitionId());
+ }
+ }
+ }
+ }
}
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcherTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcherTest.java
index 9baa3ec49..c181e4442 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcherTest.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcherTest.java
@@ -26,9 +26,11 @@ import org.apache.fluss.metadata.SchemaInfo;
import org.apache.fluss.metadata.TableChange;
import org.apache.fluss.metadata.TableDescriptor;
import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.metadata.TablePartition;
import org.apache.fluss.metadata.TablePath;
import org.apache.fluss.server.coordinator.LakeCatalogDynamicLoader;
import org.apache.fluss.server.coordinator.MetadataManager;
+import org.apache.fluss.server.coordinator.TableLifecycleThrottler;
import org.apache.fluss.server.coordinator.event.CoordinatorEvent;
import org.apache.fluss.server.coordinator.event.CreatePartitionEvent;
import org.apache.fluss.server.coordinator.event.CreateTableEvent;
@@ -46,6 +48,7 @@ import org.apache.fluss.server.zk.data.TableAssignment;
import org.apache.fluss.server.zk.data.TableRegistration;
import org.apache.fluss.testutils.common.AllCallbackWrapper;
import org.apache.fluss.types.DataTypes;
+import org.apache.fluss.utils.clock.SystemClock;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
@@ -81,6 +84,7 @@ class TableChangeWatcherTest {
private static ZooKeeperClient zookeeperClient;
private static String remoteDataDir;
private TestingEventManager eventManager;
+ private TableLifecycleThrottler lifecycleThrottler;
private TableChangeWatcher tableChangeWatcher;
private static MetadataManager metadataManager;
@@ -109,7 +113,11 @@ class TableChangeWatcherTest {
metadataManager.createDatabase(DEFAULT_DB,
DatabaseDescriptor.builder().build(), false);
eventManager = new TestingEventManager();
- tableChangeWatcher = new TableChangeWatcher(zookeeperClient,
eventManager);
+ lifecycleThrottler =
+ new TableLifecycleThrottler(
+ eventManager, SystemClock.getInstance(), new
Configuration());
+ tableChangeWatcher =
+ new TableChangeWatcher(zookeeperClient, eventManager,
lifecycleThrottler);
tableChangeWatcher.start();
}
@@ -118,6 +126,9 @@ class TableChangeWatcherTest {
if (tableChangeWatcher != null) {
tableChangeWatcher.stop();
}
+ if (lifecycleThrottler != null) {
+ lifecycleThrottler.close();
+ }
}
@Test
@@ -172,7 +183,14 @@ class TableChangeWatcherTest {
expectedTableEvents.add(new DropTableEvent(tableInfo.getTableId(),
false, false));
}
- // collect all events and check the all events
+ // The throttler admits one drop at a time; drive it by completing
each drop
+ // as it appears so the next pending drop is admitted.
+ for (CoordinatorEvent event : expectedTableEvents) {
+ DropTableEvent drop = (DropTableEvent) event;
+ lifecycleThrottler.onTableDropCompleted(drop.getTableId());
+ }
+
+ // collect all events and check the all events.
List<CoordinatorEvent> allEvents = new ArrayList<>(expectedEvents);
allEvents.addAll(expectedTableEvents);
retry(
@@ -257,11 +275,16 @@ class TableChangeWatcherTest {
// drop table event
expectedEvents.add(new DropTableEvent(tableId, true, false));
+ // The throttler admits one drop at a time; drive it by completing
partition
+ // drops as they arrive (the table drop is fire-and-forget for
partitioned tables).
retry(
Duration.ofMinutes(1),
- () ->
- assertThat(eventManager.getEvents())
-
.containsExactlyInAnyOrderElementsOf(expectedEvents));
+ () -> {
+ lifecycleThrottler.onPartitionDropCompleted(new
TablePartition(tableId, 1L));
+ lifecycleThrottler.onPartitionDropCompleted(new
TablePartition(tableId, 2L));
+ assertThat(eventManager.getEvents())
+
.containsExactlyInAnyOrderElementsOf(expectedEvents);
+ });
}
@Test
@@ -461,7 +484,11 @@ class TableChangeWatcherTest {
// existing nodes with full data - the same code path as when the async
// getData race causes NODE_CHANGED to be lost.
TestingEventManager newEventManager = new TestingEventManager();
- TableChangeWatcher newWatcher = new
TableChangeWatcher(zookeeperClient, newEventManager);
+ TableLifecycleThrottler newThrottler =
+ new TableLifecycleThrottler(
+ newEventManager, SystemClock.getInstance(), new
Configuration());
+ TableChangeWatcher newWatcher =
+ new TableChangeWatcher(zookeeperClient, newEventManager,
newThrottler);
newWatcher.start();
retry(
@@ -470,5 +497,6 @@ class TableChangeWatcherTest {
assertThat(newEventManager.getEvents())
.containsExactlyInAnyOrderElementsOf(expectedEvents));
newWatcher.stop();
+ newThrottler.close();
}
}
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 d550aea1c..0360b6a4e 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
@@ -311,7 +311,8 @@ public class RebalanceManagerTest {
conf,
Executors.newFixedThreadPool(1, new
ExecutorThreadFactory("test-coordinator-io")),
metadataManager,
- kvSnapshotLeaseManager);
+ kvSnapshotLeaseManager,
+ SystemClock.getInstance());
}
/** Records events put into the coordinator event queue. */
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 4dca20f56..2d3177b2a 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
@@ -312,7 +312,8 @@ class TableBucketStateMachineTest {
zookeeperClient,
new Configuration(),
new LakeCatalogDynamicLoader(new
Configuration(), null, true)),
- kvSnapshotLeaseManager);
+ kvSnapshotLeaseManager,
+ SystemClock.getInstance());
CoordinatorEventManager eventManager =
new CoordinatorEventManager(
coordinatorEventProcessor,
TestingMetricGroups.COORDINATOR_METRICS);
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java
b/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java
index 3d47a8861..ddf52b8ba 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java
@@ -998,6 +998,14 @@ public final class FlussClusterExtension
// reduce testing resources
clusterConf.set(ConfigOptions.NETTY_SERVER_NUM_NETWORK_THREADS, 1);
clusterConf.set(ConfigOptions.NETTY_SERVER_NUM_WORKER_THREADS, 3);
+ // Use short cleanup timeouts so that a slow in-flight drop does
not block
+ // subsequent drops beyond the test's retry window.
+ clusterConf.set(
+
ConfigOptions.COORDINATOR_LIFECYCLE_THROTTLER_INFLIGHT_TIMEOUT,
+ Duration.ofSeconds(2));
+ clusterConf.set(
+
ConfigOptions.COORDINATOR_LIFECYCLE_THROTTLER_TIMEOUT_CHECK_INTERVAL,
+ Duration.ofSeconds(1));
}
/** Sets the number of tablet servers. */
diff --git a/website/docs/maintenance/configuration.md
b/website/docs/maintenance/configuration.md
index ea3fec7fd..0a4223074 100644
--- a/website/docs/maintenance/configuration.md
+++ b/website/docs/maintenance/configuration.md
@@ -56,11 +56,13 @@ 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. |
+| 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.
[...]
## TabletServer