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 d356cce05 [server] Integrate remote directory selector into
table/partition creation (#2769)
d356cce05 is described below
commit d356cce050ca4f375a69bdbeba1dd8eeeedde8f3
Author: Liebing <[email protected]>
AuthorDate: Mon May 25 11:26:32 2026 +0800
[server] Integrate remote directory selector into table/partition creation
(#2769)
---
.../fluss/client/admin/FlussAdminITCase.java | 37 +++
.../org/apache/fluss/config/FlussConfigUtils.java | 76 +++---
.../apache/fluss/config/FlussConfigUtilsTest.java | 14 ++
.../apache/fluss/server/DynamicServerConfig.java | 9 +-
.../org/apache/fluss/server/RpcServiceBase.java | 1 +
.../server/coordinator/AutoPartitionManager.java | 10 +-
.../server/coordinator/CoordinatorServer.java | 15 +-
.../server/coordinator/CoordinatorService.java | 20 +-
.../fluss/server/coordinator/MetadataManager.java | 10 +-
.../coordinator/remote/RemoteDirDynamicLoader.java | 175 +++++++++++++
.../coordinator/remote/RemoteDirSelector.java | 49 ++++
.../remote/RoundRobinRemoteDirSelector.java | 55 ++++
.../WeightedRoundRobinRemoteDirSelector.java | 108 ++++++++
.../fluss/server/DynamicConfigChangeTest.java | 55 ++++
.../coordinator/AutoPartitionManagerTest.java | 27 +-
.../coordinator/CoordinatorEventProcessorTest.java | 64 +++--
.../event/watcher/TableChangeWatcherTest.java | 17 +-
.../rebalance/RebalanceManagerTest.java | 18 +-
.../remote/RemoteDirDynamicLoaderTest.java | 131 ++++++++++
.../coordinator/remote/RemoteDirsITCase.java | 279 +++++++++++++++++++++
.../remote/RoundRobinRemoteDirSelectorTest.java | 163 ++++++++++++
.../WeightedRoundRobinRemoteDirSelectorTest.java | 246 ++++++++++++++++++
.../statemachine/TableBucketStateMachineTest.java | 2 +
.../metadata/ZkBasedMetadataProviderTest.java | 4 +-
.../server/testutils/FlussClusterExtension.java | 35 ++-
25 files changed, 1543 insertions(+), 77 deletions(-)
diff --git
a/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
b/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
index 2cda18398..da737dd07 100644
---
a/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
+++
b/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
@@ -1619,6 +1619,43 @@ class FlussAdminITCase extends ClientToServerITCaseBase {
.get();
}
+ @Test
+ void testDynamicRemoteDataDirsWithRoundRobin() throws Exception {
+ String originalDir = FLUSS_CLUSTER_EXTENSION.getRemoteDataDir();
+ String newDir1 =
FLUSS_CLUSTER_EXTENSION.getRemoteDataDir("remote-dir-1");
+ String newDir2 =
FLUSS_CLUSTER_EXTENSION.getRemoteDataDir("remote-dir-2");
+
+ // Dynamically switch from single remote.data.dir to multiple
remote.data.dirs
+ // with round-robin strategy; original dir must be included
+ admin.alterClusterConfigs(
+ Collections.singletonList(
+ new AlterConfig(
+ ConfigOptions.REMOTE_DATA_DIRS.key(),
+ String.join(",", originalDir, newDir1,
newDir2),
+ AlterConfigOpType.SET)))
+ .get();
+
+ // Create 6 tables and verify round-robin distribution across 3
directories
+ int tableCount = 6;
+ Map<String, Integer> dirUsageCount = new HashMap<>();
+ for (int i = 0; i < tableCount; i++) {
+ TablePath tablePath = TablePath.of("test_db", "dynamic_rr_table_"
+ i);
+ createTable(
+ tablePath,
+
TableDescriptor.builder().schema(DEFAULT_SCHEMA).distributedBy(1, "id").build(),
+ true);
+
+ TableInfo tableInfo = admin.getTableInfo(tablePath).get();
+ String remoteDataDir = tableInfo.getRemoteDataDir();
+ assertThat(remoteDataDir).isNotNull();
+ dirUsageCount.merge(remoteDataDir, 1, Integer::sum);
+ }
+
+ // Each of the 3 directories should be used exactly twice (6 / 3 = 2)
+ assertThat(dirUsageCount).hasSize(3);
+ assertThat(dirUsageCount.values()).allMatch(count -> count == 2);
+ }
+
private void assertConfigEntry(
String key, @Nullable String value, ConfigEntry.ConfigSource
source)
throws ExecutionException, InterruptedException {
diff --git
a/fluss-common/src/main/java/org/apache/fluss/config/FlussConfigUtils.java
b/fluss-common/src/main/java/org/apache/fluss/config/FlussConfigUtils.java
index adbbce0af..7daa48c2b 100644
--- a/fluss-common/src/main/java/org/apache/fluss/config/FlussConfigUtils.java
+++ b/fluss-common/src/main/java/org/apache/fluss/config/FlussConfigUtils.java
@@ -128,34 +128,9 @@ public class FlussConfigUtils {
validMinValue(ConfigOptions.TABLET_SERVER_ID, serverId.get(), 0);
}
- /** Validate common server configs. */
- protected static void validateServerConfigs(Configuration conf) {
- // Validate remote.data.dir and remote.data.dirs
- String remoteDataDir = conf.get(ConfigOptions.REMOTE_DATA_DIR);
- List<String> remoteDataDirs = conf.get(ConfigOptions.REMOTE_DATA_DIRS);
- if (conf.get(ConfigOptions.REMOTE_DATA_DIR) == null
- && conf.get(ConfigOptions.REMOTE_DATA_DIRS).isEmpty()) {
- throw new IllegalConfigurationException(
- String.format(
- "Either %s or %s must be configured.",
- ConfigOptions.REMOTE_DATA_DIR.key(),
- ConfigOptions.REMOTE_DATA_DIRS.key()));
- }
-
- if (remoteDataDir != null) {
- // Must validate that remote.data.dir is a valid FsPath
- try {
- new FsPath(conf.get(ConfigOptions.REMOTE_DATA_DIR));
- } catch (Exception e) {
- throw new IllegalConfigurationException(
- String.format(
- "Invalid configuration for %s.",
- ConfigOptions.REMOTE_DATA_DIR.key()),
- e);
- }
- }
-
+ public static void validateRemoteDataDirs(Configuration conf) {
// Validate remote.data.dirs
+ List<String> remoteDataDirs = conf.get(ConfigOptions.REMOTE_DATA_DIRS);
for (int i = 0; i < remoteDataDirs.size(); i++) {
String dir = remoteDataDirs.get(i);
try {
@@ -185,19 +160,56 @@ public class FlussConfigUtils {
weights.size()));
}
- // Validate all weights are no less than 0
+ // Verify that each weight is non-negative and that the total
weight is greater than
+ // 0.
+ int totalWeight = 0;
for (int i = 0; i < weights.size(); i++) {
- if (weights.get(i) < 0) {
+ int weight = weights.get(i);
+ if (weight < 0) {
throw new IllegalConfigurationException(
String.format(
"All weights in '%s' must be no less
than 0, but found %d at index %d.",
-
ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS.key(),
- weights.get(i),
- i));
+
ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS.key(), weight, i));
}
+ totalWeight += weight;
+ }
+ if (totalWeight <= 0) {
+ throw new IllegalConfigurationException(
+ String.format(
+ "The sum of all weights in '%s' must be
greater than 0, but the current sum is %d.",
+
ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS.key(), totalWeight));
}
}
}
+ }
+
+ /** Validate common server configs. */
+ protected static void validateServerConfigs(Configuration conf) {
+ // Validate remote.data.dir and remote.data.dirs
+ String remoteDataDir = conf.get(ConfigOptions.REMOTE_DATA_DIR);
+ List<String> remoteDataDirs = conf.get(ConfigOptions.REMOTE_DATA_DIRS);
+ if (remoteDataDir == null && remoteDataDirs.isEmpty()) {
+ throw new IllegalConfigurationException(
+ String.format(
+ "Either %s or %s must be configured.",
+ ConfigOptions.REMOTE_DATA_DIR.key(),
+ ConfigOptions.REMOTE_DATA_DIRS.key()));
+ }
+
+ if (remoteDataDir != null) {
+ // Must validate that remote.data.dir is a valid FsPath
+ try {
+ new FsPath(conf.get(ConfigOptions.REMOTE_DATA_DIR));
+ } catch (Exception e) {
+ throw new IllegalConfigurationException(
+ String.format(
+ "Invalid configuration for %s.",
+ ConfigOptions.REMOTE_DATA_DIR.key()),
+ e);
+ }
+ }
+
+ validateRemoteDataDirs(conf);
validMinValue(conf, ConfigOptions.DEFAULT_REPLICATION_FACTOR, 1);
validMinValue(conf, ConfigOptions.KV_MAX_RETAINED_SNAPSHOTS, 1);
diff --git
a/fluss-common/src/test/java/org/apache/fluss/config/FlussConfigUtilsTest.java
b/fluss-common/src/test/java/org/apache/fluss/config/FlussConfigUtilsTest.java
index e2690c54b..2783eea7a 100644
---
a/fluss-common/src/test/java/org/apache/fluss/config/FlussConfigUtilsTest.java
+++
b/fluss-common/src/test/java/org/apache/fluss/config/FlussConfigUtilsTest.java
@@ -137,6 +137,20 @@ class FlussConfigUtilsTest {
.hasMessageContaining(
"All weights in 'remote.data.dirs.weights' must be no
less than 0");
+ // Test all zero weights
+ Configuration zeroWeightsConf = new Configuration();
+ zeroWeightsConf.set(
+ ConfigOptions.REMOTE_DATA_DIRS_STRATEGY,
+ ConfigOptions.RemoteDataDirStrategy.WEIGHTED_ROUND_ROBIN);
+ zeroWeightsConf.set(
+ ConfigOptions.REMOTE_DATA_DIRS, Arrays.asList("s3://bucket1",
"s3://bucket2"));
+ zeroWeightsConf.set(ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS,
Arrays.asList(0, 0));
+ assertThatThrownBy(() -> validateCoordinatorConfigs(zeroWeightsConf))
+ .isInstanceOf(IllegalConfigurationException.class)
+ .hasMessageContaining("The sum of all weights")
+
.hasMessageContaining(ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS.key())
+ .hasMessageContaining("must be greater than 0");
+
// Test invalid DEFAULT_REPLICATION_FACTOR
Configuration invalidReplicationConf = new Configuration();
invalidReplicationConf.set(ConfigOptions.REMOTE_DATA_DIR,
"s3://bucket/path");
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/DynamicServerConfig.java
b/fluss-server/src/main/java/org/apache/fluss/server/DynamicServerConfig.java
index e130c392d..dee0b1fad 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/DynamicServerConfig.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/DynamicServerConfig.java
@@ -45,6 +45,9 @@ import static
org.apache.fluss.config.ConfigOptions.DATALAKE_FORMAT;
import static
org.apache.fluss.config.ConfigOptions.KV_SHARED_RATE_LIMITER_BYTES_PER_SEC;
import static org.apache.fluss.config.ConfigOptions.KV_SNAPSHOT_INTERVAL;
import static
org.apache.fluss.config.ConfigOptions.LOG_REPLICA_MIN_IN_SYNC_REPLICAS_NUMBER;
+import static org.apache.fluss.config.ConfigOptions.REMOTE_DATA_DIRS;
+import static org.apache.fluss.config.ConfigOptions.REMOTE_DATA_DIRS_STRATEGY;
+import static org.apache.fluss.config.ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS;
import static
org.apache.fluss.config.ConfigOptions.SERVER_DATA_DISK_WRITE_LIMIT_RATIO;
import static org.apache.fluss.utils.concurrent.LockUtils.inReadLock;
import static org.apache.fluss.utils.concurrent.LockUtils.inWriteLock;
@@ -66,7 +69,11 @@ class DynamicServerConfig {
LOG_REPLICA_MIN_IN_SYNC_REPLICAS_NUMBER.key(),
KV_SHARED_RATE_LIMITER_BYTES_PER_SEC.key(),
KV_SNAPSHOT_INTERVAL.key(),
- SERVER_DATA_DISK_WRITE_LIMIT_RATIO.key()));
+ SERVER_DATA_DISK_WRITE_LIMIT_RATIO.key(),
+ // Config options for remote.data.dirs
+ REMOTE_DATA_DIRS.key(),
+ REMOTE_DATA_DIRS_STRATEGY.key(),
+ REMOTE_DATA_DIRS_WEIGHTS.key()));
private static final Set<String> ALLOWED_CONFIG_PREFIXES =
Collections.singleton("datalake.");
private final ReadWriteLock lock = new ReentrantReadWriteLock();
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java
b/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java
index dedef6ea8..f7b6de9eb 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java
@@ -302,6 +302,7 @@ public abstract class RpcServiceBase extends
RpcGatewayService implements AdminR
response.setTableJson(tableInfo.toTableDescriptor().toJsonBytes())
.setSchemaId(tableInfo.getSchemaId())
.setTableId(tableInfo.getTableId())
+ .setRemoteDataDir(tableInfo.getRemoteDataDir())
.setCreatedTime(tableInfo.getCreatedTime())
.setModifiedTime(tableInfo.getModifiedTime());
return CompletableFuture.completedFuture(response);
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 695c770d4..ae3ceef33 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
@@ -29,6 +29,7 @@ import org.apache.fluss.exception.TooManyPartitionsException;
import org.apache.fluss.metadata.ResolvedPartitionSpec;
import org.apache.fluss.metadata.TableInfo;
import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader;
import org.apache.fluss.server.metadata.ServerMetadataCache;
import org.apache.fluss.server.zk.data.BucketAssignment;
import org.apache.fluss.server.zk.data.PartitionAssignment;
@@ -85,6 +86,7 @@ public class AutoPartitionManager implements AutoCloseable {
private final ServerMetadataCache metadataCache;
private final MetadataManager metadataManager;
+ private final RemoteDirDynamicLoader remoteDirDynamicLoader;
private final Clock clock;
private final long periodicInterval;
@@ -108,10 +110,12 @@ public class AutoPartitionManager implements
AutoCloseable {
public AutoPartitionManager(
ServerMetadataCache metadataCache,
MetadataManager metadataManager,
+ RemoteDirDynamicLoader remoteDirDynamicLoader,
Configuration conf) {
this(
metadataCache,
metadataManager,
+ remoteDirDynamicLoader,
conf,
SystemClock.getInstance(),
Executors.newScheduledThreadPool(
@@ -122,11 +126,13 @@ public class AutoPartitionManager implements
AutoCloseable {
AutoPartitionManager(
ServerMetadataCache metadataCache,
MetadataManager metadataManager,
+ RemoteDirDynamicLoader remoteDirDynamicLoader,
Configuration conf,
Clock clock,
ScheduledExecutorService periodicExecutor) {
this.metadataCache = metadataCache;
this.metadataManager = metadataManager;
+ this.remoteDirDynamicLoader = remoteDirDynamicLoader;
this.clock = clock;
this.periodicExecutor = periodicExecutor;
this.periodicInterval =
conf.get(ConfigOptions.AUTO_PARTITION_CHECK_INTERVAL).toMillis();
@@ -373,8 +379,10 @@ public class AutoPartitionManager implements AutoCloseable
{
PartitionAssignment partitionAssignment =
new PartitionAssignment(tableInfo.getTableId(),
bucketAssignments);
+ // select a remote data dir for the partition
+ String remoteDataDir =
remoteDirDynamicLoader.getRemoteDirSelector().nextDataDir();
metadataManager.createPartition(
- tablePath, tableId, partitionAssignment, partition,
false);
+ tablePath, tableId, remoteDataDir,
partitionAssignment, partition, false);
// only single partition key table supports automatic creation
of partitions
currentPartitions.put(partition.getPartitionName(), null);
LOG.info(
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 c3094c7ad..6914e8207 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
@@ -34,6 +34,7 @@ import org.apache.fluss.server.authorizer.Authorizer;
import org.apache.fluss.server.authorizer.AuthorizerLoader;
import org.apache.fluss.server.coordinator.lease.KvSnapshotLeaseManager;
import org.apache.fluss.server.coordinator.rebalance.RebalanceManager;
+import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader;
import org.apache.fluss.server.metadata.CoordinatorMetadataCache;
import org.apache.fluss.server.metadata.ServerMetadataCache;
import org.apache.fluss.server.metrics.ServerMetricUtils;
@@ -148,6 +149,9 @@ public class CoordinatorServer extends ServerBase {
@GuardedBy("lock")
private LakeCatalogDynamicLoader lakeCatalogDynamicLoader;
+ @GuardedBy("lock")
+ private RemoteDirDynamicLoader remoteDirDynamicLoader;
+
@GuardedBy("lock")
private CoordinatorLeaderElection coordinatorLeaderElection;
@@ -225,10 +229,13 @@ public class CoordinatorServer extends ServerBase {
this.coordinatorLeaderElection = new
CoordinatorLeaderElection(zkClient, serverId);
this.lakeCatalogDynamicLoader = new LakeCatalogDynamicLoader(conf,
pluginManager, true);
+ this.remoteDirDynamicLoader = new RemoteDirDynamicLoader(conf);
+
this.dynamicConfigManager = new DynamicConfigManager(zkClient,
conf, true);
// Register server reconfigurable components
dynamicConfigManager.register(lakeCatalogDynamicLoader);
+ dynamicConfigManager.register(remoteDirDynamicLoader);
// Register stateless validators for coordinator-side upfront
validation
dynamicConfigManager.registerValidator(new
DiskWriteLimitRatioValidator());
@@ -274,6 +281,7 @@ public class CoordinatorServer extends ServerBase {
authorizer,
lakeCatalogDynamicLoader,
lakeTableTieringManager,
+ remoteDirDynamicLoader,
dynamicConfigManager,
ioExecutor,
kvSnapshotLeaseManager,
@@ -307,7 +315,8 @@ public class CoordinatorServer extends ServerBase {
this.coordinatorChannelManager = new
CoordinatorChannelManager(rpcClient);
this.autoPartitionManager =
- new AutoPartitionManager(metadataCache, metadataManager,
conf);
+ new AutoPartitionManager(
+ metadataCache, metadataManager,
remoteDirDynamicLoader, conf);
autoPartitionManager.start();
// start coordinator event processor after we register coordinator
leader to zk
@@ -620,6 +629,10 @@ public class CoordinatorServer extends ServerBase {
lakeCatalogDynamicLoader.close();
}
+ if (remoteDirDynamicLoader != null) {
+ remoteDirDynamicLoader.close();
+ }
+
if (kvSnapshotLeaseManager != null) {
kvSnapshotLeaseManager.close();
}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
index bdc97434e..e96e36f45 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
@@ -154,6 +154,7 @@ import
org.apache.fluss.server.coordinator.lease.KvSnapshotLeaseHandler;
import org.apache.fluss.server.coordinator.lease.KvSnapshotLeaseManager;
import org.apache.fluss.server.coordinator.producer.ProducerOffsetsManager;
import org.apache.fluss.server.coordinator.rebalance.goal.Goal;
+import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader;
import org.apache.fluss.server.entity.CommitKvSnapshotData;
import org.apache.fluss.server.entity.DatabasePropertyChanges;
import org.apache.fluss.server.entity.LakeTieringTableInfo;
@@ -241,6 +242,7 @@ public final class CoordinatorService extends
RpcServiceBase implements Coordina
private final ProducerOffsetsManager producerOffsetsManager;
private final KvSnapshotLeaseManager kvSnapshotLeaseManager;
private final CoordinatorLeaderElection coordinatorLeaderElection;
+ private final RemoteDirDynamicLoader remoteDirDynamicLoader;
public CoordinatorService(
Configuration conf,
@@ -252,6 +254,7 @@ public final class CoordinatorService extends
RpcServiceBase implements Coordina
@Nullable Authorizer authorizer,
LakeCatalogDynamicLoader lakeCatalogDynamicLoader,
LakeTableTieringManager lakeTableTieringManager,
+ RemoteDirDynamicLoader remoteDirDynamicLoader,
DynamicConfigManager dynamicConfigManager,
ExecutorService ioExecutor,
KvSnapshotLeaseManager kvSnapshotLeaseManager,
@@ -278,6 +281,7 @@ public final class CoordinatorService extends
RpcServiceBase implements Coordina
this.ioExecutor = ioExecutor;
this.lakeTableHelper =
new LakeTableHelper(zkClient,
conf.getString(ConfigOptions.REMOTE_DATA_DIR));
+ this.remoteDirDynamicLoader = remoteDirDynamicLoader;
// Initialize and start the producer snapshot manager
this.producerOffsetsManager = new ProducerOffsetsManager(conf,
zkClient);
@@ -493,9 +497,18 @@ public final class CoordinatorService extends
RpcServiceBase implements Coordina
}
}
+ // select remote data dir for table.
+ // remote data dir will be used to store table data for
non-partitioned table and metadata
+ // (such as lake snapshot offset file) for partitioned table
+ String remoteDataDir =
remoteDirDynamicLoader.getRemoteDirSelector().nextDataDir();
+
// then create table;
metadataManager.createTable(
- tablePath, tableDescriptor, tableAssignment,
request.isIgnoreIfExists());
+ tablePath,
+ remoteDataDir,
+ tableDescriptor,
+ tableAssignment,
+ request.isIgnoreIfExists());
return CompletableFuture.completedFuture(new CreateTableResponse());
}
@@ -708,9 +721,13 @@ public final class CoordinatorService extends
RpcServiceBase implements Coordina
PartitionAssignment partitionAssignment =
new PartitionAssignment(table.tableId, bucketAssignments);
+ // select remote data dir for partition
+ String remoteDataDir =
remoteDirDynamicLoader.getRemoteDirSelector().nextDataDir();
+
metadataManager.createPartition(
tablePath,
table.tableId,
+ remoteDataDir,
partitionAssignment,
partitionToCreate,
request.isIgnoreIfNotExists());
@@ -759,6 +776,7 @@ public final class CoordinatorService extends
RpcServiceBase implements Coordina
return metadataResponseAccessContextEvent.getResultFuture();
}
+ @Override
public CompletableFuture<AdjustIsrResponse> adjustIsr(AdjustIsrRequest
request) {
CompletableFuture<AdjustIsrResponse> response = new
CompletableFuture<>();
eventManagerSupplier
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java
index 65ba95d2a..e1d817950 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java
@@ -364,6 +364,7 @@ public class MetadataManager {
* Returns -1 if the table already exists and ignoreIfExists is true.
*
* @param tablePath the table path
+ * @param remoteDataDir the remote data directory
* @param tableToCreate the table descriptor describing the table to create
* @param tableAssignment the table assignment, will be null when the
table is partitioned table
* @param ignoreIfExists whether to ignore if the table already exists
@@ -371,6 +372,7 @@ public class MetadataManager {
*/
public long createTable(
TablePath tablePath,
+ String remoteDataDir,
TableDescriptor tableToCreate,
@Nullable TableAssignment tableAssignment,
boolean ignoreIfExists)
@@ -410,10 +412,7 @@ public class MetadataManager {
// register the table
zookeeperClient.registerTable(
tablePath,
- TableRegistration.newTable(
- tableId,
- zookeeperClient.getDefaultRemoteDataDir(),
- tableToCreate),
+ TableRegistration.newTable(tableId, remoteDataDir,
tableToCreate),
false);
return tableId;
},
@@ -804,6 +803,7 @@ public class MetadataManager {
public void createPartition(
TablePath tablePath,
long tableId,
+ String remoteDataDir,
PartitionAssignment partitionAssignment,
ResolvedPartitionSpec partition,
boolean ignoreIfExists) {
@@ -866,7 +866,7 @@ public class MetadataManager {
partitionId,
partitionName,
partitionAssignment,
- zookeeperClient.getDefaultRemoteDataDir(),
+ remoteDataDir,
tablePath,
tableId);
LOG.info(
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/RemoteDirDynamicLoader.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/RemoteDirDynamicLoader.java
new file mode 100644
index 000000000..3c63bd2b1
--- /dev/null
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/RemoteDirDynamicLoader.java
@@ -0,0 +1,175 @@
+/*
+ * 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.remote;
+
+import org.apache.fluss.config.ConfigOption;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.config.cluster.ServerReconfigurable;
+import org.apache.fluss.exception.ConfigException;
+import org.apache.fluss.exception.IllegalConfigurationException;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.apache.fluss.config.FlussConfigUtils.validateRemoteDataDirs;
+
+/**
+ * Dynamic loader for remote data directories that supports runtime
reconfiguration.
+ *
+ * <p>This class manages the lifecycle of remote data directories and provides
a selector for
+ * selecting remote data directories. It implements {@link
ServerReconfigurable} to support dynamic
+ * configuration updates at runtime without requiring a server restart.
+ *
+ * <p>When creating a new table or partition, the coordinator server uses this
loader to select an
+ * appropriate remote data directory based on the configured selection
strategy (see {@link
+ * org.apache.fluss.config.ConfigOptions#REMOTE_DATA_DIRS_STRATEGY}).
+ */
+public class RemoteDirDynamicLoader implements ServerReconfigurable,
AutoCloseable {
+
+ private volatile RemoteDirSelector remoteDirSelector;
+ private Configuration currentConfiguration;
+
+ public RemoteDirDynamicLoader(Configuration configuration) {
+ this.currentConfiguration = configuration;
+ this.remoteDirSelector = createRemoteDirSelector(configuration);
+ }
+
+ public RemoteDirSelector getRemoteDirSelector() {
+ return remoteDirSelector;
+ }
+
+ @Override
+ public void validate(Configuration newConfig) throws ConfigException {
+ // Validate new remote data dirs contain all old remote data dirs
+ Optional<List<String>> newRemoteDataDirsOp =
+ newConfig.getOptional(ConfigOptions.REMOTE_DATA_DIRS);
+ if (newRemoteDataDirsOp.isPresent()) {
+ List<String> oldRemoteDataDirs =
+ currentConfiguration.get(ConfigOptions.REMOTE_DATA_DIRS);
+ // When old remote.data.dirs is empty, the cluster was using
remote.data.dir
+ // as the sole directory. Ensure it is included in new
remote.data.dirs.
+ if (oldRemoteDataDirs.isEmpty()) {
+ String oldRemoteDataDir =
currentConfiguration.get(ConfigOptions.REMOTE_DATA_DIR);
+ if (oldRemoteDataDir != null) {
+ oldRemoteDataDirs =
Collections.singletonList(oldRemoteDataDir);
+ }
+ }
+ Set<String> newRemoteDataDirs = new
HashSet<>(newRemoteDataDirsOp.get());
+ if (!newRemoteDataDirs.containsAll(oldRemoteDataDirs)) {
+ throw new ConfigException(
+ String.format(
+ "New %s: %s must contain all existing remote
data directories: %s. "
+ + "If you want the Fluss cluster to
stop transferring data to a certain path, "
+ + "keep it in %s and set its weight to
0 in %s.",
+ ConfigOptions.REMOTE_DATA_DIRS.key(),
+ newRemoteDataDirsOp.get(),
+ oldRemoteDataDirs,
+ ConfigOptions.REMOTE_DATA_DIRS.key(),
+ ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS.key()));
+ }
+ }
+
+ Configuration mergedConfig = mergeConfigurations(currentConfiguration,
newConfig);
+ try {
+ validateRemoteDataDirs(mergedConfig);
+ } catch (IllegalConfigurationException e) {
+ throw new ConfigException(e.getMessage());
+ }
+ }
+
+ @Override
+ public void reconfigure(Configuration newConfig) throws ConfigException {
+ if (strategyChanged(newConfig)
+ || remoteDataDirsChanged(newConfig)
+ || weightsChanged(newConfig)) {
+ // Create a new container with the merged configuration
+ Configuration mergedConfig =
mergeConfigurations(currentConfiguration, newConfig);
+ this.remoteDirSelector = createRemoteDirSelector(mergedConfig);
+ this.currentConfiguration = mergedConfig;
+ }
+ }
+
+ private RemoteDirSelector createRemoteDirSelector(Configuration conf) {
+ ConfigOptions.RemoteDataDirStrategy strategy =
+ conf.get(ConfigOptions.REMOTE_DATA_DIRS_STRATEGY);
+ String remoteDataDir = conf.get(ConfigOptions.REMOTE_DATA_DIR);
+ List<String> remoteDataDirs = conf.get(ConfigOptions.REMOTE_DATA_DIRS);
+ List<Integer> weights =
conf.get(ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS);
+
+ switch (strategy) {
+ case ROUND_ROBIN:
+ return new RoundRobinRemoteDirSelector(remoteDataDir,
remoteDataDirs);
+ case WEIGHTED_ROUND_ROBIN:
+ return new WeightedRoundRobinRemoteDirSelector(
+ remoteDataDir, remoteDataDirs, weights);
+ default:
+ throw new IllegalArgumentException(
+ "Unsupported remote data directory select strategy: "
+ strategy);
+ }
+ }
+
+ private boolean strategyChanged(Configuration newConfig) {
+ return hasConfigChanged(newConfig,
ConfigOptions.REMOTE_DATA_DIRS_STRATEGY);
+ }
+
+ private boolean remoteDataDirsChanged(Configuration newConfig) {
+ return hasConfigChanged(newConfig, ConfigOptions.REMOTE_DATA_DIRS);
+ }
+
+ private boolean weightsChanged(Configuration newConfig) {
+ return hasConfigChanged(newConfig,
ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS);
+ }
+
+ /**
+ * Checks if a specific configuration option has changed in the new config.
+ *
+ * @param newConfig the new configuration
+ * @param option the configuration option to check
+ * @param <T> the type of the configuration value
+ * @return true if the configuration has changed
+ */
+ private <T> boolean hasConfigChanged(Configuration newConfig,
ConfigOption<T> option) {
+ return newConfig
+ .getOptional(option)
+ .map(newValue -> !Objects.equals(newValue,
currentConfiguration.get(option)))
+ .orElse(false);
+ }
+
+ /**
+ * Merges the current configuration with new configuration values.
+ *
+ * @param current the current configuration
+ * @param updates the configuration updates to apply
+ * @return a new merged configuration
+ */
+ private Configuration mergeConfigurations(Configuration current,
Configuration updates) {
+ Configuration merged = new Configuration(current);
+ updates.toMap().forEach(merged::setString);
+ return merged;
+ }
+
+ @Override
+ public void close() throws Exception {
+ // do nothing
+ }
+}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/RemoteDirSelector.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/RemoteDirSelector.java
new file mode 100644
index 000000000..f98dcaa62
--- /dev/null
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/RemoteDirSelector.java
@@ -0,0 +1,49 @@
+/*
+ * 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.remote;
+
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * Interface for selecting remote data directories from a list of available
directories.
+ *
+ * <p>This interface is used to implement different selection strategies for
choosing remote data
+ * directories when creating tables or partitions. The selection strategy can
be configured via
+ * {@link org.apache.fluss.config.ConfigOptions#REMOTE_DATA_DIRS_STRATEGY}.
+ *
+ * <p>Implementations of this interface should be thread-safe as they may be
accessed concurrently
+ * from multiple threads.
+ *
+ * @see RoundRobinRemoteDirSelector
+ * @see WeightedRoundRobinRemoteDirSelector
+ */
+@ThreadSafe
+public interface RemoteDirSelector {
+
+ /**
+ * Returns the next remote data directory path to use.
+ *
+ * <p>This method should implement the selection strategy (e.g.,
round-robin, weighted
+ * round-robin) to choose from the available remote data directories.
+ *
+ * @return the next remote data directory path to use. If {@link
+ * org.apache.fluss.config.ConfigOptions#REMOTE_DATA_DIRS} is empty,
should always return
+ * {@link org.apache.fluss.config.ConfigOptions#REMOTE_DATA_DIR}.
+ */
+ String nextDataDir();
+}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/RoundRobinRemoteDirSelector.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/RoundRobinRemoteDirSelector.java
new file mode 100644
index 000000000..b785a261a
--- /dev/null
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/RoundRobinRemoteDirSelector.java
@@ -0,0 +1,55 @@
+/*
+ * 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.remote;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Round-robin remote data dir selector.
+ *
+ * <p>This implementation cycles through the available remote data directories
in order, ensuring
+ * each directory is selected once before repeating.
+ *
+ * <p>Example: For directories [A, B, C], the selection sequence would be: A,
B, C, A, B, C, ...
+ */
+public class RoundRobinRemoteDirSelector implements RemoteDirSelector {
+
+ private final String remoteDataDir;
+ private final List<String> remoteDataDirs;
+
+ // Current position in the round-robin cycle.
+ private final AtomicInteger position;
+
+ public RoundRobinRemoteDirSelector(String remoteDataDir, List<String>
remoteDataDirs) {
+ this.remoteDataDir = remoteDataDir;
+ this.remoteDataDirs = Collections.unmodifiableList(remoteDataDirs);
+ this.position = new AtomicInteger(0);
+ }
+
+ @Override
+ public String nextDataDir() {
+ if (remoteDataDirs.isEmpty()) {
+ return remoteDataDir;
+ }
+
+ int index = position.getAndIncrement();
+ return remoteDataDirs.get(index % remoteDataDirs.size());
+ }
+}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/WeightedRoundRobinRemoteDirSelector.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/WeightedRoundRobinRemoteDirSelector.java
new file mode 100644
index 000000000..a255d7840
--- /dev/null
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/remote/WeightedRoundRobinRemoteDirSelector.java
@@ -0,0 +1,108 @@
+/*
+ * 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.remote;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.fluss.utils.Preconditions.checkArgument;
+
+/**
+ * Weighted Round-robin remote data dir selector using Interleaved (Smooth)
Weighted Round-Robin
+ * algorithm.
+ *
+ * <p>This implementation uses the smooth weighted round-robin algorithm (also
known as interleaved
+ * weighted round-robin), which distributes selections more evenly compared to
traditional weighted
+ * round-robin. Instead of selecting the same node consecutively based on its
weight, it interleaves
+ * selections to achieve a smoother distribution.
+ *
+ * <p>Algorithm:
+ *
+ * <ol>
+ * <li>Each node maintains a currentWeight initialized to 0
+ * <li>On each selection: add the node's configured weight to its
currentWeight
+ * <li>Select the node with the highest currentWeight
+ * <li>Subtract the total weight sum from the selected node's currentWeight
+ * </ol>
+ *
+ * <p>Example: For nodes A, B, C with weights 5, 1, 1 (total=7), the selection
sequence would be: A,
+ * A, B, A, C, A, A (instead of A, A, A, A, A, B, C in traditional WRR).
+ */
+public class WeightedRoundRobinRemoteDirSelector implements RemoteDirSelector {
+
+ private final String remoteDataDir;
+ private final List<String> remoteDataDirs;
+ private final int[] weights;
+ private final int totalWeight;
+
+ // Current weights for each node, used in smooth weighted round-robin
+ private final int[] currentWeights;
+
+ // Lock object for thread safety
+ private final Object lock = new Object();
+
+ public WeightedRoundRobinRemoteDirSelector(
+ String remoteDataDir, List<String> remoteDataDirs, List<Integer>
weights) {
+ checkArgument(
+ remoteDataDirs.size() == weights.size(),
+ "remoteDataDirs size (%s) must equal weights size (%s)",
+ remoteDataDirs.size(),
+ weights.size());
+
+ this.remoteDataDir = remoteDataDir;
+ this.remoteDataDirs = Collections.unmodifiableList(remoteDataDirs);
+
+ // Convert weights list to array for better performance
+ this.weights = new int[weights.size()];
+ int sum = 0;
+ for (int i = 0; i < weights.size(); i++) {
+ this.weights[i] = weights.get(i);
+ sum += this.weights[i];
+ }
+ this.totalWeight = sum;
+
+ // Initialize current weights to 0
+ this.currentWeights = new int[remoteDataDirs.size()];
+ }
+
+ @Override
+ public String nextDataDir() {
+ if (remoteDataDirs.isEmpty()) {
+ return remoteDataDir;
+ }
+
+ synchronized (lock) {
+ int selectedIndex = -1;
+ int maxCurrentWeight = Integer.MIN_VALUE;
+
+ // Step 1 & 2: Add weight to currentWeight and find the max
+ for (int i = 0; i < remoteDataDirs.size(); i++) {
+ currentWeights[i] += weights[i];
+ if (currentWeights[i] > maxCurrentWeight) {
+ maxCurrentWeight = currentWeights[i];
+ selectedIndex = i;
+ }
+ }
+
+ // Step 3: Subtract total weight from selected node's current
weight
+ currentWeights[selectedIndex] -= totalWeight;
+
+ return remoteDataDirs.get(selectedIndex);
+ }
+ }
+}
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/DynamicConfigChangeTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/DynamicConfigChangeTest.java
index 39db82daf..c41afa0f6 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/DynamicConfigChangeTest.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/DynamicConfigChangeTest.java
@@ -24,6 +24,9 @@ import org.apache.fluss.config.cluster.AlterConfigOpType;
import org.apache.fluss.config.cluster.ServerReconfigurable;
import org.apache.fluss.exception.ConfigException;
import org.apache.fluss.server.coordinator.LakeCatalogDynamicLoader;
+import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader;
+import org.apache.fluss.server.coordinator.remote.RoundRobinRemoteDirSelector;
+import
org.apache.fluss.server.coordinator.remote.WeightedRoundRobinRemoteDirSelector;
import org.apache.fluss.server.storage.LocalDiskManager;
import org.apache.fluss.server.zk.NOPErrorHandler;
import org.apache.fluss.server.zk.ZooKeeperClient;
@@ -462,6 +465,58 @@ public class DynamicConfigChangeTest {
assertThat(reconfiguredInterval.get()).isEqualTo(Duration.ofMinutes(5));
}
+ @Test
+ void testDynamicReconfigurationOfRemoteDataDirs() throws Exception {
+ Configuration configuration = new Configuration();
+ configuration.set(ConfigOptions.REMOTE_DATA_DIR, "hdfs://default-dir");
+ configuration.set(
+ ConfigOptions.REMOTE_DATA_DIRS, Arrays.asList("hdfs://dir1",
"hdfs://dir2"));
+
+ try (RemoteDirDynamicLoader remoteDirDynamicLoader =
+ new RemoteDirDynamicLoader(configuration)) {
+ DynamicConfigManager dynamicConfigManager =
+ new DynamicConfigManager(zookeeperClient, configuration,
true);
+ dynamicConfigManager.register(remoteDirDynamicLoader);
+ dynamicConfigManager.startup();
+
+ // Verify initial selector is RoundRobin (default strategy)
+ assertThat(remoteDirDynamicLoader.getRemoteDirSelector())
+ .isInstanceOf(RoundRobinRemoteDirSelector.class);
+
+ // Change multiple configs - generic validation applies to all
+ dynamicConfigManager.alterConfigs(
+ Arrays.asList(
+ new AlterConfig(
+ ConfigOptions.REMOTE_DATA_DIRS.key(),
+ "hdfs://dir1,hdfs://dir2,hdfs://dir3",
+ AlterConfigOpType.SET),
+ new AlterConfig(
+
ConfigOptions.REMOTE_DATA_DIRS_STRATEGY.key(),
+
ConfigOptions.RemoteDataDirStrategy.WEIGHTED_ROUND_ROBIN.name(),
+ AlterConfigOpType.SET),
+ new AlterConfig(
+
ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS.key(),
+ "1,2,3",
+ AlterConfigOpType.SET)));
+
+ // Verify both configs were applied
+ Map<String, String> zkConfig = zookeeperClient.fetchEntityConfig();
+ assertThat(zkConfig.get(ConfigOptions.REMOTE_DATA_DIRS.key()))
+ .isEqualTo("hdfs://dir1,hdfs://dir2,hdfs://dir3");
+
assertThat(zkConfig.get(ConfigOptions.REMOTE_DATA_DIRS_STRATEGY.key()))
+
.isEqualTo(ConfigOptions.RemoteDataDirStrategy.WEIGHTED_ROUND_ROBIN.name());
+
assertThat(zkConfig.get(ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS.key()))
+ .isEqualTo("1,2,3");
+
+ // Wait for config change to propagate via ZK watcher
+ retry(
+ Duration.ofMinutes(1),
+ () ->
+
assertThat(remoteDirDynamicLoader.getRemoteDirSelector())
+
.isInstanceOf(WeightedRoundRobinRemoteDirSelector.class));
+ }
+ }
+
@Test
void testPreventInvalidMinInSyncReplicas() throws Exception {
Configuration configuration = new Configuration();
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 5114ee115..9151ae156 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
@@ -25,6 +25,7 @@ import org.apache.fluss.metadata.Schema;
import org.apache.fluss.metadata.TableDescriptor;
import org.apache.fluss.metadata.TableInfo;
import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader;
import org.apache.fluss.server.testutils.TestingServerMetadataCache;
import org.apache.fluss.server.zk.NOPErrorHandler;
import org.apache.fluss.server.zk.ZooKeeperClient;
@@ -50,8 +51,10 @@ import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
+import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -73,6 +76,8 @@ class AutoPartitionManagerTest {
protected static ZooKeeperClient zookeeperClient;
private static MetadataManager metadataManager;
private static String remoteDataDir;
+ private static List<String> remoteDataDirs;
+ private static RemoteDirDynamicLoader remoteDirDynamicLoader;
@BeforeAll
static void beforeAll() {
@@ -85,7 +90,17 @@ class AutoPartitionManagerTest {
zookeeperClient,
new Configuration(),
new LakeCatalogDynamicLoader(new Configuration(),
null, true));
- remoteDataDir = zookeeperClient.getDefaultRemoteDataDir();
+
+ remoteDataDir = "/dir";
+ remoteDataDirs = Arrays.asList("/dir1", "/dir2", "/dir3", "/dir4");
+ Configuration conf = new Configuration();
+ conf.set(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir);
+ conf.set(ConfigOptions.REMOTE_DATA_DIRS, remoteDataDirs);
+ conf.set(ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS, Arrays.asList(1, 1,
1, 1));
+ conf.set(
+ ConfigOptions.REMOTE_DATA_DIRS_STRATEGY,
+ ConfigOptions.RemoteDataDirStrategy.WEIGHTED_ROUND_ROBIN);
+ remoteDirDynamicLoader = new RemoteDirDynamicLoader(conf);
}
@AfterEach
@@ -304,6 +319,7 @@ class AutoPartitionManagerTest {
zookeeperClient,
new Configuration(),
new LakeCatalogDynamicLoader(new
Configuration(), null, true)),
+ remoteDirDynamicLoader,
new Configuration(),
clock,
periodicExecutor);
@@ -320,6 +336,7 @@ class AutoPartitionManagerTest {
zookeeperClient.getPartitionRegistrations(tablePath);
// pre-create 4 partitions including current partition
assertThat(partitions.keySet()).containsExactlyInAnyOrder(params.expectedPartitions);
+ verifyPartitionsRemoteDataDir(tablePath, partitions.keySet());
int replicaFactor = table.getTableConfig().getReplicationFactor();
Map<Integer, BucketAssignment> bucketAssignments =
@@ -341,6 +358,7 @@ class AutoPartitionManagerTest {
metadataManager.createPartition(
tablePath,
tableId,
+ remoteDataDir,
partitionAssignment,
fromPartitionName(table.getPartitionKeys(), partitionName),
false);
@@ -397,6 +415,7 @@ class AutoPartitionManagerTest {
new AutoPartitionManager(
new TestingServerMetadataCache(3),
metadataManager,
+ remoteDirDynamicLoader,
new Configuration(),
clock,
periodicExecutor);
@@ -435,6 +454,7 @@ class AutoPartitionManagerTest {
metadataManager.createPartition(
tablePath,
tableId,
+ remoteDataDir,
partitionAssignment,
fromPartitionName(table.getPartitionKeys(), i + ""),
false);
@@ -474,6 +494,7 @@ class AutoPartitionManagerTest {
new AutoPartitionManager(
new TestingServerMetadataCache(3),
metadataManager,
+ remoteDirDynamicLoader,
new Configuration(),
clock,
periodicExecutor);
@@ -539,6 +560,7 @@ class AutoPartitionManagerTest {
new AutoPartitionManager(
new TestingServerMetadataCache(3),
metadataManager,
+ remoteDirDynamicLoader,
config,
clock,
periodicExecutor);
@@ -580,6 +602,7 @@ class AutoPartitionManagerTest {
new AutoPartitionManager(
new TestingServerMetadataCache(3),
metadataManager,
+ remoteDirDynamicLoader,
new Configuration(),
clock,
periodicExecutor);
@@ -759,7 +782,7 @@ class AutoPartitionManagerTest {
private void verifyPartitionsRemoteDataDir(
TablePath tablePath, Collection<String> partitionNames) throws
Exception {
- Set<String> allRemoteDataDirs = new HashSet<>();
+ Set<String> allRemoteDataDirs = new HashSet<>(remoteDataDirs);
allRemoteDataDirs.add(remoteDataDir);
for (String partitionName : partitionNames) {
Optional<PartitionRegistration> partition =
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 0694e9787..e1226156e 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
@@ -52,6 +52,7 @@ 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.lease.KvSnapshotLeaseManager;
+import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader;
import org.apache.fluss.server.coordinator.statemachine.BucketState;
import org.apache.fluss.server.coordinator.statemachine.ReplicaState;
import org.apache.fluss.server.entity.AdjustIsrResultForBucket;
@@ -94,7 +95,6 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.api.io.TempDir;
-import java.io.IOException;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Arrays;
@@ -196,17 +196,21 @@ class CoordinatorEventProcessorTest {
}
@BeforeEach
- void beforeEach() throws IOException {
+ void beforeEach() {
serverMetadataCache = new CoordinatorMetadataCache();
// set a test channel manager for the context
testCoordinatorChannelManager = new TestCoordinatorChannelManager();
- autoPartitionManager =
- new AutoPartitionManager(serverMetadataCache, metadataManager,
new Configuration());
lakeTableTieringManager =
new
LakeTableTieringManager(TestingMetricGroups.LAKE_TIERING_METRICS);
- Configuration conf = new Configuration();
remoteDataDir = zookeeperClient.getDefaultRemoteDataDir();
+ Configuration conf = new Configuration();
conf.setString(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir);
+ autoPartitionManager =
+ new AutoPartitionManager(
+ serverMetadataCache,
+ metadataManager,
+ new RemoteDirDynamicLoader(conf),
+ new Configuration());
kvSnapshotLeaseManager =
new KvSnapshotLeaseManager(
Duration.ofMinutes(10).toMillis(),
@@ -250,10 +254,14 @@ class CoordinatorEventProcessorTest {
new TabletServerInfo(1, "rack1"),
new TabletServerInfo(2, "rack2")
});
- long t1Id = metadataManager.createTable(t1, tableDescriptor,
tableAssignment, false);
+ long t1Id =
+ metadataManager.createTable(
+ t1, remoteDataDir, tableDescriptor, tableAssignment,
false);
TablePath t2 = TablePath.of(defaultDatabase, "create_drop_t2");
- long t2Id = metadataManager.createTable(t2, tableDescriptor,
tableAssignment, false);
+ long t2Id =
+ metadataManager.createTable(
+ t2, remoteDataDir, tableDescriptor, tableAssignment,
false);
verifyTableCreated(t2Id, tableAssignment, nBuckets, replicationFactor);
@@ -383,13 +391,15 @@ class CoordinatorEventProcessorTest {
TablePath table1Path = TablePath.of(defaultDatabase, "t1");
long table1Id =
- metadataManager.createTable(table1Path, TEST_TABLE,
table1Assignment, false);
+ metadataManager.createTable(
+ table1Path, remoteDataDir, TEST_TABLE,
table1Assignment, false);
TableAssignment table2Assignment =
TableAssignment.builder().add(0,
BucketAssignment.of(3)).build();
TablePath table2Path = TablePath.of(defaultDatabase, "t2");
long table2Id =
- metadataManager.createTable(table2Path, TEST_TABLE,
table2Assignment, false);
+ metadataManager.createTable(
+ table2Path, remoteDataDir, TEST_TABLE,
table2Assignment, false);
// retry until the table2 been created
retryVerifyContext(
@@ -494,7 +504,9 @@ class CoordinatorEventProcessorTest {
.add(1, BucketAssignment.of(1, 2, 0))
.build();
TablePath tablePath = TablePath.of(defaultDatabase, "t_restart");
- long table1Id = metadataManager.createTable(tablePath, TEST_TABLE,
tableAssignment, false);
+ long table1Id =
+ metadataManager.createTable(
+ tablePath, remoteDataDir, TEST_TABLE, tableAssignment,
false);
// let's restart
initCoordinatorChannel();
@@ -618,7 +630,8 @@ class CoordinatorEventProcessorTest {
// create a partitioned table
TableDescriptor tablePartitionTableDescriptor = getPartitionedTable();
long tableId =
- metadataManager.createTable(tablePath,
tablePartitionTableDescriptor, null, false);
+ metadataManager.createTable(
+ tablePath, remoteDataDir,
tablePartitionTableDescriptor, null, false);
int nBuckets = 3;
int replicationFactor = 3;
@@ -690,7 +703,8 @@ class CoordinatorEventProcessorTest {
// create a partitioned table
TableDescriptor tablePartitionTableDescriptor = getPartitionedTable();
long tableId =
- metadataManager.createTable(tablePath,
tablePartitionTableDescriptor, null, false);
+ metadataManager.createTable(
+ tablePath, remoteDataDir,
tablePartitionTableDescriptor, null, false);
int nBuckets = 3;
int replicationFactor = 3;
@@ -837,7 +851,8 @@ class CoordinatorEventProcessorTest {
new TabletServerInfo(1, "rack1"),
new TabletServerInfo(2, "rack2")
});
- long t1Id = metadataManager.createTable(t1, TEST_TABLE,
tableAssignment, false);
+ long t1Id =
+ metadataManager.createTable(t1, remoteDataDir, TEST_TABLE,
tableAssignment, false);
verifyTableCreated(t1Id, tableAssignment, nBuckets, replicationFactor);
// get the origin bucket leaderAndIsr
@@ -891,7 +906,7 @@ class CoordinatorEventProcessorTest {
});
// create table
List<Integer> replicas =
tableAssignment.getBucketAssignment(0).getReplicas();
- metadataManager.createTable(t1, TEST_TABLE, tableAssignment, false);
+ metadataManager.createTable(t1, remoteDataDir, TEST_TABLE,
tableAssignment, false);
TableInfo tableInfo = metadataManager.getTable(t1);
retry(
@@ -950,7 +965,7 @@ class CoordinatorEventProcessorTest {
});
// create table
List<Integer> replicas =
tableAssignment.getBucketAssignment(0).getReplicas();
- metadataManager.createTable(t1, TEST_TABLE, tableAssignment, false);
+ metadataManager.createTable(t1, remoteDataDir, TEST_TABLE,
tableAssignment, false);
TableInfo tableInfo = metadataManager.getTable(t1);
retry(
@@ -1012,7 +1027,11 @@ class CoordinatorEventProcessorTest {
TableAssignment tableAssignment = new
TableAssignment(bucketAssignments);
long t1Id =
metadataManager.createTable(
- t1, CoordinatorEventProcessorTest.TEST_TABLE,
tableAssignment, false);
+ t1,
+ remoteDataDir,
+ CoordinatorEventProcessorTest.TEST_TABLE,
+ tableAssignment,
+ false);
TableBucket tb0 = new TableBucket(t1Id, 0);
verifyIsr(tb0, 0, Arrays.asList(0, 1, 3));
@@ -1073,7 +1092,8 @@ class CoordinatorEventProcessorTest {
bucketAssignments.put(1, BucketAssignment.of(0, 1, 2));
bucketAssignments.put(2, BucketAssignment.of(0, 1, 2));
TableAssignment tableAssignment = new
TableAssignment(bucketAssignments);
- long t1Id = metadataManager.createTable(t1, TEST_TABLE,
tableAssignment, false);
+ long t1Id =
+ metadataManager.createTable(t1, remoteDataDir, TEST_TABLE,
tableAssignment, false);
TableBucket tb0 = new TableBucket(t1Id, 0);
TableBucket tb1 = new TableBucket(t1Id, 1);
@@ -1172,6 +1192,8 @@ class CoordinatorEventProcessorTest {
}
private CoordinatorEventProcessor buildCoordinatorEventProcessor() {
+ Configuration conf = new Configuration();
+ conf.set(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir);
return new CoordinatorEventProcessor(
zookeeperClient,
serverMetadataCache,
@@ -1180,7 +1202,7 @@ class CoordinatorEventProcessorTest {
autoPartitionManager,
lakeTableTieringManager,
TestingMetricGroups.COORDINATOR_METRICS,
- new Configuration(),
+ conf,
Executors.newFixedThreadPool(1, new
ExecutorThreadFactory("test-coordinator-io")),
metadataManager,
kvSnapshotLeaseManager);
@@ -1508,7 +1530,11 @@ class CoordinatorEventProcessorTest {
TableAssignment tableAssignment =
generateAssignment(N_BUCKETS, REPLICATION_FACTOR, servers);
return metadataManager.createTable(
- tablePath, CoordinatorEventProcessorTest.TEST_TABLE,
tableAssignment, false);
+ tablePath,
+ remoteDataDir,
+ CoordinatorEventProcessorTest.TEST_TABLE,
+ tableAssignment,
+ false);
}
private void alterTable(TablePath tablePath, List<TableChange>
schemaChanges) {
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 72697b7a9..9baa3ec49 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
@@ -136,7 +136,8 @@ class TableChangeWatcherTest {
new TabletServerInfo(2, "rack2")
});
long tableId =
- metadataManager.createTable(tablePath, TEST_TABLE,
tableAssignment, false);
+ metadataManager.createTable(
+ tablePath, remoteDataDir, TEST_TABLE,
tableAssignment, false);
SchemaInfo schemaInfo = metadataManager.getLatestSchema(tablePath);
long currentMillis = System.currentTimeMillis();
expectedEvents.add(
@@ -197,7 +198,9 @@ class TableChangeWatcherTest {
.property(ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT.key(), "DAY")
.build()
.withReplicationFactor(3);
- long tableId = metadataManager.createTable(tablePath,
partitionedTable, null, false);
+ long tableId =
+ metadataManager.createTable(
+ tablePath, remoteDataDir, partitionedTable, null,
false);
List<CoordinatorEvent> expectedEvents = new ArrayList<>();
SchemaInfo schemaInfo = metadataManager.getLatestSchema(tablePath);
// create table event
@@ -277,7 +280,8 @@ class TableChangeWatcherTest {
new TabletServerInfo(2, "rack2")
});
long tableId =
- metadataManager.createTable(tablePath, TEST_TABLE,
tableAssignment, false);
+ metadataManager.createTable(
+ tablePath, remoteDataDir, TEST_TABLE,
tableAssignment, false);
SchemaInfo schemaInfo = metadataManager.getLatestSchema(tablePath);
long currentMillis = System.currentTimeMillis();
expectedEvents.add(
@@ -350,7 +354,9 @@ class TableChangeWatcherTest {
new TabletServerInfo(1, "rack1"),
new TabletServerInfo(2, "rack2")
});
- long tableId = metadataManager.createTable(tablePath, TEST_TABLE,
tableAssignment, false);
+ long tableId =
+ metadataManager.createTable(
+ tablePath, remoteDataDir, TEST_TABLE, tableAssignment,
false);
SchemaInfo schemaInfo = metadataManager.getLatestSchema(tablePath);
long currentMillis = System.currentTimeMillis();
@@ -430,7 +436,8 @@ class TableChangeWatcherTest {
new TabletServerInfo(2, "rack2")
});
long tableId =
- metadataManager.createTable(tablePath, TEST_TABLE,
tableAssignment, false);
+ metadataManager.createTable(
+ tablePath, remoteDataDir, TEST_TABLE,
tableAssignment, false);
SchemaInfo schemaInfo = metadataManager.getLatestSchema(tablePath);
long currentMillis = System.currentTimeMillis();
expectedEvents.add(
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 6ae36b8de..725430ede 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
@@ -18,6 +18,7 @@
package org.apache.fluss.server.coordinator.rebalance;
import org.apache.fluss.cluster.rebalance.RebalanceStatus;
+import org.apache.fluss.config.ConfigOptions;
import org.apache.fluss.config.Configuration;
import org.apache.fluss.server.coordinator.AutoPartitionManager;
import org.apache.fluss.server.coordinator.CoordinatorContext;
@@ -27,6 +28,7 @@ import
org.apache.fluss.server.coordinator.LakeTableTieringManager;
import org.apache.fluss.server.coordinator.MetadataManager;
import org.apache.fluss.server.coordinator.TestCoordinatorChannelManager;
import org.apache.fluss.server.coordinator.lease.KvSnapshotLeaseManager;
+import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader;
import org.apache.fluss.server.metadata.CoordinatorMetadataCache;
import org.apache.fluss.server.metrics.group.TestingMetricGroups;
import org.apache.fluss.server.zk.NOPErrorHandler;
@@ -83,8 +85,10 @@ public class RebalanceManagerTest {
void beforeEach() {
serverMetadataCache = new CoordinatorMetadataCache();
testCoordinatorChannelManager = new TestCoordinatorChannelManager();
-
String remoteDataDir = "/tmp/fluss/remote-data";
+ Configuration conf = new Configuration();
+ conf.set(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir);
+
kvSnapshotLeaseManager =
new KvSnapshotLeaseManager(
Duration.ofMinutes(10).toMillis(),
@@ -95,10 +99,14 @@ public class RebalanceManagerTest {
kvSnapshotLeaseManager.start();
autoPartitionManager =
- new AutoPartitionManager(serverMetadataCache, metadataManager,
new Configuration());
+ new AutoPartitionManager(
+ serverMetadataCache,
+ metadataManager,
+ new RemoteDirDynamicLoader(conf),
+ conf);
lakeTableTieringManager =
new
LakeTableTieringManager(TestingMetricGroups.LAKE_TIERING_METRICS);
- CoordinatorEventProcessor eventProcessor =
buildCoordinatorEventProcessor();
+ CoordinatorEventProcessor eventProcessor =
buildCoordinatorEventProcessor(conf);
rebalanceManager = new RebalanceManager(eventProcessor,
zookeeperClient);
rebalanceManager.startup();
}
@@ -134,7 +142,7 @@ public class RebalanceManagerTest {
.hasValue(new RebalanceTask(rebalanceId, COMPLETED, new
HashMap<>()));
}
- private CoordinatorEventProcessor buildCoordinatorEventProcessor() {
+ private CoordinatorEventProcessor
buildCoordinatorEventProcessor(Configuration conf) {
return new CoordinatorEventProcessor(
zookeeperClient,
serverMetadataCache,
@@ -143,7 +151,7 @@ public class RebalanceManagerTest {
autoPartitionManager,
lakeTableTieringManager,
TestingMetricGroups.COORDINATOR_METRICS,
- new Configuration(),
+ conf,
Executors.newFixedThreadPool(1, new
ExecutorThreadFactory("test-coordinator-io")),
metadataManager,
kvSnapshotLeaseManager);
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/RemoteDirDynamicLoaderTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/RemoteDirDynamicLoaderTest.java
new file mode 100644
index 000000000..c8596f9a1
--- /dev/null
+++
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/RemoteDirDynamicLoaderTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.remote;
+
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.exception.ConfigException;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link RemoteDirDynamicLoader}. */
+class RemoteDirDynamicLoaderTest {
+
+ @Test
+ void testReconfigureWithStrategyChange() throws Exception {
+ Configuration conf = new Configuration();
+ conf.set(ConfigOptions.REMOTE_DATA_DIRS, Arrays.asList("hdfs://dir1",
"hdfs://dir2"));
+ try (RemoteDirDynamicLoader loader = new RemoteDirDynamicLoader(conf))
{
+ RemoteDirSelector selector = loader.getRemoteDirSelector();
+
assertThat(selector).isInstanceOf(RoundRobinRemoteDirSelector.class);
+
+ // 1. Reconfigure with WEIGHTED_ROUND_ROBIN strategy
+ Configuration newConfig = new Configuration();
+ newConfig.set(
+ ConfigOptions.REMOTE_DATA_DIRS_STRATEGY,
+ ConfigOptions.RemoteDataDirStrategy.WEIGHTED_ROUND_ROBIN);
+ newConfig.set(ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS,
Arrays.asList(1, 2));
+ loader.reconfigure(newConfig);
+
+ // Selector should be replaced
+ assertThat(loader.getRemoteDirSelector()).isNotSameAs(selector);
+ assertThat(loader.getRemoteDirSelector())
+ .isInstanceOf(WeightedRoundRobinRemoteDirSelector.class);
+
+ selector = loader.getRemoteDirSelector();
+
+ // 2. Reconfigure back to ROUND_ROBIN strategy
+ newConfig.set(
+ ConfigOptions.REMOTE_DATA_DIRS_STRATEGY,
+ ConfigOptions.RemoteDataDirStrategy.ROUND_ROBIN);
+ loader.reconfigure(newConfig);
+
+ // Selector should be replaced
+ assertThat(loader.getRemoteDirSelector()).isNotSameAs(selector);
+ assertThat(loader.getRemoteDirSelector())
+ .isInstanceOf(RoundRobinRemoteDirSelector.class);
+ }
+ }
+
+ @Test
+ void testReconfigureWithWeightsChange() throws Exception {
+ Configuration conf = new Configuration();
+ conf.set(
+ ConfigOptions.REMOTE_DATA_DIRS_STRATEGY,
+ ConfigOptions.RemoteDataDirStrategy.WEIGHTED_ROUND_ROBIN);
+ conf.set(ConfigOptions.REMOTE_DATA_DIRS, Arrays.asList("hdfs://dir1",
"hdfs://dir2"));
+ conf.set(ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS, Arrays.asList(1, 2));
+
+ try (RemoteDirDynamicLoader loader = new RemoteDirDynamicLoader(conf))
{
+ RemoteDirSelector originalSelector = loader.getRemoteDirSelector();
+
assertThat(originalSelector).isInstanceOf(WeightedRoundRobinRemoteDirSelector.class);
+
+ // Reconfigure with weights change
+ Configuration newConfig = new Configuration();
+ newConfig.set(ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS,
Arrays.asList(3, 4));
+ loader.reconfigure(newConfig);
+
+ // Container should be replaced
+
assertThat(loader.getRemoteDirSelector()).isNotSameAs(originalSelector);
+ assertThat(loader.getRemoteDirSelector())
+ .isInstanceOf(WeightedRoundRobinRemoteDirSelector.class);
+ }
+ }
+
+ @Test
+ void testValidateRemoteDataDirsChange() throws Exception {
+ // 1. New dirs must contain all old remote.data.dirs
+ Configuration conf1 = new Configuration();
+ conf1.set(ConfigOptions.REMOTE_DATA_DIRS, Arrays.asList("hdfs://dir1",
"hdfs://dir2"));
+ try (RemoteDirDynamicLoader loader = new
RemoteDirDynamicLoader(conf1)) {
+ Configuration newConfig = new Configuration();
+ newConfig.set(
+ ConfigOptions.REMOTE_DATA_DIRS,
Arrays.asList("hdfs://dir2", "hdfs://dir3"));
+
+ assertThatThrownBy(() -> loader.validate(newConfig))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining("must contain all existing remote
data directories");
+ }
+
+ // 2. New dirs must also contain old remote.data.dir (singular)
+ Configuration conf2 = new Configuration();
+ conf2.setString(ConfigOptions.REMOTE_DATA_DIR, "hdfs://original-dir");
+ try (RemoteDirDynamicLoader loader = new
RemoteDirDynamicLoader(conf2)) {
+ Configuration newConfig = new Configuration();
+ newConfig.set(
+ ConfigOptions.REMOTE_DATA_DIRS,
Arrays.asList("hdfs://dir1", "hdfs://dir2"));
+
+ assertThatThrownBy(() -> loader.validate(newConfig))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining("must contain all existing remote
data directories")
+ .hasMessageContaining("hdfs://original-dir");
+
+ // 3. Including old remote.data.dir should pass
+ Configuration validConfig = new Configuration();
+ validConfig.set(
+ ConfigOptions.REMOTE_DATA_DIRS,
+ Arrays.asList("hdfs://original-dir", "hdfs://dir2"));
+ loader.validate(validConfig);
+ }
+ }
+}
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/RemoteDirsITCase.java
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/RemoteDirsITCase.java
new file mode 100644
index 000000000..808c10b19
--- /dev/null
+++
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/RemoteDirsITCase.java
@@ -0,0 +1,279 @@
+/*
+ * 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.remote;
+
+import org.apache.fluss.config.AutoPartitionTimeUnit;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.metadata.PartitionSpec;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.server.testutils.FlussClusterExtension;
+import org.apache.fluss.server.testutils.RpcMessageTestUtils;
+import org.apache.fluss.server.zk.ZooKeeperClient;
+import org.apache.fluss.server.zk.data.PartitionRegistration;
+import org.apache.fluss.server.zk.data.TableRegistration;
+import org.apache.fluss.types.DataTypes;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import static
org.apache.fluss.record.TestData.DATA1_PARTITIONED_TABLE_DESCRIPTOR;
+import static org.apache.fluss.record.TestData.DATA1_TABLE_DESCRIPTOR;
+import static org.apache.fluss.record.TestData.DATA1_TABLE_DESCRIPTOR_PK;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** ITCase for multi remote data directories functionality. */
+class RemoteDirsITCase {
+
+ private static final TableDescriptor DATA1_PARTITIONED_TABLE_DESCRIPTOR_PK
=
+ TableDescriptor.builder()
+ .schema(
+ Schema.newBuilder()
+ .column("a", DataTypes.INT())
+ .withComment("a is first column")
+ .column("b", DataTypes.STRING())
+ .withComment("b is second column")
+ .primaryKey("a", "b")
+ .build())
+ .distributedBy(3)
+ .partitionedBy("b")
+ .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, true)
+ .property(
+ ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT,
+ AutoPartitionTimeUnit.YEAR)
+ .build();
+
+ private static final List<String> REMOTE_DIR_NAMES = Arrays.asList("dir1",
"dir2", "dir3");
+
+ @RegisterExtension
+ public static final FlussClusterExtension FLUSS_CLUSTER_EXTENSION =
+ FlussClusterExtension.builder()
+ .setNumOfTabletServers(3)
+ .setClusterConf(initConfig())
+ .setRemoteDirNames(REMOTE_DIR_NAMES)
+ .build();
+
+ private ZooKeeperClient zkClient;
+
+ private static Configuration initConfig() {
+ Configuration conf = new Configuration();
+ conf.setInt(ConfigOptions.DEFAULT_REPLICATION_FACTOR, 3);
+ conf.set(ConfigOptions.AUTO_PARTITION_CHECK_INTERVAL,
Duration.ofSeconds(1));
+
+ return conf;
+ }
+
+ @BeforeEach
+ void setup() {
+ zkClient = FLUSS_CLUSTER_EXTENSION.getZooKeeperClient();
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testCreateMultipleTablesWithRoundRobin(boolean isPrimaryKeyTable)
throws Exception {
+ // Create multiple tables and verify they use different remote dirs
via round-robin
+ TableDescriptor tableDescriptor =
+ isPrimaryKeyTable ? DATA1_TABLE_DESCRIPTOR_PK :
DATA1_TABLE_DESCRIPTOR;
+ String tablePrefix = isPrimaryKeyTable ? "pk_table_" : "non_pk_table_";
+
+ List<String> remoteDirsUsed = new ArrayList<>();
+ int tableCount = 6; // Create more tables than dirs to see round-robin
in action
+
+ for (int i = 0; i < tableCount; i++) {
+ TablePath tablePath = TablePath.of("test_db", tablePrefix + i);
+ RpcMessageTestUtils.createTable(FLUSS_CLUSTER_EXTENSION,
tablePath, tableDescriptor);
+
+ // Get the table registration to check remoteDataDir
+ Optional<TableRegistration> tableOpt =
zkClient.getTable(tablePath);
+ assertThat(tableOpt).isPresent();
+ TableRegistration table = tableOpt.get();
+
+ assertThat(table.remoteDataDir).isNotNull();
+ remoteDirsUsed.add(table.remoteDataDir);
+ }
+
+ // Verify round-robin distribution: each dir should be used at least
once
+ Map<String, Integer> dirUsageCount = new HashMap<>();
+ for (String dir : remoteDirsUsed) {
+ dirUsageCount.merge(dir, 1, Integer::sum);
+ }
+
+ // With round-robin, all configured dirs should be used exactly twice
(6 / 3)
+ assertThat(dirUsageCount.keySet()).hasSize(REMOTE_DIR_NAMES.size());
+ assertThat(dirUsageCount.values()).allMatch(count -> count == 2);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testCreatePartitionsWithRoundRobin(boolean isPrimaryKeyTable) throws
Exception {
+ // Create a partitioned table and add multiple partitions
+ // Each partition should get a different remoteDataDir via round-robin
+ String tablePrefix = isPrimaryKeyTable ? "pk_partitioned_" :
"partitioned_";
+ TablePath tablePath = TablePath.of("test_db", tablePrefix + "table_2");
+
+ TableDescriptor tableDescriptor =
+ isPrimaryKeyTable
+ ? DATA1_PARTITIONED_TABLE_DESCRIPTOR_PK
+ : DATA1_PARTITIONED_TABLE_DESCRIPTOR;
+ RpcMessageTestUtils.createTable(FLUSS_CLUSTER_EXTENSION, tablePath,
tableDescriptor);
+
+ Optional<TableRegistration> tableOpt = zkClient.getTable(tablePath);
+ assertThat(tableOpt).isPresent();
+ TableRegistration table = tableOpt.get();
+ // Partitioned table should have remoteDataDir set at table level
+ assertThat(table.remoteDataDir).isNotNull();
+
+ // Create multiple partitions using partition column "b"
+ int partitionCount = 6;
+ List<String> partitionNames = new ArrayList<>();
+ for (int i = 0; i < partitionCount; i++) {
+ String partitionName = "202" + i;
+ partitionNames.add(partitionName);
+ PartitionSpec partitionSpec =
+ new PartitionSpec(Collections.singletonMap("b",
partitionName));
+ RpcMessageTestUtils.createPartition(
+ FLUSS_CLUSTER_EXTENSION, tablePath, partitionSpec, false);
+ }
+
+ // Verify each partition has remoteDataDir set and round-robin is
applied
+ Set<String> usedRemoteDirs = new HashSet<>();
+ for (String partitionName : partitionNames) {
+ Optional<PartitionRegistration> partitionOpt =
+ zkClient.getPartition(tablePath, partitionName);
+ assertThat(partitionOpt).isPresent();
+ PartitionRegistration partition = partitionOpt.get();
+
+ assertThat(partition.getRemoteDataDir()).isNotNull();
+ usedRemoteDirs.add(partition.getRemoteDataDir());
+ }
+
+ // All configured dirs should be used
+ assertThat(usedRemoteDirs).hasSize(REMOTE_DIR_NAMES.size());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testAutoPartitionWithMultipleRemoteDirs(boolean isPrimaryKeyTable)
throws Exception {
+ // Create an auto-partitioned table and verify partitions use
different remote dirs
+ String tablePrefix = isPrimaryKeyTable ? "auto_pk_partitioned_" :
"auto_partitioned_";
+ TablePath tablePath = TablePath.of("test_db", tablePrefix + "table");
+ TableDescriptor tableDescriptor =
+ isPrimaryKeyTable
+ ? DATA1_PARTITIONED_TABLE_DESCRIPTOR_PK
+ : DATA1_PARTITIONED_TABLE_DESCRIPTOR;
+
+ RpcMessageTestUtils.createTable(FLUSS_CLUSTER_EXTENSION, tablePath,
tableDescriptor);
+
+ // Wait for auto partitions to be created
+ Map<String, Long> partitions =
+ FLUSS_CLUSTER_EXTENSION.waitUntilPartitionAllReady(tablePath);
+ assertThat(partitions).isNotEmpty();
+
+ // Verify partitions use remote dirs
+ Set<String> usedRemoteDirs = new HashSet<>();
+ for (String partitionName : partitions.keySet()) {
+ Optional<PartitionRegistration> partitionOpt =
+ zkClient.getPartition(tablePath, partitionName);
+ assertThat(partitionOpt).isPresent();
+ PartitionRegistration partition = partitionOpt.get();
+
+ assertThat(partition.getRemoteDataDir()).isNotNull();
+ usedRemoteDirs.add(partition.getRemoteDataDir());
+ }
+
+ // At least one remote dir should be used
+ assertThat(usedRemoteDirs).isNotEmpty();
+ }
+
+ @Test
+ void testMixedTableAndPartitionCreation() throws Exception {
+ // Create a mix of non-partitioned tables and partitioned table
partitions
+ // to verify round-robin works correctly across both types
+
+ // Create 2 non-partitioned tables
+ for (int i = 0; i < 2; i++) {
+ TablePath tablePath = TablePath.of("test_db", "mixed_non_pk_" + i);
+ RpcMessageTestUtils.createTable(
+ FLUSS_CLUSTER_EXTENSION, tablePath,
DATA1_TABLE_DESCRIPTOR_PK);
+ }
+
+ // Create a partitioned table
+ TablePath partitionedTablePath = TablePath.of("test_db",
"mixed_partitioned");
+ RpcMessageTestUtils.createTable(
+ FLUSS_CLUSTER_EXTENSION, partitionedTablePath,
DATA1_PARTITIONED_TABLE_DESCRIPTOR);
+
+ // Create partitions using partition column "b"
+ RpcMessageTestUtils.createPartition(
+ FLUSS_CLUSTER_EXTENSION,
+ partitionedTablePath,
+ new PartitionSpec(Collections.singletonMap("b", "2024")),
+ false);
+ RpcMessageTestUtils.createPartition(
+ FLUSS_CLUSTER_EXTENSION,
+ partitionedTablePath,
+ new PartitionSpec(Collections.singletonMap("b", "2025")),
+ false);
+
+ // Create 2 more non-partitioned tables
+ for (int i = 2; i < 4; i++) {
+ TablePath tablePath = TablePath.of("test_db", "mixed_non_pk_" + i);
+ RpcMessageTestUtils.createTable(
+ FLUSS_CLUSTER_EXTENSION, tablePath,
DATA1_TABLE_DESCRIPTOR_PK);
+ }
+
+ // Collect all remote dirs used
+ Set<String> allUsedDirs = new HashSet<>();
+
+ // Check non-partitioned tables
+ for (int i = 0; i < 4; i++) {
+ TablePath tablePath = TablePath.of("test_db", "mixed_non_pk_" + i);
+ Optional<TableRegistration> tableOpt =
zkClient.getTable(tablePath);
+ assertThat(tableOpt).isPresent();
+ assertThat(tableOpt.get().remoteDataDir).isNotNull();
+ allUsedDirs.add(tableOpt.get().remoteDataDir);
+ }
+
+ // Check partitions
+ for (String p : Arrays.asList("2024", "2025")) {
+ Optional<PartitionRegistration> partitionOpt =
+ zkClient.getPartition(partitionedTablePath, p);
+ assertThat(partitionOpt).isPresent();
+ assertThat(partitionOpt.get().getRemoteDataDir()).isNotNull();
+ allUsedDirs.add(partitionOpt.get().getRemoteDataDir());
+ }
+
+ // All remote dirs should have been used (6 items, 3 dirs, round-robin)
+ assertThat(allUsedDirs).hasSize(REMOTE_DIR_NAMES.size());
+ }
+}
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/RoundRobinRemoteDirSelectorTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/RoundRobinRemoteDirSelectorTest.java
new file mode 100644
index 000000000..b94d3831a
--- /dev/null
+++
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/RoundRobinRemoteDirSelectorTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.remote;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link RoundRobinRemoteDirSelector}. */
+class RoundRobinRemoteDirSelectorTest {
+
+ private static final String DEFAULT_DIR = "hdfs://default/data";
+
+ @Test
+ void testEmptyRemoteDirsShouldReturnDefault() {
+ RoundRobinRemoteDirSelector selector =
+ new RoundRobinRemoteDirSelector(DEFAULT_DIR,
Collections.emptyList());
+
+ // Should always return default when remoteDataDirs is empty
+ for (int i = 0; i < 10; i++) {
+ assertThat(selector.nextDataDir()).isEqualTo(DEFAULT_DIR);
+ }
+ }
+
+ @Test
+ void testSingleDirShouldAlwaysReturnSame() {
+ String dir = "hdfs://cluster/data1";
+ RoundRobinRemoteDirSelector selector =
+ new RoundRobinRemoteDirSelector(DEFAULT_DIR,
Collections.singletonList(dir));
+
+ // Should always return the single directory
+ for (int i = 0; i < 10; i++) {
+ assertThat(selector.nextDataDir()).isEqualTo(dir);
+ }
+ }
+
+ @Test
+ void testRoundRobinOrder() {
+ List<String> dirs =
+ Arrays.asList(
+ "hdfs://cluster/data1", "hdfs://cluster/data2",
"hdfs://cluster/data3");
+
+ RoundRobinRemoteDirSelector selector = new
RoundRobinRemoteDirSelector(DEFAULT_DIR, dirs);
+
+ // Collect selections for multiple cycles
+ List<String> selections = new ArrayList<>();
+ for (int i = 0; i < 9; i++) {
+ selections.add(selector.nextDataDir());
+ }
+
+ // Verify round-robin pattern: each cycle should contain all dirs in
order
+ // First cycle
+ assertThat(selections.subList(0, 3)).containsExactlyElementsOf(dirs);
+ // Second cycle
+ assertThat(selections.subList(3, 6)).containsExactlyElementsOf(dirs);
+ // Third cycle
+ assertThat(selections.subList(6, 9)).containsExactlyElementsOf(dirs);
+ }
+
+ @Test
+ void testEvenDistribution() {
+ List<String> dirs =
+ Arrays.asList(
+ "hdfs://cluster/data1", "hdfs://cluster/data2",
"hdfs://cluster/data3");
+
+ RoundRobinRemoteDirSelector selector = new
RoundRobinRemoteDirSelector(DEFAULT_DIR, dirs);
+
+ Map<String, Integer> counts = new HashMap<>();
+ int totalCalls = 30;
+
+ for (int i = 0; i < totalCalls; i++) {
+ String selected = selector.nextDataDir();
+ counts.merge(selected, 1, Integer::sum);
+ }
+
+ // Each directory should be selected equally
+ assertThat(counts.get(dirs.get(0))).isEqualTo(10);
+ assertThat(counts.get(dirs.get(1))).isEqualTo(10);
+ assertThat(counts.get(dirs.get(2))).isEqualTo(10);
+ }
+
+ @Test
+ void testTwoDirs() {
+ List<String> dirs = Arrays.asList("hdfs://cluster/data1",
"hdfs://cluster/data2");
+
+ RoundRobinRemoteDirSelector selector = new
RoundRobinRemoteDirSelector(DEFAULT_DIR, dirs);
+
+ // Verify alternating pattern
+ assertThat(selector.nextDataDir()).isEqualTo(dirs.get(0));
+ assertThat(selector.nextDataDir()).isEqualTo(dirs.get(1));
+ assertThat(selector.nextDataDir()).isEqualTo(dirs.get(0));
+ assertThat(selector.nextDataDir()).isEqualTo(dirs.get(1));
+ }
+
+ @Test
+ void testCycleWrapsCorrectly() {
+ List<String> dirs =
+ Arrays.asList(
+ "hdfs://cluster/data1", "hdfs://cluster/data2",
"hdfs://cluster/data3");
+
+ RoundRobinRemoteDirSelector selector = new
RoundRobinRemoteDirSelector(DEFAULT_DIR, dirs);
+
+ // Collect first cycle
+ List<String> firstCycle = new ArrayList<>();
+ for (int i = 0; i < 3; i++) {
+ firstCycle.add(selector.nextDataDir());
+ }
+
+ // Collect second cycle
+ List<String> secondCycle = new ArrayList<>();
+ for (int i = 0; i < 3; i++) {
+ secondCycle.add(selector.nextDataDir());
+ }
+
+ // Both cycles should have same sequence
+ assertThat(secondCycle).isEqualTo(firstCycle);
+ }
+
+ @Test
+ void testAllDirsSelectedInOneCycle() {
+ List<String> dirs =
+ Arrays.asList(
+ "hdfs://cluster/data1",
+ "hdfs://cluster/data2",
+ "hdfs://cluster/data3",
+ "hdfs://cluster/data4",
+ "hdfs://cluster/data5");
+
+ RoundRobinRemoteDirSelector selector = new
RoundRobinRemoteDirSelector(DEFAULT_DIR, dirs);
+
+ Set<String> selectedInCycle = new HashSet<>();
+ for (int i = 0; i < dirs.size(); i++) {
+ selectedInCycle.add(selector.nextDataDir());
+ }
+
+ // All directories should be selected exactly once in one cycle
+ assertThat(selectedInCycle).containsExactlyInAnyOrderElementsOf(dirs);
+ }
+}
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/WeightedRoundRobinRemoteDirSelectorTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/WeightedRoundRobinRemoteDirSelectorTest.java
new file mode 100644
index 000000000..44fe690a7
--- /dev/null
+++
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/remote/WeightedRoundRobinRemoteDirSelectorTest.java
@@ -0,0 +1,246 @@
+/*
+ * 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.remote;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link WeightedRoundRobinRemoteDirSelector}. */
+class WeightedRoundRobinRemoteDirSelectorTest {
+
+ private static final String DEFAULT_DIR = "hdfs://default/data";
+
+ @Test
+ void testEmptyRemoteDirsShouldReturnDefault() {
+ WeightedRoundRobinRemoteDirSelector selector =
+ new WeightedRoundRobinRemoteDirSelector(
+ DEFAULT_DIR, Collections.emptyList(),
Collections.emptyList());
+
+ // Should always return default when remoteDataDirs is empty
+ for (int i = 0; i < 10; i++) {
+ assertThat(selector.nextDataDir()).isEqualTo(DEFAULT_DIR);
+ }
+ }
+
+ @Test
+ void testSingleDirShouldAlwaysReturnSame() {
+ String dir = "hdfs://cluster/data1";
+ WeightedRoundRobinRemoteDirSelector selector =
+ new WeightedRoundRobinRemoteDirSelector(
+ DEFAULT_DIR, Collections.singletonList(dir),
Collections.singletonList(5));
+
+ // Should always return the single directory
+ for (int i = 0; i < 10; i++) {
+ assertThat(selector.nextDataDir()).isEqualTo(dir);
+ }
+ }
+
+ @Test
+ void testEqualWeightsShouldDistributeEvenly() {
+ List<String> dirs =
+ Arrays.asList(
+ "hdfs://cluster/data1", "hdfs://cluster/data2",
"hdfs://cluster/data3");
+ List<Integer> weights = Arrays.asList(1, 1, 1);
+
+ WeightedRoundRobinRemoteDirSelector selector =
+ new WeightedRoundRobinRemoteDirSelector(DEFAULT_DIR, dirs,
weights);
+
+ Map<String, Integer> counts = new HashMap<>();
+ int totalCalls = 30;
+
+ for (int i = 0; i < totalCalls; i++) {
+ String selected = selector.nextDataDir();
+ counts.merge(selected, 1, Integer::sum);
+ }
+
+ // Each directory should be selected equally
+ assertThat(counts.get(dirs.get(0))).isEqualTo(10);
+ assertThat(counts.get(dirs.get(1))).isEqualTo(10);
+ assertThat(counts.get(dirs.get(2))).isEqualTo(10);
+ }
+
+ @Test
+ void testWeightedDistribution() {
+ List<String> dirs =
+ Arrays.asList(
+ "hdfs://cluster/data1", "hdfs://cluster/data2",
"hdfs://cluster/data3");
+ // weights: 5, 1, 1 -> total = 7
+ List<Integer> weights = Arrays.asList(5, 1, 1);
+
+ WeightedRoundRobinRemoteDirSelector selector =
+ new WeightedRoundRobinRemoteDirSelector(DEFAULT_DIR, dirs,
weights);
+
+ Map<String, Integer> counts = new HashMap<>();
+ int totalCalls = 70; // 10 complete cycles
+
+ for (int i = 0; i < totalCalls; i++) {
+ String selected = selector.nextDataDir();
+ counts.merge(selected, 1, Integer::sum);
+ }
+
+ // Distribution should match weights ratio: 5:1:1
+ assertThat(counts.get(dirs.get(0))).isEqualTo(50); // 5/7 * 70 = 50
+ assertThat(counts.get(dirs.get(1))).isEqualTo(10); // 1/7 * 70 = 10
+ assertThat(counts.get(dirs.get(2))).isEqualTo(10); // 1/7 * 70 = 10
+ }
+
+ @Test
+ void testInterleavedDistribution() {
+ // Verify that selections are interleaved, not consecutive
+ List<String> dirs =
+ Arrays.asList("hdfs://cluster/A", "hdfs://cluster/B",
"hdfs://cluster/C");
+ // weights: 5, 1, 1 -> total = 7
+ List<Integer> weights = Arrays.asList(5, 1, 1);
+
+ WeightedRoundRobinRemoteDirSelector selector =
+ new WeightedRoundRobinRemoteDirSelector(DEFAULT_DIR, dirs,
weights);
+
+ List<String> sequence = new ArrayList<>();
+ for (int i = 0; i < 7; i++) {
+ sequence.add(selector.nextDataDir());
+ }
+
+ // Expected interleaved sequence for weights 5,1,1:
+ // The smooth WRR should produce: A, A, B, A, C, A, A (or similar
interleaved pattern)
+ // Instead of traditional WRR: A, A, A, A, A, B, C
+
+ // Count consecutive same selections - should be less than weight
+ int maxConsecutive = 0;
+ int currentConsecutive = 1;
+ for (int i = 1; i < sequence.size(); i++) {
+ if (sequence.get(i).equals(sequence.get(i - 1))) {
+ currentConsecutive++;
+ } else {
+ maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
+ currentConsecutive = 1;
+ }
+ }
+ maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
+
+ // With smooth WRR, max consecutive selections should be <= 2 for this
weight distribution
+ // (In traditional WRR, A would be selected 5 times consecutively)
+ assertThat(maxConsecutive).isLessThanOrEqualTo(2);
+
+ // Verify all directories are selected at least once within one cycle
+ assertThat(sequence).contains(dirs.get(0), dirs.get(1), dirs.get(2));
+ }
+
+ @Test
+ void testTwoDirsWithDifferentWeights() {
+ List<String> dirs = Arrays.asList("hdfs://cluster/data1",
"hdfs://cluster/data2");
+ // weights: 3, 1 -> total = 4
+ List<Integer> weights = Arrays.asList(3, 1);
+
+ WeightedRoundRobinRemoteDirSelector selector =
+ new WeightedRoundRobinRemoteDirSelector(DEFAULT_DIR, dirs,
weights);
+
+ List<String> sequence = new ArrayList<>();
+ for (int i = 0; i < 8; i++) {
+ sequence.add(selector.nextDataDir());
+ }
+
+ // Count selections
+ long dir1Count = sequence.stream().filter(d ->
d.equals(dirs.get(0))).count();
+ long dir2Count = sequence.stream().filter(d ->
d.equals(dirs.get(1))).count();
+
+ // Should follow 3:1 ratio
+ assertThat(dir1Count).isEqualTo(6); // 3/4 * 8 = 6
+ assertThat(dir2Count).isEqualTo(2); // 1/4 * 8 = 2
+ }
+
+ @Test
+ void testCycleRepeatsCorrectly() {
+ List<String> dirs = Arrays.asList("hdfs://cluster/data1",
"hdfs://cluster/data2");
+ List<Integer> weights = Arrays.asList(2, 1);
+
+ WeightedRoundRobinRemoteDirSelector selector =
+ new WeightedRoundRobinRemoteDirSelector(DEFAULT_DIR, dirs,
weights);
+
+ // Collect first cycle (3 selections)
+ List<String> firstCycle = new ArrayList<>();
+ for (int i = 0; i < 3; i++) {
+ firstCycle.add(selector.nextDataDir());
+ }
+
+ // Collect second cycle (3 selections)
+ List<String> secondCycle = new ArrayList<>();
+ for (int i = 0; i < 3; i++) {
+ secondCycle.add(selector.nextDataDir());
+ }
+
+ // Both cycles should have same sequence
+ assertThat(secondCycle).isEqualTo(firstCycle);
+ }
+
+ @Test
+ void testLargeWeights() {
+ List<String> dirs = Arrays.asList("hdfs://cluster/data1",
"hdfs://cluster/data2");
+ List<Integer> weights = Arrays.asList(100, 1);
+
+ WeightedRoundRobinRemoteDirSelector selector =
+ new WeightedRoundRobinRemoteDirSelector(DEFAULT_DIR, dirs,
weights);
+
+ Map<String, Integer> counts = new HashMap<>();
+ int totalCalls = 101; // One complete cycle
+
+ for (int i = 0; i < totalCalls; i++) {
+ String selected = selector.nextDataDir();
+ counts.merge(selected, 1, Integer::sum);
+ }
+
+ // Should follow 100:1 ratio
+ assertThat(counts.get(dirs.get(0))).isEqualTo(100);
+ assertThat(counts.get(dirs.get(1))).isEqualTo(1);
+ }
+
+ @Test
+ void testZeroWeights() {
+ // Test case 1: Some directories have zero weight - they should never
be selected
+ List<String> dirs =
+ Arrays.asList(
+ "hdfs://cluster/data1", "hdfs://cluster/data2",
"hdfs://cluster/data3");
+ // weight 0 for data2 means it should never be selected
+ List<Integer> weights = Arrays.asList(2, 0, 1);
+
+ WeightedRoundRobinRemoteDirSelector selector =
+ new WeightedRoundRobinRemoteDirSelector(DEFAULT_DIR, dirs,
weights);
+
+ Map<String, Integer> counts = new HashMap<>();
+ int totalCalls = 30; // 10 complete cycles (total weight = 3)
+
+ for (int i = 0; i < totalCalls; i++) {
+ String selected = selector.nextDataDir();
+ counts.merge(selected, 1, Integer::sum);
+ }
+
+ // data1 should be selected 20 times (2/3 * 30)
+ assertThat(counts.get(dirs.get(0))).isEqualTo(20);
+ // data2 should never be selected (weight = 0)
+ assertThat(counts.get(dirs.get(1))).isNull();
+ // data3 should be selected 10 times (1/3 * 30)
+ assertThat(counts.get(dirs.get(2))).isEqualTo(10);
+ }
+}
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 7d80fe41c..4dca20f56 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
@@ -36,6 +36,7 @@ import org.apache.fluss.server.coordinator.MetadataManager;
import org.apache.fluss.server.coordinator.TestCoordinatorChannelManager;
import org.apache.fluss.server.coordinator.event.CoordinatorEventManager;
import org.apache.fluss.server.coordinator.lease.KvSnapshotLeaseManager;
+import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader;
import
org.apache.fluss.server.coordinator.statemachine.ReplicaLeaderElection.ControlledShutdownLeaderElection;
import
org.apache.fluss.server.coordinator.statemachine.TableBucketStateMachine.ElectionResult;
import org.apache.fluss.server.metadata.CoordinatorMetadataCache;
@@ -126,6 +127,7 @@ class TableBucketStateMachineTest {
zookeeperClient,
new Configuration(),
new LakeCatalogDynamicLoader(new
Configuration(), null, true)),
+ new RemoteDirDynamicLoader(conf),
new Configuration());
lakeTableTieringManager =
new
LakeTableTieringManager(TestingMetricGroups.LAKE_TIERING_METRICS);
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/metadata/ZkBasedMetadataProviderTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/metadata/ZkBasedMetadataProviderTest.java
index 6ef53b62a..a2f4cbedb 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/metadata/ZkBasedMetadataProviderTest.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/metadata/ZkBasedMetadataProviderTest.java
@@ -102,7 +102,9 @@ class ZkBasedMetadataProviderTest {
.add(1, BucketAssignment.of(2, 3, 4))
.build();
metadataManager.createDatabase("test_db", DatabaseDescriptor.EMPTY,
true);
- long tableId = metadataManager.createTable(tablePath, desc,
tableAssignment, false);
+ long tableId =
+ metadataManager.createTable(
+ tablePath, DEFAULT_REMOTE_DATA_DIR, desc,
tableAssignment, false);
// Create leader and isr for buckets
TableBucket tableBucket0 = new TableBucket(tableId, 0);
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 00a2344a6..48777a6f2 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
@@ -138,6 +138,7 @@ public final class FlussClusterExtension
private final Configuration clusterConf;
private final Clock clock;
private final String[] racks;
+ private final List<String> remoteDirNames;
/** Creates a new {@link Builder} for {@link FlussClusterExtension}. */
public static Builder builder() {
@@ -150,7 +151,8 @@ public final class FlussClusterExtension
String tabletServerListeners,
Configuration clusterConf,
Clock clock,
- String[] racks) {
+ String[] racks,
+ List<String> remoteDirNames) {
this.initialNumOfTabletServers = numOfTabletServers;
this.tabletServers = new HashMap<>(numOfTabletServers);
this.coordinatorServerListeners = coordinatorServerListeners;
@@ -162,6 +164,7 @@ public final class FlussClusterExtension
racks != null && racks.length == numOfTabletServers,
"racks must be not null and have the same length as
numOfTabletServers");
this.racks = racks;
+ this.remoteDirNames = remoteDirNames;
}
@Override
@@ -206,6 +209,7 @@ public final class FlussClusterExtension
tempDir = Files.createTempDirectory("fluss-testing-cluster").toFile();
Configuration conf = new Configuration();
setRemoteDataDir(conf);
+ setRemoteDataDirs(conf);
zooKeeperServer =
ZooKeeperTestUtils.createAndStartZookeeperTestingServer();
zooKeeperClient =
createZooKeeperClient(
@@ -215,6 +219,7 @@ public final class FlussClusterExtension
zooKeeperClient,
clusterConf,
new LakeCatalogDynamicLoader(clusterConf, null, true));
+
rpcClient =
RpcClient.create(
conf,
@@ -263,6 +268,7 @@ public final class FlussClusterExtension
conf.setString(ConfigOptions.ZOOKEEPER_ADDRESS,
zooKeeperServer.getConnectString());
conf.setString(ConfigOptions.BIND_LISTENERS,
coordinatorServerListeners);
setRemoteDataDir(conf);
+ setRemoteDataDirs(conf);
coordinatorServer = new CoordinatorServer(conf, clock);
coordinatorServer.start();
waitUntilCoordinatorServerElected();
@@ -375,12 +381,26 @@ public final class FlussClusterExtension
conf.set(ConfigOptions.REMOTE_DATA_DIR, getRemoteDataDir());
}
+ private void setRemoteDataDirs(Configuration conf) {
+ if (!remoteDirNames.isEmpty()) {
+ List<String> remoteDataDirs =
+ remoteDirNames.stream()
+ .map(this::getRemoteDataDir)
+ .collect(Collectors.toList());
+ conf.set(ConfigOptions.REMOTE_DATA_DIRS, remoteDataDirs);
+ }
+ }
+
public String getRemoteDataDir() {
+ return getRemoteDataDir("remote-data-dir");
+ }
+
+ public String getRemoteDataDir(String dirName) {
return LocalFileSystem.getLocalFsURI().getScheme()
+ "://"
+ tempDir.getAbsolutePath()
+ File.separator
- + "remote-data-dir";
+ + dirName;
}
/** Stop a tablet server. */
@@ -780,7 +800,7 @@ public final class FlussClusterExtension
}
}
- private CompletedSnapshot waitUntilSnapshotFinished(TableBucket
tableBucket, long snapshotId) {
+ public CompletedSnapshot waitUntilSnapshotFinished(TableBucket
tableBucket, long snapshotId) {
ZooKeeperClient zkClient = getZooKeeperClient();
return waitValue(
() -> {
@@ -969,6 +989,7 @@ public final class FlussClusterExtension
private String coordinatorServerListeners = DEFAULT_LISTENERS;
private Clock clock = SystemClock.getInstance();
private String[] racks = new String[] {"rack-0"};
+ private List<String> remoteDirNames = Collections.emptyList();
private final Configuration clusterConf = new Configuration();
@@ -1014,6 +1035,11 @@ public final class FlussClusterExtension
return this;
}
+ public Builder setRemoteDirNames(List<String> remoteDirNames) {
+ this.remoteDirNames = remoteDirNames;
+ return this;
+ }
+
public FlussClusterExtension build() {
if (numOfTabletServers > 1 && racks.length == 1) {
String[] racks = new String[numOfTabletServers];
@@ -1029,7 +1055,8 @@ public final class FlussClusterExtension
tabletServerListeners,
clusterConf,
clock,
- racks);
+ racks,
+ remoteDirNames);
}
}
}