This is an automated email from the ASF dual-hosted git repository.
ashishkumar50 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git
The following commit(s) were added to refs/heads/master by this push:
new 571a67a023d HDDS-16179. Estimate subcommand for container balancer
with profiles (#11255)
571a67a023d is described below
commit 571a67a023de5305a8ebcd71ad4eb8c61daf882c
Author: sreejasahithi <[email protected]>
AuthorDate: Fri Sep 25 16:57:52 2026 +0530
HDDS-16179. Estimate subcommand for container balancer with profiles
(#11255)
---
.../balancer/ContainerBalancerAdvisor.java | 438 +++++++++++++++++++++
.../balancer/ContainerBalancerConfiguration.java | 16 +-
.../balancer/ContainerBalancerEstimation.java | 205 ++++++++++
.../balancer/ContainerBalancerProfile.java | 53 +++
.../balancer/TestContainerBalancerAdvisor.java | 296 ++++++++++++++
.../hdds/scm/cli/ContainerBalancerCommands.java | 32 +-
...nd.java => ContainerBalancerConfigOptions.java} | 149 ++++---
.../cli/ContainerBalancerEstimateSubcommand.java | 175 ++++++++
.../scm/cli/ContainerBalancerStartSubcommand.java | 92 +----
.../datanode/TestContainerBalancerSubCommand.java | 260 +++++++++++-
10 files changed, 1575 insertions(+), 141 deletions(-)
diff --git
a/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerAdvisor.java
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerAdvisor.java
new file mode 100644
index 00000000000..b7ba0b025cf
--- /dev/null
+++
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerAdvisor.java
@@ -0,0 +1,438 @@
+/*
+ * 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.hadoop.hdds.scm.container.balancer;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.conf.StorageUnit;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos.DatanodeUsageInfoProto;
+import org.apache.hadoop.hdds.scm.ScmConfigKeys;
+
+/**
+ * Orchestrates cluster analysis, estimation and recommendation for container
balancer.
+ */
+public final class ContainerBalancerAdvisor {
+
+ private static final long MIN_DELETE_PHASE_MILLIS =
Duration.ofMinutes(9).toMillis();
+ private static final String DATANODE_OFFSET_KEY =
+ "hdds.scm.replication.event.timeout.datanode.offset";
+
+ private ContainerBalancerAdvisor() {
+ }
+
+ /**
+ * Estimates per-iteration size, iterations and duration for one or more
balancer profiles.
+ *
+ * If {@link AdvisorRequest#allProfiles} is true, returns SLOW, MEDIUM, and
FAST.
+ * If profile is set, returns a result for that profile only.
+ * Otherwise returns MEDIUM only.
+ * Per-profile validation failures are returned with {@link
ContainerBalancerEstimation#succeeded()} false
+ * instead of aborting other profiles.
+ */
+ public static List<ContainerBalancerEstimation> estimate(OzoneConfiguration
conf, AdvisorRequest request) {
+ Objects.requireNonNull(conf, "conf");
+ Objects.requireNonNull(request, "request");
+ List<DatanodeUsageInfoProto> nodes = Objects.requireNonNull(request.nodes,
"nodes");
+
+ ContainerBalancerConfiguration balancerConfig =
conf.getObject(ContainerBalancerConfiguration.class);
+
+ double thresholdPercent = request.thresholdPercent != null
+ ? request.thresholdPercent
+ : balancerConfig.getThreshold();
+ validateThresholdPercent(thresholdPercent);
+ double thresholdRatio = thresholdPercent / 100.0;
+ Set<String> includeNodes = request.includeNodes != null
+ ? request.includeNodes
+ : balancerConfig.getIncludeNodes();
+ Set<String> excludeNodes = request.excludeNodes != null
+ ? request.excludeNodes
+ : balancerConfig.getExcludeNodes();
+
+ ContainerBalancerClusterSnapshot snapshot =
ContainerBalancerClusterAnalyzer.analyze(nodes, thresholdRatio,
+ includeNodes, excludeNodes);
+ validateSnapshotForEstimation(snapshot);
+
+ List<ContainerBalancerProfile> profiles = selectProfiles(request);
+ List<ContainerBalancerEstimation> estimations = new
ArrayList<>(profiles.size());
+ for (ContainerBalancerProfile profile : profiles) {
+ estimations.add(estimateForProfile(conf, request, profile, snapshot,
balancerConfig, thresholdPercent));
+ }
+ return Collections.unmodifiableList(estimations);
+ }
+
+ private static ContainerBalancerEstimation
estimateForProfile(OzoneConfiguration conf, AdvisorRequest request,
+ ContainerBalancerProfile profile, ContainerBalancerClusterSnapshot
snapshot,
+ ContainerBalancerConfiguration balancerConfig, double thresholdPercent) {
+
+ boolean userProvidedMaxDatanodesPercentage =
+ request.maxDatanodesPercentageToInvolvePerIteration != null;
+ int maxDatanodesPercentage = userProvidedMaxDatanodesPercentage
+ ? request.maxDatanodesPercentageToInvolvePerIteration
+ : profile.getDatanodesMaxPercentage();
+ long maxSizeEnteringTarget = request.maxSizeEnteringTarget != null
+ ? request.maxSizeEnteringTarget
+ : profile.getMaxSizeEnteringTarget();
+ long maxSizeLeavingSource = request.maxSizeLeavingSource != null
+ ? request.maxSizeLeavingSource
+ : profile.getMaxSizeLeavingSource();
+ long maxSizeToMovePerIteration = request.maxSizeToMovePerIteration != null
+ ? request.maxSizeToMovePerIteration
+ : balancerConfig.getMaxSizeToMovePerIteration();
+ long moveTimeoutMillis = request.moveTimeoutMillis != null
+ ? request.moveTimeoutMillis
+ : balancerConfig.getMoveTimeout().toMillis();
+ long moveReplicationTimeoutMillis = request.moveReplicationTimeoutMillis
!= null
+ ? request.moveReplicationTimeoutMillis
+ : balancerConfig.getMoveReplicationTimeout().toMillis();
+ long balancingIntervalMillis = request.balancingIntervalMillis != null
+ ? request.balancingIntervalMillis
+ : balancerConfig.getBalancingInterval().toMillis();
+
+ ContainerBalancerEstimation.Builder builder =
ContainerBalancerEstimation.newBuilder()
+ .setProfile(profile)
+ .setThresholdPercent(thresholdPercent)
+ .setMaxSizeEnteringTarget(maxSizeEnteringTarget)
+ .setMaxSizeLeavingSource(maxSizeLeavingSource)
+ .setMaxSizeToMovePerIteration(maxSizeToMovePerIteration)
+ .setMoveTimeoutMillis(moveTimeoutMillis)
+ .setBalancingIntervalMillis(balancingIntervalMillis);
+
+ try {
+
validateMaxDatanodesPercentageToInvolvePerIteration(maxDatanodesPercentage);
+ validateMoveTimeouts(conf, moveReplicationTimeoutMillis,
moveTimeoutMillis);
+ validateBalancingIntervalMillis(balancingIntervalMillis);
+ validateResolvedMoveLimits(conf, maxSizeEnteringTarget,
maxSizeLeavingSource, maxSizeToMovePerIteration);
+
+ int eligibleDatanodeCount = snapshot.getTotalEligibleDatanodes();
+ int maxInvolved =
ContainerBalancerConfiguration.computeMaxDatanodesToInvolvePerIteration(
+ maxDatanodesPercentage / 100d, eligibleDatanodeCount);
+
+ if (!userProvidedMaxDatanodesPercentage && maxInvolved < 2) {
+ int maxProfileDatanodesPercentage =
ContainerBalancerProfile.FAST.getDatanodesMaxPercentage();
+ maxDatanodesPercentage = minimumPercentForAtLeastTwoNodes(
+ eligibleDatanodeCount, maxDatanodesPercentage,
maxProfileDatanodesPercentage);
+ maxInvolved =
ContainerBalancerConfiguration.computeMaxDatanodesToInvolvePerIteration(
+ maxDatanodesPercentage / 100d, eligibleDatanodeCount);
+ }
+
+ if (maxInvolved < 2) {
+ throw new IllegalArgumentException(String.format(
+ "max-datanodes-percentage-to-involve-per-iteration=%d allows at
most %d datanode(s) "
+ + "per iteration with %d eligible datanode(s), but at least 2
are required for a "
+ + "source and target datanode pair.",
+ maxDatanodesPercentage, maxInvolved, eligibleDatanodeCount));
+ }
+
+ int[] involved =
computeInvolvedDatanodeCounts(snapshot.getSourceCount(),
snapshot.getTargetCount(), maxInvolved);
+
+ long bytesToMove = snapshot.getBytesToMove();
+ long perIterationBytes = computePerIterationBytes(
+ bytesToMove,
+ maxSizeToMovePerIteration,
+ maxSizeLeavingSource,
+ maxSizeEnteringTarget,
+ involved);
+ long estimatedIterations = computeEstimatedIterations(perIterationBytes,
bytesToMove);
+ long cycleTimeMillis = computeCycleTimeMillis(moveTimeoutMillis,
balancingIntervalMillis);
+ long estimatedDurationMillis = estimatedIterations * cycleTimeMillis;
+
+ return builder
+ .setMaxDatanodesPercentage(maxDatanodesPercentage)
+ .setBytesToMove(bytesToMove)
+ .setPerIterationBytes(perIterationBytes)
+ .setEstimatedIterations(estimatedIterations)
+ .setEstimatedDurationMillis(estimatedDurationMillis)
+ .build();
+ } catch (IllegalArgumentException e) {
+ return builder
+ .setMaxDatanodesPercentage(maxDatanodesPercentage)
+ .setFailureMessage(e.getMessage())
+ .build();
+ }
+ }
+
+ /** Raises datanode involvement percent until at least two datanodes can be
involved. */
+ static int minimumPercentForAtLeastTwoNodes(
+ int eligibleDatanodeCount, int startPercent, int maxPercent) {
+ for (int percent = startPercent; percent <= maxPercent; percent++) {
+ if
(ContainerBalancerConfiguration.computeMaxDatanodesToInvolvePerIteration(
+ percent / 100d, eligibleDatanodeCount) >= 2) {
+ return percent;
+ }
+ }
+ return maxPercent;
+ }
+
+ /**
+ * Estimated bytes moved in one iteration: minimum of global cap, source cap,
+ * target cap, and total bytes to move.
+ */
+ static long computePerIterationBytes(
+ long bytesToMove,
+ long maxSizeToMovePerIteration,
+ long maxSizeLeavingSource,
+ long maxSizeEnteringTarget,
+ int[] involved) {
+
+ long fromLeaving = involved[0] * maxSizeLeavingSource;
+ long fromEntering = involved[1] * maxSizeEnteringTarget;
+
+ return minPositive(
+ maxSizeToMovePerIteration,
+ fromLeaving,
+ fromEntering,
+ bytesToMove);
+ }
+
+ static long computeCycleTimeMillis(long moveTimeoutMillis, long
balancingIntervalMillis) {
+ return moveTimeoutMillis + balancingIntervalMillis;
+ }
+
+ static long computeEstimatedIterations(long perIterationBytes, long
bytesToMove) {
+ if (perIterationBytes <= 0) {
+ throw new IllegalArgumentException("Per-iteration move size must be
positive.");
+ }
+ return (long) Math.ceil(bytesToMove / (double) perIterationBytes);
+ }
+
+ /**
+ * Estimated source and target datanode counts for one iteration (50/50
split heuristic).
+ *
+ * @return {@code [involvedSources, involvedTargets]}
+ */
+ static int[] computeInvolvedDatanodeCounts(
+ int sourceCount,
+ int targetCount,
+ int maxInvolved) {
+ if (sourceCount <= 0 || targetCount <= 0 || maxInvolved < 2) {
+ return new int[] {0, 0};
+ }
+
+ int sEff = Math.min(sourceCount, (maxInvolved + 1) / 2);
+ int tEff = Math.min(targetCount, maxInvolved / 2);
+ return new int[] {sEff, tEff};
+ }
+
+ private static long minPositive(long... values) {
+ long result = Long.MAX_VALUE;
+ for (long value : values) {
+ if (value > 0 && value < result) {
+ result = value;
+ }
+ }
+ return result;
+ }
+
+ private static void
validateSnapshotForEstimation(ContainerBalancerClusterSnapshot snapshot) {
+ if (snapshot.getSourceCount() < 1) {
+ throw new IllegalArgumentException("No over-utilized datanodes (sources)
found.");
+ }
+ if (snapshot.getTargetCount() < 1) {
+ throw new IllegalArgumentException("No under-utilized datanodes
(targets) found.");
+ }
+ if (snapshot.getBytesToMove() <= 0) {
+ throw new IllegalArgumentException("No bytes to move.");
+ }
+ if (snapshot.getTotalEligibleDatanodes() < 2) {
+ throw new IllegalArgumentException(String.format(
+ "Container Balancer found %d eligible datanode(s) but requires at
least 2.",
+ snapshot.getTotalEligibleDatanodes()));
+ }
+ }
+
+ private static void validateResolvedMoveLimits(OzoneConfiguration conf, long
maxSizeEnteringTarget,
+ long maxSizeLeavingSource, long maxSizeToMovePerIteration) {
+ long containerSizeBytes = (long) conf.getStorageSize(
+ ScmConfigKeys.OZONE_SCM_CONTAINER_SIZE,
+ ScmConfigKeys.OZONE_SCM_CONTAINER_SIZE_DEFAULT,
+ StorageUnit.BYTES);
+
+ if (maxSizeEnteringTarget <= 0 || maxSizeEnteringTarget <=
containerSizeBytes) {
+ throw new IllegalArgumentException(
+ "max-size-entering-target must be greater than
ozone.scm.container.size ("
+ + containerSizeBytes + " bytes).");
+ }
+ if (maxSizeLeavingSource <= 0 || maxSizeLeavingSource <=
containerSizeBytes) {
+ throw new IllegalArgumentException(
+ "max-size-leaving-source must be greater than
ozone.scm.container.size ("
+ + containerSizeBytes + " bytes).");
+ }
+ if (maxSizeToMovePerIteration <= 0) {
+ throw new IllegalArgumentException("Max Size To Move Per Iteration In GB
must be positive.");
+ }
+ if (maxSizeEnteringTarget > maxSizeToMovePerIteration) {
+ throw new IllegalArgumentException(
+ "max-size-entering-target must be less than or equal to "
+ + "max-size-to-move-per-iteration.");
+ }
+ if (maxSizeLeavingSource > maxSizeToMovePerIteration) {
+ throw new IllegalArgumentException(
+ "max-size-leaving-source must be less than or equal to "
+ + "max-size-to-move-per-iteration.");
+ }
+ }
+
+ private static void validateMoveTimeouts(OzoneConfiguration conf, long
moveReplicationTimeoutMillis,
+ long moveTimeoutMillis) {
+ if (moveTimeoutMillis <= 0 || moveReplicationTimeoutMillis <= 0) {
+ throw new IllegalArgumentException(
+ "Move timeout and move replication timeout must each be greater than
zero.");
+ }
+ if (moveReplicationTimeoutMillis >= moveTimeoutMillis) {
+ throw new
IllegalArgumentException("hdds.container.balancer.move.replication.timeout
should " +
+ "be less than hdds.container.balancer.move.timeout.");
+ }
+ long datanodeOffsetMillis = conf.getTimeDuration(
+ DATANODE_OFFSET_KEY, Duration.ofMinutes(6).toMillis(),
TimeUnit.MILLISECONDS);
+ if ((moveTimeoutMillis - moveReplicationTimeoutMillis -
datanodeOffsetMillis)
+ < MIN_DELETE_PHASE_MILLIS) {
+ String msg = String.format("(hdds.container.balancer.move.timeout (%sms)
- " +
+ "hdds.container.balancer.move.replication.timeout (%sms) - " +
+ "hdds.scm.replication.event.timeout.datanode.offset (%sms)) " +
+ "should be greater than or equal to 540000ms or 9 minutes.",
+ moveTimeoutMillis,
+ moveReplicationTimeoutMillis,
+ datanodeOffsetMillis);
+ throw new IllegalArgumentException(msg);
+ }
+ }
+
+ private static void validateThresholdPercent(double thresholdPercent) {
+ if (thresholdPercent < 0d || thresholdPercent >= 100d) {
+ throw new IllegalArgumentException("Threshold should be specified in the
range [0.0, 100.0).");
+ }
+ }
+
+ private static void validateMaxDatanodesPercentageToInvolvePerIteration(int
percentage) {
+ if (percentage <= 0 || percentage > 100) {
+ throw new IllegalArgumentException("Max Datanodes Percentage To Involve
Per Iteration "
+ + "should be specified in the range (0, 100]");
+ }
+ }
+
+ private static void validateBalancingIntervalMillis(long
balancingIntervalMillis) {
+ if (balancingIntervalMillis <= 0) {
+ throw new IllegalArgumentException("Balancing Interval must be greater
than zero.");
+ }
+ }
+
+ private static List<ContainerBalancerProfile> selectProfiles(AdvisorRequest
request) {
+ if (request.allProfiles) {
+ return Arrays.asList(
+ ContainerBalancerProfile.SLOW,
+ ContainerBalancerProfile.MEDIUM,
+ ContainerBalancerProfile.FAST);
+ }
+ if (request.profile != null) {
+ return Collections.singletonList(request.profile);
+ }
+ return Collections.singletonList(ContainerBalancerProfile.MEDIUM);
+ }
+
+ /**
+ * Input for {@link ContainerBalancerAdvisor}: cluster usage data and
optional overrides.
+ * Unset fields fall back to {@link ContainerBalancerConfiguration} or
profile presets.
+ */
+ public static final class AdvisorRequest {
+ private List<DatanodeUsageInfoProto> nodes;
+ private Set<String> includeNodes;
+ private Set<String> excludeNodes;
+ private Double thresholdPercent;
+ private ContainerBalancerProfile profile;
+ private boolean allProfiles;
+ private Integer maxDatanodesPercentageToInvolvePerIteration;
+ private Long maxSizeToMovePerIteration;
+ private Long maxSizeEnteringTarget;
+ private Long maxSizeLeavingSource;
+ private Long moveTimeoutMillis;
+ private Long moveReplicationTimeoutMillis;
+ private Long balancingIntervalMillis;
+
+ public AdvisorRequest setNodes(List<DatanodeUsageInfoProto> nodesList) {
+ this.nodes = nodesList;
+ return this;
+ }
+
+ public AdvisorRequest setIncludeNodes(Set<String> includeNodesSet) {
+ this.includeNodes = includeNodesSet;
+ return this;
+ }
+
+ public AdvisorRequest setExcludeNodes(Set<String> excludeNodesSet) {
+ this.excludeNodes = excludeNodesSet;
+ return this;
+ }
+
+ public AdvisorRequest setThresholdPercent(Double threshold) {
+ this.thresholdPercent = threshold;
+ return this;
+ }
+
+ public AdvisorRequest setProfile(ContainerBalancerProfile profileValue) {
+ this.profile = profileValue;
+ return this;
+ }
+
+ public AdvisorRequest setAllProfiles(boolean allProfilesValue) {
+ this.allProfiles = allProfilesValue;
+ return this;
+ }
+
+ public AdvisorRequest
setMaxDatanodesPercentageToInvolvePerIteration(Integer percentage) {
+ this.maxDatanodesPercentageToInvolvePerIteration = percentage;
+ return this;
+ }
+
+ public AdvisorRequest setMaxSizeToMovePerIteration(Long maxSize) {
+ this.maxSizeToMovePerIteration = maxSize;
+ return this;
+ }
+
+ public AdvisorRequest setMaxSizeEnteringTarget(Long maxSize) {
+ this.maxSizeEnteringTarget = maxSize;
+ return this;
+ }
+
+ public AdvisorRequest setMaxSizeLeavingSource(Long maxSize) {
+ this.maxSizeLeavingSource = maxSize;
+ return this;
+ }
+
+ public AdvisorRequest setMoveTimeoutMillis(Long timeoutMillis) {
+ this.moveTimeoutMillis = timeoutMillis;
+ return this;
+ }
+
+ public AdvisorRequest setMoveReplicationTimeoutMillis(Long timeoutMillis) {
+ this.moveReplicationTimeoutMillis = timeoutMillis;
+ return this;
+ }
+
+ public AdvisorRequest setBalancingIntervalMillis(Long intervalMillis) {
+ this.balancingIntervalMillis = intervalMillis;
+ return this;
+ }
+ }
+}
diff --git
a/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerConfiguration.java
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerConfiguration.java
index 3e73eb5bccd..af4a2304a36 100644
---
a/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerConfiguration.java
+++
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerConfiguration.java
@@ -261,7 +261,21 @@ public double getMaxDatanodesRatioToInvolvePerIteration() {
* @return maximum datanodes that may be involved in one iteration
*/
public int computeMaxDatanodesToInvolvePerIteration(int
eligibleDatanodeCount) {
- return (int) (getMaxDatanodesRatioToInvolvePerIteration() *
eligibleDatanodeCount);
+ return computeMaxDatanodesToInvolvePerIteration(
+ getMaxDatanodesRatioToInvolvePerIteration(), eligibleDatanodeCount);
+ }
+
+ /**
+ * Computes the maximum number of datanodes that may be involved in an
+ * iteration for the given percentage and eligible datanode count.
+ *
+ * @param maxDatanodesPercentage percentage of eligible datanodes to involve
+ * @param eligibleDatanodeCount number of healthy, in-service datanodes
+ * @return maximum datanodes that may be involved in one iteration
+ */
+ public static int computeMaxDatanodesToInvolvePerIteration(
+ double maxDatanodesPercentage, int eligibleDatanodeCount) {
+ return (int) (maxDatanodesPercentage * eligibleDatanodeCount);
}
/**
diff --git
a/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerEstimation.java
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerEstimation.java
new file mode 100644
index 00000000000..ccae7d3486c
--- /dev/null
+++
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerEstimation.java
@@ -0,0 +1,205 @@
+/*
+ * 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.hadoop.hdds.scm.container.balancer;
+
+import java.util.Objects;
+
+/**
+ * Estimate for a single container balancer profile.
+ */
+public final class ContainerBalancerEstimation {
+
+ private final ContainerBalancerProfile profile;
+ private final String failureMessage;
+ private final long bytesToMove;
+ private final long perIterationBytes;
+ private final long estimatedIterations;
+ private final long estimatedDurationMillis;
+ private final double thresholdPercent;
+ private final int maxDatanodesPercentage;
+ private final long maxSizeEnteringTarget;
+ private final long maxSizeLeavingSource;
+ private final long maxSizeToMovePerIteration;
+ private final long moveTimeoutMillis;
+ private final long balancingIntervalMillis;
+
+ private ContainerBalancerEstimation(Builder b) {
+ this.profile = Objects.requireNonNull(b.profile, "profile == null");
+ this.failureMessage = b.failureMessage;
+ this.bytesToMove = b.bytesToMove;
+ this.perIterationBytes = b.perIterationBytes;
+ this.estimatedIterations = b.estimatedIterations;
+ this.estimatedDurationMillis = b.estimatedDurationMillis;
+ this.thresholdPercent = b.thresholdPercent;
+ this.maxDatanodesPercentage = b.maxDatanodesPercentage;
+ this.maxSizeEnteringTarget = b.maxSizeEnteringTarget;
+ this.maxSizeLeavingSource = b.maxSizeLeavingSource;
+ this.maxSizeToMovePerIteration = b.maxSizeToMovePerIteration;
+ this.moveTimeoutMillis = b.moveTimeoutMillis;
+ this.balancingIntervalMillis = b.balancingIntervalMillis;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public ContainerBalancerProfile getProfile() {
+ return profile;
+ }
+
+ public boolean succeeded() {
+ return failureMessage == null;
+ }
+
+ public String getFailureMessage() {
+ return failureMessage;
+ }
+
+ public long getBytesToMove() {
+ return bytesToMove;
+ }
+
+ public long getPerIterationBytes() {
+ return perIterationBytes;
+ }
+
+ public long getEstimatedIterations() {
+ return estimatedIterations;
+ }
+
+ public long getEstimatedDurationMillis() {
+ return estimatedDurationMillis;
+ }
+
+ public double getThresholdPercent() {
+ return thresholdPercent;
+ }
+
+ public int getMaxDatanodesPercentage() {
+ return maxDatanodesPercentage;
+ }
+
+ public long getMaxSizeEnteringTarget() {
+ return maxSizeEnteringTarget;
+ }
+
+ public long getMaxSizeLeavingSource() {
+ return maxSizeLeavingSource;
+ }
+
+ public long getMaxSizeToMovePerIteration() {
+ return maxSizeToMovePerIteration;
+ }
+
+ public long getMoveTimeoutMillis() {
+ return moveTimeoutMillis;
+ }
+
+ public long getBalancingIntervalMillis() {
+ return balancingIntervalMillis;
+ }
+
+ /** Builder for {@link ContainerBalancerEstimation}. */
+ public static final class Builder {
+ private ContainerBalancerProfile profile;
+ private String failureMessage;
+ private long bytesToMove;
+ private long perIterationBytes;
+ private long estimatedIterations;
+ private long estimatedDurationMillis;
+ private double thresholdPercent;
+ private int maxDatanodesPercentage;
+ private long maxSizeEnteringTarget;
+ private long maxSizeLeavingSource;
+ private long maxSizeToMovePerIteration;
+ private long moveTimeoutMillis;
+ private long balancingIntervalMillis;
+
+ private Builder() {
+ }
+
+ public Builder setProfile(ContainerBalancerProfile profileValue) {
+ this.profile = profileValue;
+ return this;
+ }
+
+ public Builder setFailureMessage(String message) {
+ this.failureMessage = message;
+ return this;
+ }
+
+ public Builder setBytesToMove(long bytes) {
+ this.bytesToMove = bytes;
+ return this;
+ }
+
+ public Builder setPerIterationBytes(long bytes) {
+ this.perIterationBytes = bytes;
+ return this;
+ }
+
+ public Builder setEstimatedIterations(long iterations) {
+ this.estimatedIterations = iterations;
+ return this;
+ }
+
+ public Builder setEstimatedDurationMillis(long durationMillis) {
+ this.estimatedDurationMillis = durationMillis;
+ return this;
+ }
+
+ public Builder setThresholdPercent(double threshold) {
+ this.thresholdPercent = threshold;
+ return this;
+ }
+
+ public Builder setMaxDatanodesPercentage(int percentage) {
+ this.maxDatanodesPercentage = percentage;
+ return this;
+ }
+
+ public Builder setMaxSizeEnteringTarget(long bytes) {
+ this.maxSizeEnteringTarget = bytes;
+ return this;
+ }
+
+ public Builder setMaxSizeLeavingSource(long bytes) {
+ this.maxSizeLeavingSource = bytes;
+ return this;
+ }
+
+ public Builder setMaxSizeToMovePerIteration(long bytes) {
+ this.maxSizeToMovePerIteration = bytes;
+ return this;
+ }
+
+ public Builder setMoveTimeoutMillis(long millis) {
+ this.moveTimeoutMillis = millis;
+ return this;
+ }
+
+ public Builder setBalancingIntervalMillis(long millis) {
+ this.balancingIntervalMillis = millis;
+ return this;
+ }
+
+ public ContainerBalancerEstimation build() {
+ return new ContainerBalancerEstimation(this);
+ }
+ }
+}
diff --git
a/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerProfile.java
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerProfile.java
new file mode 100644
index 00000000000..58b9b14f294
--- /dev/null
+++
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/scm/container/balancer/ContainerBalancerProfile.java
@@ -0,0 +1,53 @@
+/*
+ * 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.hadoop.hdds.scm.container.balancer;
+
+import org.apache.hadoop.ozone.OzoneConsts;
+
+/**
+ * Throttling profile for container balancer.
+ */
+
+public enum ContainerBalancerProfile {
+ SLOW(10, 10L * OzoneConsts.GB, 10L * OzoneConsts.GB),
+ MEDIUM(20, 26L * OzoneConsts.GB, 26L * OzoneConsts.GB),
+ FAST(40, 100L * OzoneConsts.GB, 100L * OzoneConsts.GB);
+
+ private final int datanodesMaxPercentage;
+ private final long maxSizeEnteringTarget;
+ private final long maxSizeLeavingSource;
+
+ ContainerBalancerProfile(int datanodesMaxPercentage, long
maxSizeEnteringTarget,
+ long maxSizeLeavingSource) {
+ this.datanodesMaxPercentage = datanodesMaxPercentage;
+ this.maxSizeEnteringTarget = maxSizeEnteringTarget;
+ this.maxSizeLeavingSource = maxSizeLeavingSource;
+ }
+
+ public int getDatanodesMaxPercentage() {
+ return datanodesMaxPercentage;
+ }
+
+ public long getMaxSizeEnteringTarget() {
+ return maxSizeEnteringTarget;
+ }
+
+ public long getMaxSizeLeavingSource() {
+ return maxSizeLeavingSource;
+ }
+}
diff --git
a/hadoop-hdds/common/src/test/java/org/apache/hadoop/hdds/scm/container/balancer/TestContainerBalancerAdvisor.java
b/hadoop-hdds/common/src/test/java/org/apache/hadoop/hdds/scm/container/balancer/TestContainerBalancerAdvisor.java
new file mode 100644
index 00000000000..411183f4504
--- /dev/null
+++
b/hadoop-hdds/common/src/test/java/org/apache/hadoop/hdds/scm/container/balancer/TestContainerBalancerAdvisor.java
@@ -0,0 +1,296 @@
+/*
+ * 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.hadoop.hdds.scm.container.balancer;
+
+import static org.apache.hadoop.ozone.ClientVersion.DEFAULT_VERSION;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.protocol.DatanodeDetails;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos.DatanodeUsageInfoProto;
+import org.apache.hadoop.ozone.OzoneConsts;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link ContainerBalancerAdvisor} estimation. */
+public final class TestContainerBalancerAdvisor {
+
+ @Test
+ void testComputePerIterationBytesNeverExceedsBytesToMove() {
+ int[] involved = {3, 3};
+ long bytesToMove = 50L * OzoneConsts.GB;
+
+ assertEquals(bytesToMove,
ContainerBalancerAdvisor.computePerIterationBytes(
+ bytesToMove,
+ 500L * OzoneConsts.GB,
+ 26 * OzoneConsts.GB,
+ 26 * OzoneConsts.GB,
+ involved));
+ }
+
+ @Test
+ void testEstimateDefaultReturnsMediumProfile() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ List<ContainerBalancerEstimation> results =
ContainerBalancerAdvisor.estimate(
+ conf,
+ new
ContainerBalancerAdvisor.AdvisorRequest().setNodes(buildCluster(70, 14, 14)));
+
+ assertEquals(1, results.size());
+ ContainerBalancerEstimation estimation = results.get(0);
+ assertThat(estimation.succeeded()).isTrue();
+ assertEquals(ContainerBalancerProfile.MEDIUM, estimation.getProfile());
+ assertThat(estimation.getBytesToMove()).isPositive();
+ assertEquals(26L * OzoneConsts.GB * 7, estimation.getPerIterationBytes());
+ }
+
+ @Test
+ void testEstimateAllProfilesReturnsThreeProfiles() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ List<ContainerBalancerEstimation> results =
ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest()
+ .setNodes(buildCluster(70, 14, 14))
+ .setAllProfiles(true));
+
+ assertEquals(3, results.size());
+ assertEquals(ContainerBalancerProfile.SLOW, results.get(0).getProfile());
+ assertEquals(ContainerBalancerProfile.MEDIUM, results.get(1).getProfile());
+ assertEquals(ContainerBalancerProfile.FAST, results.get(2).getProfile());
+ assertThat(results.get(0).succeeded()).isTrue();
+ assertThat(results.get(1).succeeded()).isTrue();
+ assertThat(results.get(2).succeeded()).isTrue();
+
+ long bytesToMove = results.get(0).getBytesToMove();
+ assertThat(bytesToMove).isPositive();
+ for (ContainerBalancerEstimation result : results) {
+ assertEquals(bytesToMove, result.getBytesToMove());
+ }
+
+ // buildCluster(70, 14, 14): 42 sources, 14 targets; SLOW [4,3], MEDIUM
[7,7], FAST [14,14]
+ assertEquals(30L * OzoneConsts.GB, results.get(0).getPerIterationBytes());
+ assertEquals(26L * OzoneConsts.GB * 7,
results.get(1).getPerIterationBytes());
+ assertEquals(500L * OzoneConsts.GB, results.get(2).getPerIterationBytes());
+ }
+
+ @Test
+ void testEstimateSingleProfileFast() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ ContainerBalancerConfiguration balancerConfig =
conf.getObject(ContainerBalancerConfiguration.class);
+ long expectedCycleTimeMillis =
ContainerBalancerAdvisor.computeCycleTimeMillis(
+ balancerConfig.getMoveTimeout().toMillis(),
+ balancerConfig.getBalancingInterval().toMillis());
+
+ List<ContainerBalancerEstimation> results =
ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest()
+ .setNodes(buildCluster(70, 14, 14))
+ .setProfile(ContainerBalancerProfile.FAST));
+
+ assertEquals(1, results.size());
+ ContainerBalancerEstimation estimation = results.get(0);
+ assertThat(estimation.succeeded()).isTrue();
+ assertEquals(ContainerBalancerProfile.FAST, estimation.getProfile());
+ // 40% of 70 -> maxInvolved=28 -> [14 sources, 14 targets];
+ // 14*100GB exceeds the 500GB iteration cap, so the cap binds.
+ assertEquals(500L * OzoneConsts.GB, estimation.getPerIterationBytes());
+ assertEquals(expectedCycleTimeMillis,
+ estimation.getMoveTimeoutMillis() +
estimation.getBalancingIntervalMillis());
+ assertEquals(
+ (long) Math.ceil((double) estimation.getBytesToMove() /
estimation.getPerIterationBytes()),
+ estimation.getEstimatedIterations());
+ assertEquals(
+ estimation.getEstimatedIterations() * expectedCycleTimeMillis,
+ estimation.getEstimatedDurationMillis());
+ assertEquals(40, estimation.getMaxDatanodesPercentage());
+ }
+
+ @Test
+ void testEstimateRespectsThresholdOverride() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ List<DatanodeUsageInfoProto> nodes = buildCluster(70, 14, 14);
+
+ long defaultThresholdBytesToMove = ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest().setNodes(nodes))
+ .get(0)
+ .getBytesToMove();
+
+ long tighterThresholdBytesToMove = ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest()
+ .setNodes(nodes)
+ .setThresholdPercent(5.0))
+ .get(0)
+ .getBytesToMove();
+
+
assertThat(tighterThresholdBytesToMove).isGreaterThan(defaultThresholdBytesToMove);
+ }
+
+ @Test
+ void testEstimateRespectsExplicitMaxDatanodesPercentageOverride() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ List<ContainerBalancerEstimation> results =
ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest()
+ .setNodes(buildCluster(70, 14, 14))
+ .setProfile(ContainerBalancerProfile.SLOW)
+ .setMaxDatanodesPercentageToInvolvePerIteration(1));
+ assertEquals(1, results.size());
+ assertThat(results.get(0).succeeded()).isFalse();
+ assertThat(results.get(0).getFailureMessage()).contains("at least 2 are
required");
+ }
+
+ @Test
+ void testEstimateRespectsMaxSizeLeavingSourceOverride() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ List<DatanodeUsageInfoProto> nodes = buildCluster(70, 14, 14);
+
+ ContainerBalancerEstimation baseline = ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest()
+ .setNodes(nodes)
+ .setProfile(ContainerBalancerProfile.FAST))
+ .get(0);
+
+ long overriddenLeavingSource = 10L * OzoneConsts.GB;
+ ContainerBalancerEstimation overridden = ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest()
+ .setNodes(nodes)
+ .setProfile(ContainerBalancerProfile.FAST)
+ .setMaxSizeLeavingSource(overriddenLeavingSource))
+ .get(0);
+
+ assertEquals(baseline.getBytesToMove(), overridden.getBytesToMove());
+ assertEquals(500L * OzoneConsts.GB, baseline.getPerIterationBytes());
+ assertEquals(14L * overriddenLeavingSource,
overridden.getPerIterationBytes());
+
+
assertThat(overridden.getEstimatedIterations()).isGreaterThan(baseline.getEstimatedIterations());
+
assertThat(overridden.getEstimatedDurationMillis()).isGreaterThan(baseline.getEstimatedDurationMillis());
+ }
+
+ @Test
+ void
testEstimateReturnsFailedResultWhenMaxMoveOverrideConflictsWithFastPreset() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ List<ContainerBalancerEstimation> results =
ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest()
+ .setNodes(buildCluster(70, 14, 14))
+ .setAllProfiles(true)
+ .setMaxSizeToMovePerIteration(70L * OzoneConsts.GB));
+
+ assertEquals(3, results.size());
+ assertThat(results.get(0).succeeded()).isTrue();
+ assertThat(results.get(1).succeeded()).isTrue();
+ assertThat(results.get(2).succeeded()).isFalse();
+ assertEquals(ContainerBalancerProfile.FAST, results.get(2).getProfile());
+ assertThat(results.get(2).getFailureMessage()).contains(
+ "max-size-entering-target must be less than or equal to
max-size-to-move-per-iteration.");
+ assertEquals(100L * OzoneConsts.GB,
results.get(2).getMaxSizeEnteringTarget());
+ assertEquals(70L * OzoneConsts.GB,
results.get(2).getMaxSizeToMovePerIteration());
+ }
+
+ @Test
+ void testEstimateFailsWhenNodesNull() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ assertThrows(NullPointerException.class, () ->
+ ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest()));
+ }
+
+ @Test
+ void testEstimateFailsWhenNodesEmpty() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ assertThrows(IllegalArgumentException.class, () ->
+ ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest().setNodes(new
ArrayList<>())));
+ }
+
+ @Test
+ void testEstimateFailsWhenClusterBalanced() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ List<DatanodeUsageInfoProto> balanced = new ArrayList<>();
+ balanced.add(proto("dn-1", OzoneConsts.TB, (long) (0.70 *
OzoneConsts.TB)));
+ balanced.add(proto("dn-2", OzoneConsts.TB, (long) (0.70 *
OzoneConsts.TB)));
+
+ assertThrows(IllegalArgumentException.class, () ->
+ ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest().setNodes(balanced)));
+ }
+
+ @Test
+ void testEstimateFailsWhenEnteringTargetTooSmall() {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ List<ContainerBalancerEstimation> results =
ContainerBalancerAdvisor.estimate(
+ conf,
+ new ContainerBalancerAdvisor.AdvisorRequest()
+ .setNodes(buildCluster(70, 14, 14))
+ .setProfile(ContainerBalancerProfile.SLOW)
+ .setMaxSizeEnteringTarget(OzoneConsts.GB));
+ assertEquals(1, results.size());
+ assertThat(results.get(0).succeeded()).isFalse();
+ }
+
+ /**
+ * Builds an imbalanced cluster for balancer estimate tests.
+ *
+ * <p>With default 10% threshold and {@code buildCluster(70, 14, 14)}:
+ * <ul>
+ * <li>{@code under-*} — 5% used — 14 under-utilized targets</li>
+ * <li>{@code mid-*} — 40% used — 14 nodes near cluster average
(neutral)</li>
+ * <li>{@code over-*} — 65% used — 42 over-utilized sources</li>
+ * </ul>
+ */
+ private static List<DatanodeUsageInfoProto> buildCluster(
+ int totalNodes, int underUtilNodeCount, int midUtilNodeCount) {
+ List<DatanodeUsageInfoProto> nodes = new ArrayList<>(totalNodes);
+ long capacity = OzoneConsts.TB;
+ for (int i = 0; i < underUtilNodeCount; i++) {
+ nodes.add(proto("under-" + i, capacity, (long) (capacity * 0.95)));
+ }
+ for (int i = 0; i < midUtilNodeCount; i++) {
+ nodes.add(proto("mid-" + i, capacity, (long) (capacity * 0.60)));
+ }
+ int overUtil = totalNodes - underUtilNodeCount - midUtilNodeCount;
+ for (int i = 0; i < overUtil; i++) {
+ nodes.add(proto("over-" + i, capacity, (long) (capacity * 0.35)));
+ }
+ return nodes;
+ }
+
+ private static DatanodeUsageInfoProto proto(String hostname, long capacity,
long remaining) {
+ DatanodeDetails datanode = DatanodeDetails.newBuilder()
+ .setHostName(hostname)
+ .setIpAddress("127.0.0.1")
+ .setUuid(UUID.randomUUID())
+ .build();
+ return DatanodeUsageInfoProto.newBuilder()
+ .setNode(datanode.toProto(DEFAULT_VERSION.toProtoValue()))
+ .setCapacity(capacity)
+ .setRemaining(remaining)
+ .setUsed(capacity - remaining)
+ .build();
+ }
+}
diff --git
a/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerCommands.java
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerCommands.java
index 951d447d0d5..1c2cd364a51 100644
---
a/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerCommands.java
+++
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerCommands.java
@@ -54,11 +54,40 @@
* involved in balancing
* ozone admin containerbalancer start -s 10
* start balancer with maximum size of 10GB to move in one iteration
+ * To estimate:
+ * ozone admin containerbalancer estimate
+ * [ --profile {@literal <slow|medium|fast>} ]
+ * [ --all ]
+ * [ -t/--threshold {@literal <threshold>} ]
+ * [ -d/--max-datanodes-percentage-to-involve-per-iteration {@literal
<percent>} ]
+ * [ -s/--max-size-to-move-per-iteration-in-gb {@literal <gb>} ]
+ * [ -e/--max-size-entering-target-in-gb {@literal <gb>} ]
+ * [ -l/--max-size-leaving-source-in-gb {@literal <gb>} ]
+ * [ --balancing-iteration-interval-minutes {@literal <minutes>} ]
+ * [ --move-timeout-minutes {@literal <minutes>} ]
+ * [ --move-replication-timeout-minutes {@literal <minutes>} ]
+ * [ --include-datanodes {@literal <host1,host2,...>} ]
+ * [ --exclude-datanodes {@literal <host1,host2,...>} ]
+ * Examples:
+ * ozone admin containerbalancer estimate
+ * estimate bytes to move, number of iterations, per-iteration
throughput, and duration for
+ * the MEDIUM profile (does not start the balancer)
+ * ozone admin containerbalancer estimate --all
+ * estimate for SLOW, MEDIUM, and FAST profiles
+ * ozone admin containerbalancer estimate --profile slow
+ * estimate for the SLOW profile only
+ * ozone admin containerbalancer estimate --profile fast -t 5
+ * estimate FAST profile with a 5% threshold
* To stop:
* ozone admin containerbalancer stop
* </pre>
*
* <p>DESCRIPTION
+ * <p>The estimate subcommand fetches datanode usage from SCM and estimates
from
+ * local configurations, profile presets and cluster analysis made. It does
not start the balancer. Start does not yet
+ * support {@code --profile}, compare estimate profiles to the config you plan
+ * to pass on start, or wait until profile support is added to start.
+ * estimate subcommand produces upper-bound estimates.
* <p>The threshold parameter is a fraction in the range of (1%, 100%) with a
* default value of 10%. The threshold sets a target for whether the cluster
* is balanced. A cluster is balanced if for each datanode, the utilization
@@ -82,7 +111,8 @@
subcommands = {
ContainerBalancerStartSubcommand.class,
ContainerBalancerStopSubcommand.class,
- ContainerBalancerStatusSubcommand.class
+ ContainerBalancerStatusSubcommand.class,
+ ContainerBalancerEstimateSubcommand.class
})
@MetaInfServices(AdminSubcommand.class)
public class ContainerBalancerCommands implements AdminSubcommand {
diff --git
a/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerStartSubcommand.java
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerConfigOptions.java
similarity index 54%
copy from
hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerStartSubcommand.java
copy to
hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerConfigOptions.java
index 09de9e2b758..e82a954f2fd 100644
---
a/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerStartSubcommand.java
+++
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerConfigOptions.java
@@ -17,23 +17,21 @@
package org.apache.hadoop.hdds.scm.cli;
-import java.io.IOException;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.Optional;
-import org.apache.hadoop.hdds.cli.HddsVersionProvider;
-import
org.apache.hadoop.hdds.protocol.proto.StorageContainerLocationProtocolProtos.StartContainerBalancerResponseProto;
-import org.apache.hadoop.hdds.scm.client.ScmClient;
-import picocli.CommandLine.Command;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.hdds.scm.container.balancer.ContainerBalancerAdvisor;
+import org.apache.hadoop.ozone.OzoneConsts;
import picocli.CommandLine.Option;
/**
- * Handler to start container balancer.
+ * Shared Picocli options for container balancer commands.
*/
-@Command(
- name = "start",
- description = "Start ContainerBalancer",
- mixinStandardHelpOptions = true,
- versionProvider = HddsVersionProvider.class)
-public class ContainerBalancerStartSubcommand extends ScmSubcommand {
+public class ContainerBalancerConfigOptions {
@Option(names = {"-t", "--threshold"},
description = "Percentage deviation from average utilization of " +
@@ -42,36 +40,31 @@ public class ContainerBalancerStartSubcommand extends
ScmSubcommand {
"(specify '10' for 10%%).")
private Optional<Double> threshold;
- @Option(names = {"-i", "--iterations"},
- description = "Maximum consecutive iterations that " +
- "balancer will run for. The value should be positive " +
- "or -1, with a default of 10 (specify '10' for 10 iterations).")
- private Optional<Integer> iterations;
-
@Option(names = {"-d",
"--max-datanodes-percentage-to-involve-per-iteration"},
description = "Max percentage of healthy, in service datanodes " +
"that can be involved in balancing in one iteration. The value " +
- "should be in the range [0,100], with a default of 20 (specify " +
- "'20' for 20%%).")
+ "should be in the range (0,100]. When omitted on estimate, each
profile uses its default preset. " +
+ "When omitted on start, the global config with a default of 20
(specify '20' for 20%%) is used.")
private Optional<Integer> maxDatanodesPercentageToInvolvePerIteration;
@Option(names = {"-s", "--max-size-to-move-per-iteration-in-gb"},
description = "Maximum size that can be moved per iteration of " +
- "balancing. The value should be positive, with a default of 500 " +
- "(specify '500' for 500GB).")
+ "balancing. The value should be positive. When omitted, the " +
+ "global config default 500 (specify '500' for 500GB) is used on both
estimate and start.")
private Optional<Long> maxSizeToMovePerIterationInGB;
@Option(names = {"-e", "--max-size-entering-target-in-gb"},
description = "Maximum size that can enter a target datanode while " +
"balancing. This is the sum of data from multiple sources. The value
" +
- "should be positive, with a default of 26 (specify '26' for 26GB).")
+ "should be positive. When omitted on estimate, each profile uses its
default preset. " +
+ "When omitted on start, the global config default 26 (specify '26'
for 26GB) is used.")
private Optional<Long> maxSizeEnteringTargetInGB;
@Option(names = {"-l", "--max-size-leaving-source-in-gb"},
description = "Maximum size that can leave a source datanode while " +
"balancing. This is the sum of data moving to multiple targets. " +
- "The value should be positive, with a default of 26 " +
- "(specify '26' for 26GB).")
+ "The value should be positive. When omitted on estimate, each
profile uses its default preset. " +
+ "When omitted on start, the global config default 26 (specify '26'
for 26GB) is used.")
private Optional<Long> maxSizeLeavingSourceInGB;
@Option(names = {"--balancing-iteration-interval-minutes"},
@@ -95,12 +88,6 @@ public class ContainerBalancerStartSubcommand extends
ScmSubcommand {
"'50' for 50 minutes).")
private Optional<Integer> moveReplicationTimeout;
- @Option(names = {"--move-network-topology-enable"},
- description = "Whether to take network topology into account when " +
- "selecting a target for a source. " +
- "This configuration is false by default.")
- private Optional<Boolean> networkTopologyEnable;
-
@Option(names = {"--include-datanodes"},
description = "A list of Datanode " +
"hostnames or ip addresses separated by commas. Only the Datanodes "
+
@@ -114,39 +101,75 @@ public class ContainerBalancerStartSubcommand extends
ScmSubcommand {
"in this list are excluded from balancing. This configuration is
empty " +
"by default (specify \"hostname1,hostname2,hostname3\").")
private Optional<String> excludeNodes;
+
+ public Optional<Double> getThreshold() {
+ return threshold;
+ }
+
+ public Optional<Integer> getMaxDatanodesPercentageToInvolvePerIteration() {
+ return maxDatanodesPercentageToInvolvePerIteration;
+ }
+
+ public Optional<Long> getMaxSizeToMovePerIterationInGB() {
+ return maxSizeToMovePerIterationInGB;
+ }
+
+ public Optional<Long> getMaxSizeEnteringTargetInGB() {
+ return maxSizeEnteringTargetInGB;
+ }
+
+ public Optional<Long> getMaxSizeLeavingSourceInGB() {
+ return maxSizeLeavingSourceInGB;
+ }
+
+ public Optional<Integer> getBalancingIntervalMinutes() {
+ return balancingInterval;
+ }
+
+ public Optional<Integer> getMoveTimeoutMinutes() {
+ return moveTimeout;
+ }
+
+ public Optional<Integer> getMoveReplicationTimeoutMinutes() {
+ return moveReplicationTimeout;
+ }
+
+ public Optional<String> getIncludeNodes() {
+ return includeNodes;
+ }
+
+ public Optional<String> getExcludeNodes() {
+ return excludeNodes;
+ }
+
+ /** Applies CLI overrides to an estimate request. */
+ public void applyToEstimateRequest(ContainerBalancerAdvisor.AdvisorRequest
request) {
+ threshold.ifPresent(request::setThresholdPercent);
+ maxDatanodesPercentageToInvolvePerIteration.ifPresent(
+ request::setMaxDatanodesPercentageToInvolvePerIteration);
+ maxSizeToMovePerIterationInGB.ifPresent(gb ->
+ request.setMaxSizeToMovePerIteration(gb * OzoneConsts.GB));
+ maxSizeEnteringTargetInGB.ifPresent(gb ->
+ request.setMaxSizeEnteringTarget(gb * OzoneConsts.GB));
+ maxSizeLeavingSourceInGB.ifPresent(gb ->
+ request.setMaxSizeLeavingSource(gb * OzoneConsts.GB));
+ balancingInterval.ifPresent(minutes ->
+
request.setBalancingIntervalMillis(Duration.ofMinutes(minutes).toMillis()));
+ moveTimeout.ifPresent(minutes ->
+ request.setMoveTimeoutMillis(Duration.ofMinutes(minutes).toMillis()));
+ moveReplicationTimeout.ifPresent(minutes ->
+
request.setMoveReplicationTimeoutMillis(Duration.ofMinutes(minutes).toMillis()));
+ includeNodes.ifPresent(value ->
request.setIncludeNodes(parseNodeSet(value)));
+ excludeNodes.ifPresent(value ->
request.setExcludeNodes(parseNodeSet(value)));
+ }
- @Option(names = {"--exclude-containers"},
- description = "A list of container IDs separated by commas. " +
- "The containers specified in this list are excluded from balancing.
" +
- "This configuration is empty by default " +
- "(specify \"1,2,3\" for container IDs).")
- private Optional<String> excludeContainers;
-
- @Option(names = {"--include-containers"},
- description = "A list of container IDs separated by commas. " +
- "Only the containers specified in this list will be included in
balancing." +
- " If --exclude-containers is also specified, those containers will "
+
- "be excluded. This configuration is empty by default " +
- "(specify \"1,2,3\" for container IDs).")
- private Optional<String> includeContainers;
-
- @Override
- public void execute(ScmClient scmClient) throws IOException {
- StartContainerBalancerResponseProto response = scmClient.
- startContainerBalancer(threshold, iterations,
- maxDatanodesPercentageToInvolvePerIteration,
- maxSizeToMovePerIterationInGB, maxSizeEnteringTargetInGB,
- maxSizeLeavingSourceInGB, balancingInterval, moveTimeout,
- moveReplicationTimeout, networkTopologyEnable, includeNodes,
- excludeNodes, excludeContainers, includeContainers);
- if (response.getStart()) {
- System.out.println("Container Balancer started successfully.");
- } else {
- String reason = "";
- if (response.hasMessage()) {
- reason = response.getMessage();
- }
- throw new IOException("Failed to start Container Balancer. " + reason);
+ private static Set<String> parseNodeSet(String nodes) {
+ if (StringUtils.isBlank(nodes)) {
+ return Collections.emptySet();
}
+ return Arrays.stream(nodes.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .collect(Collectors.toSet());
}
}
diff --git
a/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerEstimateSubcommand.java
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerEstimateSubcommand.java
new file mode 100644
index 00000000000..3e0b984b050
--- /dev/null
+++
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerEstimateSubcommand.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.hadoop.hdds.scm.cli;
+
+import static org.apache.hadoop.util.StringUtils.byteDesc;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import org.apache.hadoop.hdds.cli.HddsVersionProvider;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos.DatanodeUsageInfoProto;
+import org.apache.hadoop.hdds.scm.client.ScmClient;
+import org.apache.hadoop.hdds.scm.container.balancer.ContainerBalancerAdvisor;
+import
org.apache.hadoop.hdds.scm.container.balancer.ContainerBalancerEstimation;
+import org.apache.hadoop.hdds.scm.container.balancer.ContainerBalancerProfile;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+/**
+ * Estimates container balancer bytes to move, per iteration bytes, estimated
iterations, estimated duration
+ * without starting the balancer.
+ */
+@Command(
+ name = "estimate",
+ description = "Estimate container balancer bytes to move, iterations, per
iteration bytes " +
+ "and upper-bound duration without starting it. Throttling profiles
(SLOW, MEDIUM, FAST) use built-in presets," +
+ " datanode usage is fetched from SCM.",
+ mixinStandardHelpOptions = true,
+ versionProvider = HddsVersionProvider.class)
+public class ContainerBalancerEstimateSubcommand extends ScmSubcommand {
+
+ private static final double PLANNING_ITERATION_BUFFER = 1.3d;
+
+ @CommandLine.Mixin
+ private ContainerBalancerConfigOptions configOptions;
+
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1")
+ private ProfileSelection profileSelection;
+
+ @Override
+ public void execute(ScmClient scmClient) throws IOException {
+
+ List<DatanodeUsageInfoProto> nodes = scmClient.getDatanodeUsageInfo(true,
Integer.MAX_VALUE);
+ if (nodes == null || nodes.isEmpty()) {
+ throw new IOException("No datanode usage information available from
SCM.");
+ }
+
+ OzoneConfiguration conf = getOzoneConf();
+ ContainerBalancerAdvisor.AdvisorRequest request = buildRequest(nodes);
+ List<ContainerBalancerEstimation> estimations;
+ try {
+ estimations = ContainerBalancerAdvisor.estimate(conf, request);
+ } catch (IllegalArgumentException e) {
+ throw new IOException(e.getMessage(), e);
+ }
+
+ if (profileSelection == null) {
+ out().println("No profile specified, using the default.");
+ }
+
+ boolean anySucceeded = false;
+ boolean multipleProfiles = estimations.size() > 1;
+ for (ContainerBalancerEstimation result : estimations) {
+ out().printf("Profile: %s%n", result.getProfile().name());
+ printBasedOn(result);
+ if (result.succeeded()) {
+ anySucceeded = true;
+ printEstimation(result);
+ } else if (multipleProfiles) {
+ out().printf(" Estimation failed: %s%n%n", result.getFailureMessage());
+ }
+ }
+ if (!anySucceeded) {
+ if (multipleProfiles) {
+ throw new IOException("Estimation failed for all profiles.");
+ }
+ throw new IOException(estimations.get(0).getFailureMessage());
+ }
+ }
+
+ private ContainerBalancerAdvisor.AdvisorRequest
buildRequest(List<DatanodeUsageInfoProto> nodes) throws IOException {
+ ContainerBalancerAdvisor.AdvisorRequest request = new
ContainerBalancerAdvisor.AdvisorRequest().setNodes(nodes);
+ configOptions.applyToEstimateRequest(request);
+
+ if (profileSelection != null) {
+ if (profileSelection.allProfiles) {
+ request.setAllProfiles(true);
+ } else {
+ request.setProfile(parseProfile(profileSelection.profileName.get()));
+ }
+ }
+ return request;
+ }
+
+ private static ContainerBalancerProfile parseProfile(String name) throws
IOException {
+ try {
+ return
ContainerBalancerProfile.valueOf(name.trim().toUpperCase(Locale.ENGLISH));
+ } catch (IllegalArgumentException e) {
+ throw new IOException("Invalid profile: " + name + ". Expected SLOW,
MEDIUM, or FAST.");
+ }
+ }
+
+ private void printBasedOn(ContainerBalancerEstimation estimation) {
+ long moveTimeoutMinutes = Math.round(estimation.getMoveTimeoutMillis() /
60000d);
+ long balancingIntervalMinutes =
Math.round(estimation.getBalancingIntervalMillis() / 60000d);
+ out().println(" Based on:");
+ out().printf(Locale.ENGLISH, " Threshold: %.1f%%%n",
estimation.getThresholdPercent());
+ out().printf(Locale.ENGLISH, " Datanode involvement: %d%%%n",
+ estimation.getMaxDatanodesPercentage());
+ out().printf(" Max entering target: %s / node%n",
byteDesc(estimation.getMaxSizeEnteringTarget()));
+ out().printf(" Max leaving source: %s / node%n",
byteDesc(estimation.getMaxSizeLeavingSource()));
+ out().printf(" Max per iteration: %s%n",
byteDesc(estimation.getMaxSizeToMovePerIteration()));
+ out().printf(" Move timeout: %d min%n", moveTimeoutMinutes);
+ out().printf(" Balancing interval: %d min%n",
balancingIntervalMinutes);
+ }
+
+ private void printEstimation(ContainerBalancerEstimation estimation) {
+ long estimatedIterations = estimation.getEstimatedIterations();
+ long planningIterations = (long) Math.ceil(estimatedIterations *
PLANNING_ITERATION_BUFFER);
+ long cycleTimeMillis = estimation.getMoveTimeoutMillis() +
estimation.getBalancingIntervalMillis();
+ long baseDurationMillis = estimation.getEstimatedDurationMillis();
+ long planningDurationMillis = planningIterations * cycleTimeMillis;
+ out().printf(" Bytes to move: %s%n",
byteDesc(estimation.getBytesToMove()));
+ out().printf(" Per iteration (estimate): ~%s%n",
byteDesc(estimation.getPerIterationBytes()));
+ out().printf(" Estimated iterations: %d (planning estimate: %d,
includes +30%% buffer)%n",
+ estimatedIterations, planningIterations);
+ out().printf(" Estimated duration: upper bound %s (planning
estimate: %s, includes +30%% buffer)%n",
+ formatEstimatedDuration(baseDurationMillis),
+ formatEstimatedDuration(planningDurationMillis));
+ out().println(" (assumes full move timeout +
interval each cycle)");
+ out().println();
+ }
+
+ private static String formatEstimatedDuration(long durationMillis) {
+ double days = durationMillis / 86400000d;
+ if (days >= 1) {
+ return String.format(Locale.ENGLISH, "~%.1f days", days);
+ }
+ double hours = durationMillis / 3600000d;
+ if (hours >= 1) {
+ return String.format(Locale.ENGLISH, "~%.1f hours", hours);
+ }
+ long minutes = durationMillis / 60000;
+ return String.format(Locale.ENGLISH, "~%d min", minutes);
+ }
+
+ static class ProfileSelection {
+ @Option(names = {"--profile"},
+ description = "Throttling profile: SLOW, MEDIUM, or FAST profiles.
When set, only this profile is estimated. "
+ + "When omitted, the MEDIUM profile is estimated.")
+ private Optional<String> profileName;
+
+ @Option(names = {"--all"},
+ description = "Estimate SLOW, MEDIUM, and FAST profiles.")
+ private boolean allProfiles;
+ }
+}
diff --git
a/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerStartSubcommand.java
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerStartSubcommand.java
index 09de9e2b758..4583a6d9be5 100644
---
a/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerStartSubcommand.java
+++
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/ContainerBalancerStartSubcommand.java
@@ -22,6 +22,7 @@
import org.apache.hadoop.hdds.cli.HddsVersionProvider;
import
org.apache.hadoop.hdds.protocol.proto.StorageContainerLocationProtocolProtos.StartContainerBalancerResponseProto;
import org.apache.hadoop.hdds.scm.client.ScmClient;
+import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
@@ -35,12 +36,8 @@
versionProvider = HddsVersionProvider.class)
public class ContainerBalancerStartSubcommand extends ScmSubcommand {
- @Option(names = {"-t", "--threshold"},
- description = "Percentage deviation from average utilization of " +
- "the cluster after which a datanode will be rebalanced. The value " +
- "should be in the range [0.0, 100.0), with a default of 10 " +
- "(specify '10' for 10%%).")
- private Optional<Double> threshold;
+ @CommandLine.Mixin
+ private ContainerBalancerConfigOptions configOptions;
@Option(names = {"-i", "--iterations"},
description = "Maximum consecutive iterations that " +
@@ -48,73 +45,12 @@ public class ContainerBalancerStartSubcommand extends
ScmSubcommand {
"or -1, with a default of 10 (specify '10' for 10 iterations).")
private Optional<Integer> iterations;
- @Option(names = {"-d",
"--max-datanodes-percentage-to-involve-per-iteration"},
- description = "Max percentage of healthy, in service datanodes " +
- "that can be involved in balancing in one iteration. The value " +
- "should be in the range [0,100], with a default of 20 (specify " +
- "'20' for 20%%).")
- private Optional<Integer> maxDatanodesPercentageToInvolvePerIteration;
-
- @Option(names = {"-s", "--max-size-to-move-per-iteration-in-gb"},
- description = "Maximum size that can be moved per iteration of " +
- "balancing. The value should be positive, with a default of 500 " +
- "(specify '500' for 500GB).")
- private Optional<Long> maxSizeToMovePerIterationInGB;
-
- @Option(names = {"-e", "--max-size-entering-target-in-gb"},
- description = "Maximum size that can enter a target datanode while " +
- "balancing. This is the sum of data from multiple sources. The value
" +
- "should be positive, with a default of 26 (specify '26' for 26GB).")
- private Optional<Long> maxSizeEnteringTargetInGB;
-
- @Option(names = {"-l", "--max-size-leaving-source-in-gb"},
- description = "Maximum size that can leave a source datanode while " +
- "balancing. This is the sum of data moving to multiple targets. " +
- "The value should be positive, with a default of 26 " +
- "(specify '26' for 26GB).")
- private Optional<Long> maxSizeLeavingSourceInGB;
-
- @Option(names = {"--balancing-iteration-interval-minutes"},
- description = "The interval period in minutes between each iteration of
Container Balancer. " +
- "The value should be positive, with a default of 70 (specify '70'
for 70 minutes).")
- private Optional<Integer> balancingInterval;
-
- @Option(names = {"--move-timeout-minutes"},
- description = "The amount of time in minutes to allow a single container
to move " +
- "from source to target. The value should be positive, with a default
of 65 " +
- "(specify '65' for 65 minutes).")
- private Optional<Integer> moveTimeout;
-
- @Option(names = {"--move-replication-timeout-minutes"},
- description = "The " +
- "amount of time in minutes to allow a single container's replication
from source " +
- "to target as part of container move. The value should be positive,
with " +
- "a default of 50. For example, if \"hdds.container" +
- ".balancer.move.timeout\" is 65 minutes, then out of those 65
minutes " +
- "50 minutes will be the deadline for replication to complete
(specify " +
- "'50' for 50 minutes).")
- private Optional<Integer> moveReplicationTimeout;
-
@Option(names = {"--move-network-topology-enable"},
description = "Whether to take network topology into account when " +
"selecting a target for a source. " +
"This configuration is false by default.")
private Optional<Boolean> networkTopologyEnable;
- @Option(names = {"--include-datanodes"},
- description = "A list of Datanode " +
- "hostnames or ip addresses separated by commas. Only the Datanodes "
+
- "specified in this list are balanced. This configuration is empty by
" +
- "default and is applicable only if it is non-empty (specify
\"hostname1,hostname2,hostname3\").")
- private Optional<String> includeNodes;
-
- @Option(names = {"--exclude-datanodes"},
- description = "A list of Datanode " +
- "hostnames or ip addresses separated by commas. The Datanodes
specified " +
- "in this list are excluded from balancing. This configuration is
empty " +
- "by default (specify \"hostname1,hostname2,hostname3\").")
- private Optional<String> excludeNodes;
-
@Option(names = {"--exclude-containers"},
description = "A list of container IDs separated by commas. " +
"The containers specified in this list are excluded from balancing.
" +
@@ -132,13 +68,21 @@ public class ContainerBalancerStartSubcommand extends
ScmSubcommand {
@Override
public void execute(ScmClient scmClient) throws IOException {
- StartContainerBalancerResponseProto response = scmClient.
- startContainerBalancer(threshold, iterations,
- maxDatanodesPercentageToInvolvePerIteration,
- maxSizeToMovePerIterationInGB, maxSizeEnteringTargetInGB,
- maxSizeLeavingSourceInGB, balancingInterval, moveTimeout,
- moveReplicationTimeout, networkTopologyEnable, includeNodes,
- excludeNodes, excludeContainers, includeContainers);
+ StartContainerBalancerResponseProto response =
scmClient.startContainerBalancer(
+ configOptions.getThreshold(),
+ iterations,
+ configOptions.getMaxDatanodesPercentageToInvolvePerIteration(),
+ configOptions.getMaxSizeToMovePerIterationInGB(),
+ configOptions.getMaxSizeEnteringTargetInGB(),
+ configOptions.getMaxSizeLeavingSourceInGB(),
+ configOptions.getBalancingIntervalMinutes(),
+ configOptions.getMoveTimeoutMinutes(),
+ configOptions.getMoveReplicationTimeoutMinutes(),
+ networkTopologyEnable,
+ configOptions.getIncludeNodes(),
+ configOptions.getExcludeNodes(),
+ excludeContainers,
+ includeContainers);
if (response.getStart()) {
System.out.println("Container Balancer started successfully.");
} else {
diff --git
a/hadoop-ozone/cli-admin/src/test/java/org/apache/hadoop/hdds/scm/cli/datanode/TestContainerBalancerSubCommand.java
b/hadoop-ozone/cli-admin/src/test/java/org/apache/hadoop/hdds/scm/cli/datanode/TestContainerBalancerSubCommand.java
index 5eca86a8a6c..92aae28b2f3 100644
---
a/hadoop-ozone/cli-admin/src/test/java/org/apache/hadoop/hdds/scm/cli/datanode/TestContainerBalancerSubCommand.java
+++
b/hadoop-ozone/cli-admin/src/test/java/org/apache/hadoop/hdds/scm/cli/datanode/TestContainerBalancerSubCommand.java
@@ -17,27 +17,37 @@
package org.apache.hadoop.hdds.scm.cli.datanode;
+import static org.apache.hadoop.hdds.DatanodeVersion.DEFAULT_VERSION;
import static org.apache.hadoop.ozone.OzoneConsts.GB;
+import static org.apache.hadoop.util.StringUtils.byteDesc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.OffsetDateTime;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
+import org.apache.hadoop.hdds.protocol.DatanodeDetails;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos;
import
org.apache.hadoop.hdds.protocol.proto.StorageContainerLocationProtocolProtos;
import
org.apache.hadoop.hdds.protocol.proto.StorageContainerLocationProtocolProtos.ContainerBalancerStatusInfoProto;
import
org.apache.hadoop.hdds.protocol.proto.StorageContainerLocationProtocolProtos.ContainerBalancerStatusInfoResponseProto;
+import org.apache.hadoop.hdds.scm.cli.ContainerBalancerEstimateSubcommand;
import org.apache.hadoop.hdds.scm.cli.ContainerBalancerStartSubcommand;
import org.apache.hadoop.hdds.scm.cli.ContainerBalancerStatusSubcommand;
import org.apache.hadoop.hdds.scm.cli.ContainerBalancerStopSubcommand;
import org.apache.hadoop.hdds.scm.client.ScmClient;
import
org.apache.hadoop.hdds.scm.container.balancer.ContainerBalancerConfiguration;
import org.apache.hadoop.hdds.utils.IOUtils;
+import org.apache.hadoop.ozone.OzoneConsts;
import org.apache.ozone.test.GenericTestUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -160,6 +170,7 @@ class TestContainerBalancerSubCommand {
private ContainerBalancerStopSubcommand stopCmd;
private ContainerBalancerStartSubcommand startCmd;
private ContainerBalancerStatusSubcommand statusCmd;
+ private ContainerBalancerEstimateSubcommand estimateCmd;
private GenericTestUtils.PrintStreamCapturer out;
private GenericTestUtils.PrintStreamCapturer err;
private AtomicBoolean verbose;
@@ -369,6 +380,8 @@ protected boolean isVerbose() {
return verbose.get();
}
};
+ parseSubcommand(startCmd);
+ estimateCmd = new ContainerBalancerEstimateSubcommand();
out = GenericTestUtils.captureOut();
err = GenericTestUtils.captureErr();
}
@@ -574,7 +587,7 @@ public void
testContainerBalancerStartSubcommandWhenBalancerIsNotRunning()
throws IOException {
ScmClient scmClient = mock(ScmClient.class);
when(scmClient.startContainerBalancer(
- null, null, null, null, null, null, null, null, null, null, null,
null, null, null))
+ any(), any(), any(), any(), any(), any(), any(), any(), any(), any(),
any(), any(), any(), any()))
.thenReturn(
StorageContainerLocationProtocolProtos
.StartContainerBalancerResponseProto.newBuilder()
@@ -590,7 +603,7 @@ public void
testContainerBalancerStartSubcommandWhenBalancerIsRunning()
throws IOException {
ScmClient scmClient = mock(ScmClient.class);
when(scmClient.startContainerBalancer(
- null, null, null, null, null, null, null, null, null, null, null,
null, null, null))
+ any(), any(), any(), any(), any(), any(), any(), any(), any(), any(),
any(), any(), any(), any()))
.thenReturn(StorageContainerLocationProtocolProtos
.StartContainerBalancerResponseProto.newBuilder()
.setStart(false)
@@ -821,4 +834,247 @@ void
testContainerBalancerStatusVerboseShowsNoBreakdownWhenFailuresMissing() thr
.contains("Failed to move containers 3")
.contains("Failed container moves (no
breakdown available)");
}
+
+ @Test
+ void testContainerBalancerEstimateSubcommandDefaultShowsMediumProfile()
throws IOException {
+ ScmClient scmClient = mock(ScmClient.class);
+ when(scmClient.getDatanodeUsageInfo(true, Integer.MAX_VALUE))
+ .thenReturn(buildImbalancedCluster());
+
+ parseSubcommand(estimateCmd);
+ estimateCmd.execute(scmClient);
+
+ String output = out.get();
+ assertThat(output)
+ .contains("No profile specified, using the default.")
+ .contains("Profile: MEDIUM");
+ assertThat(output).doesNotContain("Profile: SLOW");
+ assertThat(output).doesNotContain("Profile: FAST");
+ assertThat(output.split("Profile:")).hasSize(2);
+ assertThat(output)
+ .contains("Based on:")
+ .contains("Threshold: 10.0%")
+ .contains("Datanode involvement: 20%")
+ .contains("Bytes to move:")
+ .contains("Per iteration (estimate): ~" + byteDesc(26L * GB * 7))
+ .contains("planning estimate:")
+ .contains("upper bound")
+ .contains("assumes full move timeout + interval each cycle")
+ .doesNotContain("Estimation failed:");
+ }
+
+ @Test
+ void testContainerBalancerEstimateSubcommandAllShowsAllProfiles() throws
IOException {
+ ScmClient scmClient = mock(ScmClient.class);
+ when(scmClient.getDatanodeUsageInfo(true, Integer.MAX_VALUE))
+ .thenReturn(buildImbalancedCluster());
+
+ parseSubcommand(estimateCmd, "--all");
+ estimateCmd.execute(scmClient);
+
+ String output = out.get();
+ String[] blocks = output.split("Profile:");
+ assertThat(blocks).hasSize(4);
+
+ String slow = blocks[1];
+ assertThat(slow)
+ .contains("Based on:")
+ .contains("Threshold: 10.0%")
+ .contains("Datanode involvement: 10%")
+ .contains("Bytes to move:")
+ .contains("Per iteration (estimate): ~" + byteDesc(30L * GB))
+ .contains("planning estimate:")
+ .contains("upper bound")
+ .contains("assumes full move timeout + interval each cycle")
+ .doesNotContain("Estimation failed:");
+
+ String medium = blocks[2];
+ assertThat(medium)
+ .contains("Based on:")
+ .contains("Threshold: 10.0%")
+ .contains("Datanode involvement: 20%")
+ .contains("Bytes to move:")
+ .contains("Per iteration (estimate): ~" + byteDesc(26L * GB * 7))
+ .contains("planning estimate:")
+ .contains("upper bound")
+ .doesNotContain("Estimation failed:");
+
+ String fast = blocks[3];
+ assertThat(fast)
+ .contains("Based on:")
+ .contains("Threshold: 10.0%")
+ .contains("Datanode involvement: 40%")
+ .contains("Bytes to move:")
+ .contains("Per iteration (estimate): ~" + byteDesc(500L * GB))
+ .contains("planning estimate:")
+ .contains("upper bound")
+ .doesNotContain("Estimation failed:");
+ }
+
+ @Test
+ void testContainerBalancerEstimateSubcommandInvalidThresholdFails() throws
IOException {
+ ScmClient scmClient = mock(ScmClient.class);
+ when(scmClient.getDatanodeUsageInfo(true, Integer.MAX_VALUE))
+ .thenReturn(buildImbalancedCluster());
+ parseSubcommand(estimateCmd, "-t", "-1");
+ IOException ex = assertThrows(IOException.class, () ->
estimateCmd.execute(scmClient));
+ assertThat(ex.getMessage()).contains("Threshold should be specified in the
range [0.0, 100.0).");
+ }
+
+ @Test
+ void testContainerBalancerEstimateSubcommandInvalidDatanodePercentageFails()
throws IOException {
+ ScmClient scmClient = mock(ScmClient.class);
+ when(scmClient.getDatanodeUsageInfo(true, Integer.MAX_VALUE))
+ .thenReturn(buildImbalancedCluster());
+ parseSubcommand(estimateCmd, "-d", "0");
+ IOException ex = assertThrows(IOException.class, () ->
estimateCmd.execute(scmClient));
+ assertThat(ex.getMessage()).contains(
+ "Max Datanodes Percentage To Involve Per Iteration should be specified
in the range (0, 100]");
+ }
+
+ @Test
+ void testContainerBalancerEstimateSubcommandInvalidMaxSizeFails() throws
IOException {
+ ScmClient scmClient = mock(ScmClient.class);
+ when(scmClient.getDatanodeUsageInfo(true, Integer.MAX_VALUE))
+ .thenReturn(buildImbalancedCluster());
+ parseSubcommand(estimateCmd, "-s", "0");
+ IOException ex = assertThrows(IOException.class, () ->
estimateCmd.execute(scmClient));
+ assertThat(ex.getMessage()).contains(
+ "Max Size To Move Per Iteration In GB must be positive.");
+ }
+
+ @Test
+ void testContainerBalancerEstimateSubcommandInvalidProfileFails() throws
IOException {
+ ScmClient scmClient = mock(ScmClient.class);
+ when(scmClient.getDatanodeUsageInfo(true, Integer.MAX_VALUE))
+ .thenReturn(buildImbalancedCluster());
+
+ parseSubcommand(estimateCmd, "--profile", "turbo");
+ IOException ex = assertThrows(IOException.class, () ->
estimateCmd.execute(scmClient));
+ assertThat(ex.getMessage()).contains("Invalid profile: turbo");
+ }
+
+ @Test
+ void testContainerBalancerEstimateSubcommandWithProfileShowsOneProfile()
throws IOException {
+ ScmClient scmClient = mock(ScmClient.class);
+ when(scmClient.getDatanodeUsageInfo(true, Integer.MAX_VALUE))
+ .thenReturn(buildImbalancedCluster());
+
+ parseSubcommand(estimateCmd, "--profile", "FAST");
+ estimateCmd.execute(scmClient);
+
+ String output = out.get();
+ assertThat(output).contains("Profile: FAST");
+ assertThat(output).doesNotContain("Profile: SLOW");
+ assertThat(output).doesNotContain("Profile: MEDIUM");
+ assertThat(output.split("Profile:")).hasSize(2);
+ assertThat(output)
+ .contains("Datanode involvement: 40%")
+ .contains("Per iteration (estimate): ~" + byteDesc(500L * GB))
+ .contains("Estimated duration: upper bound");
+ }
+
+ @Test
+ void
testContainerBalancerEstimateSubcommandCliOverrideUsesResolvedValuesInOutput()
throws IOException {
+ ScmClient scmClient = mock(ScmClient.class);
+ when(scmClient.getDatanodeUsageInfo(true, Integer.MAX_VALUE))
+ .thenReturn(buildImbalancedCluster());
+
+ parseSubcommand(estimateCmd, "--profile", "slow", "-e", "6");
+ estimateCmd.execute(scmClient);
+
+ assertThat(out.get())
+ .contains("Max entering target: " + byteDesc(6L * GB) + " / node")
+ .contains("Per iteration (estimate): ~" + byteDesc(18L * GB));
+ }
+
+ @Test
+ void
testContainerBalancerEstimateSubcommandPartialFailureWhenMaxMoveOverrideConflictsWithFastPreset()
+ throws IOException {
+ ScmClient scmClient = mock(ScmClient.class);
+ when(scmClient.getDatanodeUsageInfo(true, Integer.MAX_VALUE))
+ .thenReturn(buildImbalancedCluster());
+
+ parseSubcommand(estimateCmd, "--all", "-s", "70", "-t", "5");
+ estimateCmd.execute(scmClient);
+
+ String output = out.get();
+ String[] blocks = output.split("Profile:");
+ assertThat(blocks).hasSize(4);
+
+ String slow = blocks[1];
+ assertThat(slow)
+ .contains("Based on:")
+ .contains("Threshold: 5.0%")
+ .contains("Max per iteration: " + byteDesc(70L * GB))
+ .contains("Bytes to move:")
+ .contains("Per iteration (estimate): ~" + byteDesc(30L * GB))
+ .doesNotContain("Estimation failed:");
+
+ String medium = blocks[2];
+ assertThat(medium)
+ .contains("Based on:")
+ .contains("Threshold: 5.0%")
+ .contains("Max per iteration: " + byteDesc(70L * GB))
+ .contains("Bytes to move:")
+ .contains("Per iteration (estimate): ~" + byteDesc(70L * GB))
+ .doesNotContain("Estimation failed:");
+
+ String fast = blocks[3];
+ assertThat(fast)
+ .contains("Based on:")
+ .contains("Threshold: 5.0%")
+ .contains("Max entering target: " + byteDesc(100L * GB) + " /
node")
+ .contains("Max per iteration: " + byteDesc(70L * GB))
+ .contains("Estimation failed: max-size-entering-target must be less
than or equal to "
+ + "max-size-to-move-per-iteration.")
+ .doesNotContain("Bytes to move:")
+ .doesNotContain("Per iteration (estimate):");
+ }
+
+ /**
+ * Imbalanced cluster for estimate CLI tests.
+ *
+ * <p>With default 10% threshold: 14 targets, 42 sources, 14 neutral (70
eligible).
+ */
+ private static List<HddsProtos.DatanodeUsageInfoProto>
buildImbalancedCluster() {
+ return buildCluster(70, 14, 14);
+ }
+
+ private static List<HddsProtos.DatanodeUsageInfoProto> buildCluster(
+ int totalNodes, int underUtilNodeCount, int midUtilNodeCount) {
+ List<HddsProtos.DatanodeUsageInfoProto> nodes = new
ArrayList<>(totalNodes);
+ long capacity = OzoneConsts.TB;
+ for (int i = 0; i < underUtilNodeCount; i++) {
+ nodes.add(datanodeUsageProto("under-" + i, capacity, (long) (capacity *
0.95)));
+ }
+ for (int i = 0; i < midUtilNodeCount; i++) {
+ nodes.add(datanodeUsageProto("mid-" + i, capacity, (long) (capacity *
0.60)));
+ }
+ int overUtil = totalNodes - underUtilNodeCount - midUtilNodeCount;
+ for (int i = 0; i < overUtil; i++) {
+ nodes.add(datanodeUsageProto("over-" + i, capacity, (long) (capacity *
0.35)));
+ }
+ return nodes;
+ }
+
+ private static HddsProtos.DatanodeUsageInfoProto datanodeUsageProto(
+ String hostname, long capacity, long remaining) {
+ DatanodeDetails datanode = DatanodeDetails.newBuilder()
+ .setHostName(hostname)
+ .setIpAddress("127.0.0.1")
+ .setUuid(UUID.randomUUID())
+ .build();
+ return HddsProtos.DatanodeUsageInfoProto.newBuilder()
+ .setNode(datanode.toProto(DEFAULT_VERSION.toProtoValue()))
+ .setCapacity(capacity)
+ .setRemaining(remaining)
+ .setUsed(capacity - remaining)
+ .build();
+ }
+
+ /** Picocli must parse args so @Mixin, @Option, and @Spec fields are
injected. */
+ private static void parseSubcommand(Object subcommand, String... args) {
+ new CommandLine(subcommand).parseArgs(args);
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]