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

devmadhuu pushed a commit to branch HDDS-11233
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/HDDS-11233 by this push:
     new ac79e041aba HDDS-16040. Make DN DiskBalancerService StorageType Aware 
for Container Migration. (#11322)
ac79e041aba is described below

commit ac79e041aba10530f0e643b93851f081b5a0cc12
Author: Devesh Kumar Singh <[email protected]>
AuthorDate: Wed Oct 14 10:13:14 2026 +0530

    HDDS-16040. Make DN DiskBalancerService StorageType Aware for Container 
Migration. (#11322)
    
    Co-authored-by: xichen01
---
 .../container/diskbalancer/DiskBalancerInfo.java   |  13 +-
 .../diskbalancer/DiskBalancerProtocolServer.java   |   2 +-
 .../diskbalancer/DiskBalancerService.java          |  91 +++++++---
 .../DiskBalancerVolumeCalculation.java             |  87 ++++++++-
 .../policy/DefaultContainerChoosingPolicy.java     | 195 +++++++++++++++------
 .../TestDefaultContainerChoosingPolicy.java        | 115 ++++++++++++
 .../TestDiskBalancerProtocolServer.java            |  13 +-
 .../TestDiskBalancerVolumeCalculation.java         | 133 +++++++++++++-
 .../interface-client/src/main/proto/hdds.proto     |  11 ++
 .../interface-client/src/main/resources/proto.lock |  55 +++++-
 .../cli/datanode/DiskBalancerReportSubcommand.java | 103 +++++++++--
 .../cli/datanode/DiskBalancerStatusSubcommand.java |  39 ++++-
 .../cli/datanode/TestDiskBalancerSubCommands.java  |  88 ++++++++++
 13 files changed, 843 insertions(+), 102 deletions(-)

diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerInfo.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerInfo.java
index 46c21bc4d65..a9ce007a540 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerInfo.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerInfo.java
@@ -21,11 +21,12 @@
 import java.util.List;
 import java.util.Objects;
 import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.DiskBalancerRunningStatus;
+import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.StorageTypeDiskBalancerInfoProto;
 import org.apache.hadoop.hdds.protocol.proto.HddsProtos.VolumeReportProto;
 
 /**
  * DiskBalancer's information to persist and for report.
- * Report-only fields (idealUsage, volumeInfo) are NOT persisted to YAML.
+ * Report-only fields (idealUsage, volumeInfo, storageTypeInfo) are NOT 
persisted to YAML.
  */
 public class DiskBalancerInfo {
   private DiskBalancerRunningStatus operationalState;
@@ -44,6 +45,8 @@ public class DiskBalancerInfo {
   private double idealUsage;
   // Report-only: per-volume info. NOT persisted.
   private List<VolumeReportProto> volumeInfo;
+  // Report-only: per-storage-type balancing info. NOT persisted.
+  private List<StorageTypeDiskBalancerInfoProto> storageTypeInfo;
 
   public DiskBalancerInfo(DiskBalancerRunningStatus operationalState, double 
threshold,
       long bandwidthInMB, int parallelThread, boolean stopAfterDiskEven) {
@@ -250,6 +253,14 @@ public void setVolumeInfo(List<VolumeReportProto> 
volumeInfo) {
     this.volumeInfo = volumeInfo;
   }
 
+  public List<StorageTypeDiskBalancerInfoProto> getStorageTypeInfo() {
+    return storageTypeInfo != null ? storageTypeInfo : Collections.emptyList();
+  }
+
+  public void setStorageTypeInfo(List<StorageTypeDiskBalancerInfoProto> 
storageTypeInfo) {
+    this.storageTypeInfo = storageTypeInfo;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (this == o) {
diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerProtocolServer.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerProtocolServer.java
index 770f7b67414..801c0f952de 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerProtocolServer.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerProtocolServer.java
@@ -85,6 +85,7 @@ public DatanodeDiskBalancerInfoProto 
getDiskBalancerInfo(GetDiskBalancerInfoRequ
         .setRunningStatus(info.getOperationalState())
         .setIdealUsage(info.getIdealUsage())
         .addAllVolumeInfo(info.getVolumeInfo())
+        .addAllStorageTypeInfo(info.getStorageTypeInfo())
         .build();
   }
 
@@ -167,4 +168,3 @@ public void close() {
   }
 }
 
-
diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerService.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerService.java
index 7ab0243db06..1105947d5e8 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerService.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerService.java
@@ -21,9 +21,10 @@
 import static 
org.apache.hadoop.ozone.container.common.volume.StorageVolume.TMP_DIR_NAME;
 import static 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.calculateVolumeDataDensity;
 import static 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.getIdealUsage;
+import static 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.getUsableVolumesByStorageType;
 import static 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.getVolumeUsages;
+import static 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.isBalanceable;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import java.io.File;
 import java.io.IOException;
@@ -47,11 +48,14 @@
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.stream.Collectors;
 import org.apache.commons.io.FileUtils;
+import org.apache.hadoop.fs.StorageType;
+import org.apache.hadoop.hdds.client.StorageTypeUtils;
 import org.apache.hadoop.hdds.conf.ConfigurationSource;
 import org.apache.hadoop.hdds.fs.SpaceUsageSource;
 import 
org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ContainerDataProto.State;
 import org.apache.hadoop.hdds.protocol.proto.HddsProtos;
 import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.DiskBalancerRunningStatus;
+import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.StorageTypeDiskBalancerInfoProto;
 import org.apache.hadoop.hdds.protocol.proto.HddsProtos.VolumeReportProto;
 import org.apache.hadoop.hdds.scm.container.ContainerID;
 import 
org.apache.hadoop.hdds.scm.container.common.helpers.StorageContainerException;
@@ -71,6 +75,7 @@
 import org.apache.hadoop.ozone.container.common.utils.StorageVolumeUtil;
 import org.apache.hadoop.ozone.container.common.volume.HddsVolume;
 import org.apache.hadoop.ozone.container.common.volume.MutableVolumeSet;
+import 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.ThresholdRange;
 import 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.VolumeFixedUsage;
 import 
org.apache.hadoop.ozone.container.diskbalancer.policy.ContainerCandidate;
 import 
org.apache.hadoop.ozone.container.diskbalancer.policy.ContainerChoosingPolicy;
@@ -458,8 +463,11 @@ public BackgroundTaskQueue getTasks() {
 
     if (queue.isEmpty() && inProgressContainers.isEmpty()) {
       if (stopAfterDiskEven) {
-        LOG.info("Disk balancer is stopped due to disk even as" +
-            " the property StopAfterDiskEven is set to true.");
+        // Containers only move between volumes of the same StorageType, so no 
volume pair is also
+        // reported when every StorageType has fewer than two volumes, even if 
those volumes are
+        // unevenly used.
+        LOG.info("Disk balancer is stopped because no volume pair of the same 
StorageType needs" +
+            " balancing as the property StopAfterDiskEven is set to true.");
         this.operationalState = DiskBalancerRunningStatus.STOPPED;
         try {
           // Persist the updated shouldRun status into the YAML file
@@ -691,7 +699,6 @@ private void deleteContainer(Container container) {
     }
   }
 
-  @VisibleForTesting
   public void cleanupPendingDeletionContainers() {
     // delete all pending deletion containers before stop the service
     boolean ret;
@@ -717,16 +724,15 @@ private boolean tryCleanupOnePendingDeletionContainer() {
 
   public DiskBalancerInfo getDiskBalancerInfo() {
     final List<VolumeFixedUsage> volumeUsages = getVolumeUsages(volumeSet, 
deltaSizes);
-
-    // Calculate volumeDataDensity
-    final double volumeDataDensity = calculateVolumeDataDensity(volumeUsages);
-
-    long bytesToMove = 0;
-    if (this.operationalState == DiskBalancerRunningStatus.RUNNING) {
-      // this calculates live changes in bytesToMove
-      // calculate bytes to move if the balancer is in a running state, else 0.
-      bytesToMove = calculateBytesToMove(volumeUsages);
-    }
+    final List<StorageTypeDiskBalancerInfoProto> storageTypeInfo =
+        buildStorageTypeInfo(volumeUsages, threshold,
+            this.operationalState == DiskBalancerRunningStatus.RUNNING);
+    final double volumeDataDensity = storageTypeInfo.stream()
+        
.mapToDouble(StorageTypeDiskBalancerInfoProto::getCurrentVolumeDensitySum)
+        .sum();
+    final long bytesToMove = storageTypeInfo.stream()
+        .mapToLong(StorageTypeDiskBalancerInfoProto::getBytesToMove)
+        .sum();
 
     DiskBalancerInfo info = new DiskBalancerInfo(operationalState, threshold, 
bandwidthInMB,
         parallelThread, stopAfterDiskEven, version, 
formatContainerStates(movableContainerStates),
@@ -734,9 +740,43 @@ parallelThread, stopAfterDiskEven, version, 
formatContainerStates(movableContain
         metrics.getFailureCount(), bytesToMove, metrics.getSuccessBytes(), 
volumeDataDensity);
     info.setIdealUsage(getIdealUsage(volumeUsages));
     info.setVolumeInfo(buildVolumeReportProto(volumeUsages));
+    info.setStorageTypeInfo(storageTypeInfo);
     return info;
   }
 
+  static List<StorageTypeDiskBalancerInfoProto> buildStorageTypeInfo(
+      List<VolumeFixedUsage> volumeUsages, double thresholdPercentage,
+      boolean calculateBytes) {
+    final Map<StorageType, List<VolumeFixedUsage>> usagesByStorageType =
+        getUsableVolumesByStorageType(volumeUsages);
+    final List<StorageTypeDiskBalancerInfoProto> result = new ArrayList<>();
+    for (StorageType storageType : StorageType.values()) {
+      final List<VolumeFixedUsage> sameTypeUsages = 
usagesByStorageType.get(storageType);
+      if (sameTypeUsages == null) {
+        continue;
+      }
+
+      final boolean balanceable = isBalanceable(sameTypeUsages);
+      final StorageTypeDiskBalancerInfoProto.Builder builder =
+          StorageTypeDiskBalancerInfoProto.newBuilder()
+              
.setStorageType(StorageTypeUtils.getStorageTypeProto(storageType))
+              .setUsableVolumeCount(sameTypeUsages.size())
+              .setBalanceable(balanceable);
+      if (balanceable) {
+        // One snapshot per storage type, shared by the reported ideal usage 
and the byte estimate.
+        final ThresholdRange thresholdRange =
+            ThresholdRange.of(sameTypeUsages, thresholdPercentage);
+        builder.setIdealUsage(thresholdRange.getIdealUsage())
+            
.setCurrentVolumeDensitySum(calculateVolumeDataDensity(sameTypeUsages));
+        if (calculateBytes) {
+          builder.setBytesToMove(calculateBytesToMove(sameTypeUsages, 
thresholdRange));
+        }
+      }
+      result.add(builder.build());
+    }
+    return result;
+  }
+
   /**
    * Build a list of VolumeReportProto from a list of VolumeFixedUsage.
    * VolumeReportProto consists of information like StorageID,
@@ -755,6 +795,7 @@ public static List<VolumeReportProto> 
buildVolumeReportProto(List<VolumeFixedUsa
       HddsVolume volume = v.getVolume();
       VolumeReportProto.Builder builder = VolumeReportProto.newBuilder()
           .setStorageId(volume.getStorageID())
+          
.setStorageType(StorageTypeUtils.getStorageTypeProto(volume.getStorageType()))
           .setTotalCapacity(v.getUsage().getCapacity())
           .setOzoneAvailable(v.getUsage().getAvailable())
           .setUsedSpace(v.getUsage().getUsedSpace())
@@ -770,13 +811,19 @@ public static List<VolumeReportProto> 
buildVolumeReportProto(List<VolumeFixedUsa
   }
 
   public long calculateBytesToMove(List<VolumeFixedUsage> inputVolumeSet) {
-    // If there are no available volumes or only one volume, return 0 bytes to 
move
-    if (inputVolumeSet.isEmpty() || inputVolumeSet.size() < 2) {
+    if (!isBalanceable(inputVolumeSet)) {
       return 0;
     }
+    return calculateBytesToMove(inputVolumeSet, 
ThresholdRange.of(inputVolumeSet, threshold));
+  }
 
-    // Calculate actual threshold
-    final double actualThreshold = getIdealUsage(inputVolumeSet) + threshold / 
100.0;
+  /**
+   * @param thresholdRange range already computed for {@code inputVolumeSet}
+   */
+  private static long calculateBytesToMove(List<VolumeFixedUsage> 
inputVolumeSet,
+      ThresholdRange thresholdRange) {
+    // Volumes above the upper threshold are the sources that need to give up 
data.
+    final double actualThreshold = thresholdRange.getUpperThreshold();
 
     long totalBytesToMove = 0;
 
@@ -806,7 +853,6 @@ public DiskBalancerServiceMetrics getMetrics() {
     return metrics;
   }
 
-  @VisibleForTesting
   public void setBalancedBytesInLastWindow(long bytes) {
     this.balancedBytesInLastWindow.set(bytes);
   }
@@ -815,17 +861,14 @@ public ContainerChoosingPolicy 
getVolumeContainerChoosingPolicy() {
     return volumeContainerChoosingPolicy;
   }
 
-  @VisibleForTesting
   public void setVolumeContainerChoosingPolicy(ContainerChoosingPolicy 
volumeContainerChoosingPolicy) {
     this.volumeContainerChoosingPolicy = volumeContainerChoosingPolicy;
   }
 
-  @VisibleForTesting
   public Set<ContainerID> getInProgressContainers() {
     return inProgressContainers;
   }
 
-  @VisibleForTesting
   public Map<HddsVolume, Long> getDeltaSizes() {
     return deltaSizes;
   }
@@ -878,7 +921,6 @@ public void shutdown() {
     }
   }
 
-  @VisibleForTesting
   public static void setInjector(FaultInjector instance) {
     injector = instance;
   }
@@ -894,17 +936,14 @@ private static void pauseInjector() {
     }
   }
 
-  @VisibleForTesting
   public void setReplicaDeletionDelay(long durationMills) {
     this.replicaDeletionDelay = durationMills;
   }
 
-  @VisibleForTesting
   public int getPendingDeletionDeadlineCount() {
     return pendingDeletionContainers.size();
   }
 
-  @VisibleForTesting
   public int getPendingDeletionQueueSize() {
     return pendingDeletionContainers.values().stream()
         .mapToInt(Queue::size)
diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerVolumeCalculation.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerVolumeCalculation.java
index 071e43c5187..7899e40f3f3 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerVolumeCalculation.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/DiskBalancerVolumeCalculation.java
@@ -20,9 +20,11 @@
 import static org.apache.ratis.util.Preconditions.assertInstanceOf;
 import static org.apache.ratis.util.Preconditions.assertTrue;
 
+import java.util.EnumMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
+import org.apache.hadoop.fs.StorageType;
 import org.apache.hadoop.hdds.fs.SpaceUsageSource;
 import org.apache.hadoop.ozone.container.common.volume.HddsVolume;
 import org.apache.hadoop.ozone.container.common.volume.MutableVolumeSet;
@@ -59,7 +61,90 @@ public static List<VolumeFixedUsage> 
getVolumeUsages(MutableVolumeSet volumeSet,
         .map(v -> newVolumeFixedUsage(v, deltas))
         .collect(Collectors.toList());
   }
-  
+
+  /**
+   * Group positive-capacity volumes by storage type. These are the volumes 
eligible for disk
+   * balancing calculations.
+   */
+  public static Map<StorageType, List<VolumeFixedUsage>> 
getUsableVolumesByStorageType(
+      List<VolumeFixedUsage> volumes) {
+    return volumes.stream()
+        .filter(v -> v.getUsage().getCapacity() > 0)
+        .collect(Collectors.groupingBy(v -> v.getVolume().getStorageType(),
+            () -> new EnumMap<>(StorageType.class), Collectors.toList()));
+  }
+
+  /**
+   * Whether a set of volumes can be balanced against each other. A single 
volume has no second
+   * volume to move containers to, so there is nothing to balance.
+   */
+  public static boolean isBalanceable(List<VolumeFixedUsage> volumes) {
+    return volumes != null && volumes.size() >= 2;
+  }
+
+  /**
+   * The ideal usage of a set of volumes and the acceptable band around it, 
computed once from a
+   * single volume snapshot. Volumes outside the band are candidates for 
balancing.
+   */
+  public static final class ThresholdRange {
+
+    private final double idealUsage;
+    private final double lowerThreshold;
+    private final double upperThreshold;
+
+    private ThresholdRange(double idealUsage, double thresholdPercentage) {
+      final double threshold = thresholdPercentage / 100.0;
+      this.idealUsage = idealUsage;
+      this.lowerThreshold = idealUsage - threshold;
+      this.upperThreshold = idealUsage + threshold;
+    }
+
+    /**
+     * @param volumes volumes that share a storage type, so the ideal usage is 
a target that
+     *                container moves among them can actually reach
+     * @param thresholdPercentage acceptable deviation from ideal usage, in 
percent
+     */
+    public static ThresholdRange of(List<VolumeFixedUsage> volumes, double 
thresholdPercentage) {
+      return new ThresholdRange(
+          DiskBalancerVolumeCalculation.getIdealUsage(volumes), 
thresholdPercentage);
+    }
+
+    public double getIdealUsage() {
+      return idealUsage;
+    }
+
+    public double getLowerThreshold() {
+      return lowerThreshold;
+    }
+
+    public double getUpperThreshold() {
+      return upperThreshold;
+    }
+
+    /**
+     * How far the most deviant volume lies outside the band. Zero or negative 
means every volume
+     * is within the band and nothing needs to move.
+     *
+     * @param sortedVolumes volumes sorted ascending by utilization
+     */
+    public double getViolation(List<VolumeFixedUsage> sortedVolumes) {
+      final double lowestUsage = sortedVolumes.get(0).getUtilization();
+      final double highestUsage = sortedVolumes.get(sortedVolumes.size() - 
1).getUtilization();
+      return Math.max(highestUsage - upperThreshold, lowerThreshold - 
lowestUsage);
+    }
+
+    /**
+     * Whether both ends of the utilization range sit inside the band.
+     *
+     * @param sortedVolumes volumes sorted ascending by utilization
+     */
+    public boolean isWithinRange(List<VolumeFixedUsage> sortedVolumes) {
+      final double lowestUsage = sortedVolumes.get(0).getUtilization();
+      final double highestUsage = sortedVolumes.get(sortedVolumes.size() - 
1).getUtilization();
+      return highestUsage < upperThreshold && lowestUsage > lowerThreshold;
+    }
+  }
+
   /**
    * Get ideal usage from an immutable list of volumes.
    * 
diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/policy/DefaultContainerChoosingPolicy.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/policy/DefaultContainerChoosingPolicy.java
index baee04bb976..aeda1a2c080 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/policy/DefaultContainerChoosingPolicy.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/diskbalancer/policy/DefaultContainerChoosingPolicy.java
@@ -19,7 +19,8 @@
 
 import static java.util.concurrent.TimeUnit.HOURS;
 import static 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.computeUtilization;
-import static 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.getIdealUsage;
+import static 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.getUsableVolumesByStorageType;
+import static 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.isBalanceable;
 import static 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.newVolumeFixedUsage;
 
 import com.google.common.cache.Cache;
@@ -32,6 +33,7 @@
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.ReentrantLock;
 import java.util.stream.Collectors;
+import org.apache.hadoop.fs.StorageType;
 import org.apache.hadoop.hdds.fs.SpaceUsageSource;
 import 
org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ContainerDataProto.State;
 import org.apache.hadoop.hdds.scm.container.ContainerID;
@@ -40,16 +42,23 @@
 import org.apache.hadoop.ozone.container.common.volume.HddsVolume;
 import org.apache.hadoop.ozone.container.common.volume.MutableVolumeSet;
 import org.apache.hadoop.ozone.container.common.volume.StorageVolume;
+import 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.ThresholdRange;
 import 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerVolumeCalculation.VolumeFixedUsage;
 import org.apache.hadoop.ozone.container.ozoneimpl.OzoneContainer;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * First chooses a source volume and destination volume pair based on ideal 
utilization and threshold,
- * then chooses a container from the source volume that can be moved to the 
destination without
- * exceeding the upper threshold. Space is reserved on the destination only 
when a container is
- * chosen, using the actual container size.
+ * Volumes are grouped by {@link StorageType} and each group is balanced on 
its own, so a container
+ * never moves between volumes of different storage types. Within a group, 
this first chooses a
+ * source volume and destination volume pair based on ideal utilization and 
threshold, then chooses
+ * a container from the source volume that can be moved to the destination 
without exceeding the
+ * upper threshold. Space is reserved on the destination only when a container 
is chosen, using the
+ * actual container size.
+ *
+ * A storage type with fewer than two usable volumes is skipped rather than 
paired with a volume of
+ * another type, because moving a container across types would break the 
storage policy its data was
+ * placed for.
  *
  * Which container states may move is determined by {@code 
DiskBalancerConfiguration#getMovableContainerStates()}.
  */
@@ -91,62 +100,140 @@ public ContainerCandidate 
chooseVolumesAndContainer(OzoneContainer ozoneContaine
         return null;
       }
 
-      // Calculate ideal usage and threshold range (once)
-      final double idealUsage = getIdealUsage(volumeUsages);
-      final double actualThreshold = thresholdPercentage / 100.0;
-      final double lowerThreshold = idealUsage - actualThreshold;
-      final double upperThreshold = idealUsage + actualThreshold;
-
-      if (LOG.isDebugEnabled()) {
-        logVolumeBalancingState(volumeUsages, idealUsage, thresholdPercentage,
-            lowerThreshold, upperThreshold, deltaMap);
+      // Balance each storage type independently so a container never crosses 
types. Try the type
+      // with the largest threshold violation first. StorageType order is only 
a deterministic
+      // tie-breaker. Each type's threshold range is computed once from the 
snapshot above and
+      // reused for both the ordering and the balancing attempt.
+      final List<StorageTypeGroup> storageTypeGroups =
+          getUsableVolumesByStorageType(volumeUsages).entrySet().stream()
+              .filter(entry -> isGroupBalanceable(entry.getKey(), 
entry.getValue()))
+              .map(entry -> new StorageTypeGroup(entry.getKey(), 
entry.getValue(),
+                  ThresholdRange.of(entry.getValue(), thresholdPercentage)))
+              
.sorted(Comparator.comparingDouble(StorageTypeGroup::getViolation).reversed()
+                  .thenComparingInt(group -> group.getStorageType().ordinal()))
+              .collect(Collectors.toList());
+      for (StorageTypeGroup group : storageTypeGroups) {
+        final ContainerCandidate candidate = 
chooseWithinStorageType(ozoneContainer, group,
+            deltaMap, inProgressContainerIDs, thresholdPercentage, 
movableContainerStates);
+        if (candidate != null) {
+          return candidate;
+        }
       }
+      LOG.debug("Failed to find appropriate destination volume and container 
in any storage type.");
+      return null;
+    } finally {
+      lock.unlock();
+    }
+  }
+
+  private static boolean isGroupBalanceable(StorageType storageType,
+      List<VolumeFixedUsage> volumeUsages) {
+    if (isBalanceable(volumeUsages)) {
+      return true;
+    }
+    LOG.debug("Skipping storage type {} for disk balancing: only {} usable 
volume(s)",
+        storageType, volumeUsages.size());
+    return false;
+  }
 
-      // Get highest and lowest utilization volumes
-      final VolumeFixedUsage highestUsage = 
volumeUsages.get(volumeUsages.size() - 1);
-      final VolumeFixedUsage lowestUsage = volumeUsages.get(0);
+  /**
+   * Volumes of a single storage type with the threshold range they are 
measured against. The
+   * range is computed once per balancing cycle so the ordering and the 
balancing attempt agree.
+   */
+  private static final class StorageTypeGroup {
 
-      // Only return null if highest is below upper threshold AND lowest is 
above lower threshold
-      if (highestUsage.getUtilization() < upperThreshold &&
-          lowestUsage.getUtilization() > lowerThreshold) {
-        return null;
-      }
+    private final StorageType storageType;
+    private final List<VolumeFixedUsage> volumeUsages;
+    private final ThresholdRange thresholdRange;
+    private final double violation;
 
-      // Determine source volume: highest utilization volume
-      final VolumeFixedUsage srcUsage = highestUsage;
-      final HddsVolume src = srcUsage.getVolume();
-
-      // Find destination volume and container: try each dest with lower 
utilization than source
-      for (int i = 0; i < volumeUsages.size() - 1; i++) {
-        final VolumeFixedUsage dstUsage = volumeUsages.get(i);
-        final HddsVolume dst = dstUsage.getVolume();
-
-        // Check if destination has lower utilization than source and some 
usable space
-        if (dstUsage.getUtilization() < srcUsage.getUtilization() &&
-            dstUsage.computeUsableSpace() > 0) {
-          ContainerData containerData = chooseContainer(ozoneContainer,
-              src, dst, dstUsage, inProgressContainerIDs, upperThreshold, 
movableContainerStates);
-          if (containerData != null) {
-            long containerSize = containerData.getBytesUsed();
-            dst.incCommittedBytes(containerSize);
-            LOG.debug("Chosen volume pair for disk balancing: source={} 
(utilization={}), "
-                    + "destination={} (utilization={})",
-                src.getStorageDir().getPath(), srcUsage.getUtilization(),
-                dst.getStorageDir().getPath(), dstUsage.getUtilization());
-            return new ContainerCandidate(containerData, src, dst);
-          }
-          LOG.debug("No container to move for destination {}, trying next 
volume.",
-              dst.getStorageDir().getPath());
-        } else {
-          LOG.debug("Destination volume {} does not have enough space, trying 
next volume.",
-              dst.getStorageDir().getPath());
+    private StorageTypeGroup(StorageType storageType, List<VolumeFixedUsage> 
volumeUsages,
+        ThresholdRange thresholdRange) {
+      this.storageType = storageType;
+      this.volumeUsages = volumeUsages;
+      this.thresholdRange = thresholdRange;
+      this.violation = thresholdRange.getViolation(volumeUsages);
+    }
+
+    private StorageType getStorageType() {
+      return storageType;
+    }
+
+    private List<VolumeFixedUsage> getVolumeUsages() {
+      return volumeUsages;
+    }
+
+    private ThresholdRange getThresholdRange() {
+      return thresholdRange;
+    }
+
+    private double getViolation() {
+      return violation;
+    }
+  }
+
+  /**
+   * Chooses a source volume, destination volume and container among volumes 
that all share one
+   * storage type.
+   *
+   * @param group usable volumes of a single storage type, sorted ascending by 
utilization, with
+   *              the threshold range already computed for them
+   * @return a candidate, or null if this storage type has nothing to move
+   */
+  private ContainerCandidate chooseWithinStorageType(OzoneContainer 
ozoneContainer,
+      StorageTypeGroup group, Map<HddsVolume, Long> deltaMap,
+      Set<ContainerID> inProgressContainerIDs, double thresholdPercentage,
+      Set<State> movableContainerStates) {
+    final StorageType storageType = group.getStorageType();
+    final List<VolumeFixedUsage> volumeUsages = group.getVolumeUsages();
+    final ThresholdRange thresholdRange = group.getThresholdRange();
+    final double upperThreshold = thresholdRange.getUpperThreshold();
+
+    if (LOG.isDebugEnabled()) {
+      logVolumeBalancingState(volumeUsages, thresholdRange.getIdealUsage(), 
thresholdPercentage,
+          thresholdRange.getLowerThreshold(), upperThreshold, deltaMap);
+    }
+
+    // Nothing to move while every volume sits inside the threshold range.
+    if (thresholdRange.isWithinRange(volumeUsages)) {
+      return null;
+    }
+
+    final VolumeFixedUsage highestUsage = volumeUsages.get(volumeUsages.size() 
- 1);
+
+    // Determine source volume: highest utilization volume
+    final VolumeFixedUsage srcUsage = highestUsage;
+    final HddsVolume src = srcUsage.getVolume();
+
+    // Find destination volume and container: try each dest with lower 
utilization than source
+    for (int i = 0; i < volumeUsages.size() - 1; i++) {
+      final VolumeFixedUsage dstUsage = volumeUsages.get(i);
+      final HddsVolume dst = dstUsage.getVolume();
+
+      // Check if destination has lower utilization than source and some 
usable space
+      if (dstUsage.getUtilization() < srcUsage.getUtilization() &&
+          dstUsage.computeUsableSpace() > 0) {
+        ContainerData containerData = chooseContainer(ozoneContainer,
+            src, dst, dstUsage, inProgressContainerIDs, upperThreshold, 
movableContainerStates);
+        if (containerData != null) {
+          long containerSize = containerData.getBytesUsed();
+          dst.incCommittedBytes(containerSize);
+          LOG.debug("Chosen volume pair for disk balancing on storage type {}: 
source={} "
+                  + "(utilization={}), destination={} (utilization={})",
+              storageType, src.getStorageDir().getPath(), 
srcUsage.getUtilization(),
+              dst.getStorageDir().getPath(), dstUsage.getUtilization());
+          return new ContainerCandidate(containerData, src, dst);
         }
+        LOG.debug("No container to move for destination {}, trying next 
volume.",
+            dst.getStorageDir().getPath());
+      } else {
+        LOG.debug("Destination volume {} does not have enough space, trying 
next volume.",
+            dst.getStorageDir().getPath());
       }
-      LOG.debug("Failed to find appropriate destination volume and 
container.");
-      return null;
-    } finally {
-      lock.unlock();
     }
+    LOG.debug("Failed to find appropriate destination volume and container on 
storage type {}.",
+        storageType);
+    return null;
   }
 
   private static boolean hasPositiveCapacity(VolumeFixedUsage volumeUsage) {
diff --git 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDefaultContainerChoosingPolicy.java
 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDefaultContainerChoosingPolicy.java
index 2dbeab1c5bf..066c7c20ced 100644
--- 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDefaultContainerChoosingPolicy.java
+++ 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDefaultContainerChoosingPolicy.java
@@ -41,6 +41,7 @@
 import java.util.UUID;
 import java.util.concurrent.locks.ReentrantLock;
 import java.util.stream.Stream;
+import org.apache.hadoop.fs.StorageType;
 import org.apache.hadoop.hdds.conf.OzoneConfiguration;
 import org.apache.hadoop.hdds.conf.StorageUnit;
 import org.apache.hadoop.hdds.fs.MockSpaceUsageCheckFactory;
@@ -257,6 +258,11 @@ public String toString() {
 
   private HddsVolume createVolume(String name, double utilization, long 
capacity)
       throws IOException {
+    return createVolume(name, utilization, capacity, StorageType.DEFAULT);
+  }
+
+  private HddsVolume createVolume(String name, double utilization, long 
capacity,
+      StorageType storageType) throws IOException {
     long usedSpace = (long) (capacity * utilization);
     Path volumePath = baseDir.resolve(name);
 
@@ -272,6 +278,7 @@ private HddsVolume createVolume(String name, double 
utilization, long capacity)
     return new HddsVolume.Builder(volumePath.toString())
         .conf(volumeConf)
         .usageCheckFactory(factory)
+        .storageType(storageType)
         .build();
   }
 
@@ -452,6 +459,114 @@ public void testChooseVolumesSkipsZeroCapacityVolume() 
throws IOException {
     assertNull(result);
   }
 
+  /**
+   * When multiple storage types need balancing, the type with the largest 
threshold violation is
+   * selected even when another eligible type appears earlier in {@link 
StorageType#values()}.
+   * Source and destination remain within the selected storage type.
+   */
+  @Test
+  public void testChoosesMostImbalancedStorageTypeWithoutCrossingTypes() 
throws IOException {
+    HddsVolume ssdHigh = createVolume("ssd-high", 0.75, VOLUME_CAPACITY, 
StorageType.SSD);
+    HddsVolume ssdLow = createVolume("ssd-low", 0.50, VOLUME_CAPACITY, 
StorageType.SSD);
+    HddsVolume diskHigh = createVolume("disk-high", 0.60, VOLUME_CAPACITY, 
StorageType.DISK);
+    HddsVolume diskMid = createVolume("disk-mid", 0.30, VOLUME_CAPACITY, 
StorageType.DISK);
+    HddsVolume diskLow = createVolume("disk-low", 0.20, VOLUME_CAPACITY, 
StorageType.DISK);
+    volumeSet = createVolumeSetForUsages(
+        Arrays.asList(ssdHigh, ssdLow, diskHigh, diskMid, diskLow));
+
+    containerSet = newContainerSet();
+    createContainer(1L, DEFAULT_CONTAINER_SIZE, ssdHigh, containerSet);
+    createContainer(2L, DEFAULT_CONTAINER_SIZE, diskHigh, containerSet);
+    mockContainerSet(containerSet);
+
+    ContainerCandidate result = 
policy.chooseVolumesAndContainer(ozoneContainer,
+        volumeSet, deltaMap, inProgressContainerIDs, THRESHOLD, 
DEFAULT_MOVABLE_STATES);
+
+    assertNotNull(result);
+    assertEquals(diskHigh, result.getSourceVolume());
+    assertEquals(diskLow, result.getDestVolume());
+    assertEquals(StorageType.DISK, result.getSourceVolume().getStorageType());
+    assertEquals(StorageType.DISK, result.getDestVolume().getStorageType());
+  }
+
+  /**
+   * When the most imbalanced storage type has no container that can move, the 
next storage type is
+   * balanced instead of giving up for the cycle. Here DISK has the larger 
threshold violation and
+   * is tried first, but its only container is OPEN and so not movable. SSD is 
chosen next.
+   */
+  @Test
+  public void testFallsBackToNextStorageTypeWhenTopTypeHasNoMovableContainer()
+      throws IOException {
+    // DISK is the most imbalanced type, so it sorts first.
+    HddsVolume diskHigh = createVolume("disk-high", 0.75, VOLUME_CAPACITY, 
StorageType.DISK);
+    HddsVolume diskLow = createVolume("disk-low", 0.20, VOLUME_CAPACITY, 
StorageType.DISK);
+    // SSD is imbalanced too, but less so, so it sorts second.
+    HddsVolume ssdHigh = createVolume("ssd-high", 0.60, VOLUME_CAPACITY, 
StorageType.SSD);
+    HddsVolume ssdLow = createVolume("ssd-low", 0.30, VOLUME_CAPACITY, 
StorageType.SSD);
+    volumeSet = createVolumeSetForUsages(Arrays.asList(diskHigh, diskLow, 
ssdHigh, ssdLow));
+
+    containerSet = newContainerSet();
+    // The only DISK container is OPEN, which is not a movable state by 
default.
+    createContainer(1L, DEFAULT_CONTAINER_SIZE, diskHigh, containerSet,
+        ContainerDataProto.State.OPEN);
+    createContainer(2L, DEFAULT_CONTAINER_SIZE, ssdHigh, containerSet);
+    mockContainerSet(containerSet);
+
+    ContainerCandidate result = 
policy.chooseVolumesAndContainer(ozoneContainer,
+        volumeSet, deltaMap, inProgressContainerIDs, THRESHOLD, 
DEFAULT_MOVABLE_STATES);
+
+    assertNotNull(result, "DISK had nothing movable, so SSD should have been 
balanced instead");
+    assertEquals(2L, result.getContainerData().getContainerID());
+    assertEquals(ssdHigh, result.getSourceVolume());
+    assertEquals(ssdLow, result.getDestVolume());
+    assertEquals(StorageType.SSD, result.getSourceVolume().getStorageType());
+    assertEquals(StorageType.SSD, result.getDestVolume().getStorageType());
+  }
+
+  /**
+   * A StorageType with a single volume has no same-type peer, so it is 
skipped instead of being
+   * paired with a volume of another type. Here the lone SSD volume is left 
alone and the DISK pair
+   * is balanced.
+   */
+  @Test
+  public void testChooseVolumesSkipsStorageTypeWithSingleVolume() throws 
IOException {
+    HddsVolume lonelySsd = createVolume("ssd-only", 0.20, VOLUME_CAPACITY, 
StorageType.SSD);
+    HddsVolume diskHigh = createVolume("disk-high", 0.75, VOLUME_CAPACITY, 
StorageType.DISK);
+    HddsVolume diskLow = createVolume("disk-low", 0.50, VOLUME_CAPACITY, 
StorageType.DISK);
+    volumeSet = createVolumeSetForUsages(Arrays.asList(lonelySsd, diskHigh, 
diskLow));
+
+    containerSet = newContainerSet();
+    createContainer(1L, DEFAULT_CONTAINER_SIZE, diskHigh, containerSet);
+    mockContainerSet(containerSet);
+
+    ContainerCandidate result = 
policy.chooseVolumesAndContainer(ozoneContainer,
+        volumeSet, deltaMap, inProgressContainerIDs, THRESHOLD, 
DEFAULT_MOVABLE_STATES);
+
+    assertNotNull(result);
+    assertEquals(diskHigh, result.getSourceVolume());
+    assertEquals(diskLow, result.getDestVolume());
+  }
+
+  /**
+   * When every StorageType has a single volume there is no valid pair, even 
though the volumes are
+   * unevenly used. Nothing moves across types.
+   */
+  @Test
+  public void testChooseVolumesReturnsNullWhenEveryStorageTypeHasOneVolume() 
throws IOException {
+    HddsVolume ssd = createVolume("ssd-only", 0.95, VOLUME_CAPACITY, 
StorageType.SSD);
+    HddsVolume disk = createVolume("disk-only", 0.10, VOLUME_CAPACITY, 
StorageType.DISK);
+    volumeSet = createVolumeSetForUsages(Arrays.asList(ssd, disk));
+
+    containerSet = newContainerSet();
+    createContainer(1L, DEFAULT_CONTAINER_SIZE, ssd, containerSet);
+    mockContainerSet(containerSet);
+
+    ContainerCandidate result = 
policy.chooseVolumesAndContainer(ozoneContainer,
+        volumeSet, deltaMap, inProgressContainerIDs, THRESHOLD, 
DEFAULT_MOVABLE_STATES);
+
+    assertNull(result);
+  }
+
   /**
    * Generic test method that can be reused for different scenarios.
    *
diff --git 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDiskBalancerProtocolServer.java
 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDiskBalancerProtocolServer.java
index 03494eb78f9..547e2a8ba8c 100644
--- 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDiskBalancerProtocolServer.java
+++ 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDiskBalancerProtocolServer.java
@@ -35,6 +35,8 @@
 import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.DatanodeDiskBalancerInfoProto;
 import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.DiskBalancerConfigurationProto;
 import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.DiskBalancerRunningStatus;
+import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.StorageTypeDiskBalancerInfoProto;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos.StorageTypeProto;
 import org.apache.hadoop.hdds.protocol.proto.HddsProtos.VolumeReportProto;
 import 
org.apache.hadoop.ozone.container.common.statemachine.DatanodeStateMachine;
 import 
org.apache.hadoop.ozone.container.diskbalancer.DiskBalancerProtocolServer.PrivilegedOperation;
@@ -101,6 +103,14 @@ void setup() throws IOException {
         TEST_VOLUME_DENSITY
     );
     diskBalancerInfo.setIdealUsage(TEST_IDEAL_USAGE);
+    diskBalancerInfo.setStorageTypeInfo(Arrays.asList(
+        StorageTypeDiskBalancerInfoProto.newBuilder()
+            .setStorageType(StorageTypeProto.DISK)
+            .setCurrentVolumeDensitySum(TEST_VOLUME_DENSITY)
+            .setIdealUsage(TEST_IDEAL_USAGE)
+            .setUsableVolumeCount(TEST_VOLUME_INFO_COUNT)
+            .setBalanceable(true)
+            .build()));
     diskBalancerInfo.setVolumeInfo(Arrays.asList(
         VolumeReportProto.newBuilder()
             .setStorageId(TEST_STORAGE_ID_1)
@@ -154,6 +164,8 @@ void testGetDiskBalancerInfoReport() throws IOException {
     assertEquals(TEST_VOLUME_DENSITY, report.getCurrentVolumeDensitySum());
     assertEquals(TEST_IDEAL_USAGE, report.getIdealUsage());
     assertEquals(TEST_VOLUME_INFO_COUNT, report.getVolumeInfoCount());
+    assertEquals(1, report.getStorageTypeInfoCount());
+    assertEquals(StorageTypeProto.DISK, 
report.getStorageTypeInfo(0).getStorageType());
     assertEquals(TEST_STORAGE_ID_1, volReport0.getStorageId());
     assertEquals(TEST_STORAGE_PATH_1, volReport0.getStoragePath());
     assertEquals(TEST_UTILIZATION_1, volReport0.getUtilization());
@@ -325,4 +337,3 @@ void testUpdateRequiresAdmin() {
         exception.getMessage());
   }
 }
-
diff --git 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDiskBalancerVolumeCalculation.java
 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDiskBalancerVolumeCalculation.java
index 4289af7afb7..ab6b1f3ef91 100644
--- 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDiskBalancerVolumeCalculation.java
+++ 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/diskbalancer/TestDiskBalancerVolumeCalculation.java
@@ -17,20 +17,28 @@
 
 package org.apache.hadoop.ozone.container.diskbalancer;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.IOException;
 import java.nio.file.Path;
 import java.time.Duration;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
+import org.apache.hadoop.fs.StorageType;
 import org.apache.hadoop.hdds.conf.OzoneConfiguration;
 import org.apache.hadoop.hdds.fs.MockSpaceUsageCheckFactory;
 import org.apache.hadoop.hdds.fs.MockSpaceUsageSource;
 import org.apache.hadoop.hdds.fs.SpaceUsageCheckFactory;
 import org.apache.hadoop.hdds.fs.SpaceUsagePersistence;
 import org.apache.hadoop.hdds.fs.SpaceUsageSource;
+import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.StorageTypeDiskBalancerInfoProto;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos.StorageTypeProto;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos.VolumeReportProto;
 import org.apache.hadoop.ozone.container.common.volume.HddsVolume;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
@@ -49,6 +57,66 @@ void getIdealUsageReturnsZeroForEmptyVolumeList() {
         Collections.emptyList()));
   }
 
+  @Test
+  void isBalanceableRequiresAtLeastTwoVolumes() throws IOException {
+    assertFalse(DiskBalancerVolumeCalculation.isBalanceable(null));
+    
assertFalse(DiskBalancerVolumeCalculation.isBalanceable(Collections.emptyList()));
+    assertFalse(DiskBalancerVolumeCalculation.isBalanceable(
+        Collections.singletonList(usage("solo", 100, 50))));
+    assertTrue(DiskBalancerVolumeCalculation.isBalanceable(
+        Arrays.asList(usage("a", 100, 50), usage("b", 100, 50))));
+  }
+
+  /**
+   * The range is the ideal usage plus or minus the threshold, so a 10% 
threshold around a 50%
+   * ideal accepts everything between 40% and 60%.
+   */
+  @Test
+  void thresholdRangeBracketsIdealUsage() throws IOException {
+    // 20 of 100 used on one volume, 80 of 100 on the other -> ideal 50%.
+    List<DiskBalancerVolumeCalculation.VolumeFixedUsage> volumes =
+        Arrays.asList(usage("low", 100, 80), usage("high", 100, 20));
+
+    DiskBalancerVolumeCalculation.ThresholdRange range =
+        DiskBalancerVolumeCalculation.ThresholdRange.of(volumes, 10.0);
+
+    // Tolerance covers the small amount of space the volume builder reserves.
+    assertEquals(0.5, range.getIdealUsage(), 0.01);
+    assertEquals(0.4, range.getLowerThreshold(), 0.01);
+    assertEquals(0.6, range.getUpperThreshold(), 0.01);
+  }
+
+  /**
+   * Violation measures how far the worst volume sits outside the band, and is 
not positive while
+   * every volume is inside it.
+   */
+  @Test
+  void thresholdRangeReportsViolationOutsideBand() throws IOException {
+    // 20% and 80% against a 50% ideal with a 10% threshold -> 20 points past 
the upper bound.
+    List<DiskBalancerVolumeCalculation.VolumeFixedUsage> spread =
+        Arrays.asList(usage("low", 100, 80), usage("high", 100, 20));
+    DiskBalancerVolumeCalculation.ThresholdRange wideRange =
+        DiskBalancerVolumeCalculation.ThresholdRange.of(spread, 10.0);
+
+    assertEquals(0.2, wideRange.getViolation(spread), 0.01);
+    assertFalse(wideRange.isWithinRange(spread));
+
+    // Both volumes at 50% -> nothing outside the band.
+    List<DiskBalancerVolumeCalculation.VolumeFixedUsage> even =
+        Arrays.asList(usage("a", 100, 50), usage("b", 100, 50));
+    DiskBalancerVolumeCalculation.ThresholdRange evenRange =
+        DiskBalancerVolumeCalculation.ThresholdRange.of(even, 10.0);
+
+    assertThat(evenRange.getViolation(even)).isLessThanOrEqualTo(0.0);
+    assertTrue(evenRange.isWithinRange(even));
+  }
+
+  private DiskBalancerVolumeCalculation.VolumeFixedUsage usage(String name, 
long capacity,
+      long available) throws IOException {
+    return DiskBalancerVolumeCalculation.newVolumeFixedUsage(
+        createVolume(name, capacity, available), null);
+  }
+
   @Test
   void getIdealUsageReturnsZeroForZeroTotalCapacity() throws IOException {
     HddsVolume zeroCapacityVolume = createVolume("zero-capacity", 0, 0);
@@ -93,14 +161,53 @@ void getUtilizationReturnsZeroForZeroCapacityVolume()
   }
 
   @Test
-  void buildVolumeReportProtoReportsZeroUtilizationForZeroCapacityVolume()
+  void buildVolumeReportProtoIncludesStorageTypeForZeroCapacityVolume()
       throws IOException {
-    HddsVolume volume = createVolume("zero-capacity-report", 0, 0);
+    HddsVolume volume = createVolume("zero-capacity-report", 0, 0, 
StorageType.SSD);
 
-    assertEquals(0.0, DiskBalancerService.buildVolumeReportProto(
+    VolumeReportProto report = DiskBalancerService.buildVolumeReportProto(
         Collections.singletonList(
-            DiskBalancerVolumeCalculation.newVolumeFixedUsage(volume, null)))
-        .get(0).getUtilization());
+            DiskBalancerVolumeCalculation.newVolumeFixedUsage(volume, 
null))).get(0);
+
+    assertEquals(StorageTypeProto.SSD, report.getStorageType());
+    assertEquals(0.0, report.getUtilization());
+  }
+
+  @Test
+  void buildStorageTypeInfoCalculatesEachTypeIndependently() throws 
IOException {
+    List<DiskBalancerVolumeCalculation.VolumeFixedUsage> volumeUsages = 
Arrays.asList(
+        DiskBalancerVolumeCalculation.newVolumeFixedUsage(
+            createVolume("ssd-1", 100, 20, StorageType.SSD), null),
+        DiskBalancerVolumeCalculation.newVolumeFixedUsage(
+            createVolume("ssd-2", 100, 20, StorageType.SSD), null),
+        DiskBalancerVolumeCalculation.newVolumeFixedUsage(
+            createVolume("disk-1", 100, 80, StorageType.DISK), null),
+        DiskBalancerVolumeCalculation.newVolumeFixedUsage(
+            createVolume("disk-2", 100, 80, StorageType.DISK), null));
+
+    List<StorageTypeDiskBalancerInfoProto> result =
+        DiskBalancerService.buildStorageTypeInfo(volumeUsages, 10.0, true);
+
+    assertEquals(2, result.size());
+    assertStorageTypeInfo(result.get(0), StorageTypeProto.SSD, 0.8);
+    assertStorageTypeInfo(result.get(1), StorageTypeProto.DISK, 0.2);
+  }
+
+  @Test
+  void buildStorageTypeInfoMarksSingleVolumeTypeNotBalanceable() throws 
IOException {
+    List<DiskBalancerVolumeCalculation.VolumeFixedUsage> volumeUsages = 
Collections.singletonList(
+        DiskBalancerVolumeCalculation.newVolumeFixedUsage(
+            createVolume("ssd", 100, 5, StorageType.SSD), null));
+
+    StorageTypeDiskBalancerInfoProto result =
+        DiskBalancerService.buildStorageTypeInfo(volumeUsages, 10.0, 
true).get(0);
+
+    assertEquals(StorageTypeProto.SSD, result.getStorageType());
+    assertEquals(1, result.getUsableVolumeCount());
+    assertFalse(result.getBalanceable());
+    assertFalse(result.hasIdealUsage());
+    assertEquals(0, result.getBytesToMove());
+    assertEquals(0.0, result.getCurrentVolumeDensitySum());
   }
 
   @Test
@@ -155,6 +262,11 @@ void getIdealUsageRejectsEffectiveUsedGreaterThanCapacity()
 
   private HddsVolume createVolume(String name, long capacity, long available)
       throws IOException {
+    return createVolume(name, capacity, available, StorageType.DEFAULT);
+  }
+
+  private HddsVolume createVolume(String name, long capacity, long available,
+      StorageType storageType) throws IOException {
     OzoneConfiguration conf = new OzoneConfiguration();
     SpaceUsageSource source = MockSpaceUsageSource.fixed(capacity, available);
     SpaceUsageCheckFactory factory = MockSpaceUsageCheckFactory.of(
@@ -163,6 +275,17 @@ private HddsVolume createVolume(String name, long 
capacity, long available)
     return new HddsVolume.Builder(tempDir.resolve(name).toString())
         .conf(conf)
         .usageCheckFactory(factory)
+        .storageType(storageType)
         .build();
   }
+
+  private static void assertStorageTypeInfo(StorageTypeDiskBalancerInfoProto 
info,
+      StorageTypeProto expectedType, double expectedIdealUsage) {
+    assertEquals(expectedType, info.getStorageType());
+    assertEquals(2, info.getUsableVolumeCount());
+    assertTrue(info.getBalanceable());
+    assertEquals(expectedIdealUsage, info.getIdealUsage(), 0.01);
+    assertEquals(0, info.getBytesToMove());
+    assertEquals(0.0, info.getCurrentVolumeDensitySum());
+  }
 }
diff --git a/hadoop-hdds/interface-client/src/main/proto/hdds.proto 
b/hadoop-hdds/interface-client/src/main/proto/hdds.proto
index 920ebd38e3f..f1fe629d480 100644
--- a/hadoop-hdds/interface-client/src/main/proto/hdds.proto
+++ b/hadoop-hdds/interface-client/src/main/proto/hdds.proto
@@ -610,6 +610,16 @@ message VolumeReportProto {
     optional uint64 effectiveUsedSpace = 6;
     optional double utilization = 7;
     optional uint64 ozoneAvailable = 8;
+    optional StorageTypeProto storageType = 9;
+}
+
+message StorageTypeDiskBalancerInfoProto {
+    optional StorageTypeProto storageType = 1;
+    optional double currentVolumeDensitySum = 2;
+    optional uint64 bytesToMove = 3;
+    optional double idealUsage = 4;
+    optional uint32 usableVolumeCount = 5;
+    optional bool balanceable = 6;
 }
 
 message DatanodeDiskBalancerInfoProto {
@@ -623,4 +633,5 @@ message DatanodeDiskBalancerInfoProto {
     optional uint64 bytesMoved = 8;
     optional double idealUsage = 9;
     repeated VolumeReportProto volumeInfo = 10;
+    repeated StorageTypeDiskBalancerInfoProto storageTypeInfo = 11;
 }
diff --git a/hadoop-hdds/interface-client/src/main/resources/proto.lock 
b/hadoop-hdds/interface-client/src/main/resources/proto.lock
index 005b6ca7371..e7d6b6d8618 100644
--- a/hadoop-hdds/interface-client/src/main/resources/proto.lock
+++ b/hadoop-hdds/interface-client/src/main/resources/proto.lock
@@ -4625,6 +4625,53 @@
                 "name": "ozoneAvailable",
                 "type": "uint64",
                 "optional": true
+              },
+              {
+                "id": 9,
+                "name": "storageType",
+                "type": "StorageTypeProto",
+                "optional": true
+              }
+            ]
+          },
+          {
+            "name": "StorageTypeDiskBalancerInfoProto",
+            "fields": [
+              {
+                "id": 1,
+                "name": "storageType",
+                "type": "StorageTypeProto",
+                "optional": true
+              },
+              {
+                "id": 2,
+                "name": "currentVolumeDensitySum",
+                "type": "double",
+                "optional": true
+              },
+              {
+                "id": 3,
+                "name": "bytesToMove",
+                "type": "uint64",
+                "optional": true
+              },
+              {
+                "id": 4,
+                "name": "idealUsage",
+                "type": "double",
+                "optional": true
+              },
+              {
+                "id": 5,
+                "name": "usableVolumeCount",
+                "type": "uint32",
+                "optional": true
+              },
+              {
+                "id": 6,
+                "name": "balanceable",
+                "type": "bool",
+                "optional": true
               }
             ]
           },
@@ -4690,6 +4737,12 @@
                 "name": "volumeInfo",
                 "type": "VolumeReportProto",
                 "is_repeated": true
+              },
+              {
+                "id": 11,
+                "name": "storageTypeInfo",
+                "type": "StorageTypeDiskBalancerInfoProto",
+                "is_repeated": true
               }
             ]
           }
@@ -4718,4 +4771,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git 
a/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/datanode/DiskBalancerReportSubcommand.java
 
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/datanode/DiskBalancerReportSubcommand.java
index 023d42758bb..d07efe520f8 100644
--- 
a/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/datanode/DiskBalancerReportSubcommand.java
+++ 
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/datanode/DiskBalancerReportSubcommand.java
@@ -30,6 +30,8 @@
 import org.apache.hadoop.hdds.cli.HddsVersionProvider;
 import org.apache.hadoop.hdds.protocol.DiskBalancerProtocol;
 import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.DatanodeDiskBalancerInfoProto;
+import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.StorageTypeDiskBalancerInfoProto;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos.StorageTypeProto;
 import org.apache.hadoop.hdds.protocol.proto.HddsProtos.VolumeReportProto;
 import org.apache.hadoop.util.StringUtils;
 import picocli.CommandLine.Command;
@@ -123,7 +125,10 @@ private String generateReport(
           .append(formatPercent(p.getCurrentVolumeDensitySum()))
           .append(System.lineSeparator());
 
-      if (p.hasIdealUsage() && p.hasDiskBalancerConf()
+      if (p.getStorageTypeInfoCount() > 0 && p.hasDiskBalancerConf()
+          && p.getDiskBalancerConf().hasThreshold()) {
+        appendStorageTypeDetails(header, p);
+      } else if (p.hasIdealUsage() && p.hasDiskBalancerConf()
           && p.getDiskBalancerConf().hasThreshold()) {
         double idealUsage = p.getIdealUsage();
         double threshold = p.getDiskBalancerConf().getThreshold();
@@ -141,8 +146,9 @@ private String generateReport(
       formatBuilder.append("%s%n");
       contentList.add(header.toString());
 
-      if (p.getVolumeInfoCount() > 0 && p.hasIdealUsage()) {
-        formatBuilder.append("%-45s %-40s %15s %15s %15s %30s %20s %15s 
%15s%n");
+      if (p.getVolumeInfoCount() > 0 && (p.hasIdealUsage() || 
p.getStorageTypeInfoCount() > 0)) {
+        formatBuilder.append("%-12s %-45s %-40s %15s %15s %15s %30s %20s %15s 
%15s%n");
+        contentList.add("StorageType");
         contentList.add("StorageID");
         contentList.add("StoragePath");
         contentList.add("OzoneCapacity");
@@ -153,9 +159,11 @@ private String generateReport(
         contentList.add("Utilization");
         contentList.add("VolumeDensity");
 
-        double ideal = p.getIdealUsage();
+        Map<StorageTypeProto, StorageTypeDiskBalancerInfoProto> 
storageTypeInfo =
+            getStorageTypeInfo(p);
         for (VolumeReportProto v : p.getVolumeInfoList()) {
-          formatBuilder.append("%-45s %-40s %15s %15s %15s %30s %20s %15s 
%15s%n");
+          formatBuilder.append("%-12s %-45s %-40s %15s %15s %15s %30s %20s 
%15s %15s%n");
+          contentList.add(v.hasStorageType() ? v.getStorageType().name() : 
"-");
           contentList.add(v.hasStorageId() ? v.getStorageId() : "-");
           contentList.add(v.hasStoragePath() ? v.getStoragePath() : "-");
           contentList.add(v.hasTotalCapacity() ? 
StringUtils.byteDesc(v.getTotalCapacity()) : "-");
@@ -164,7 +172,7 @@ private String generateReport(
           contentList.add(StringUtils.byteDesc(v.getCommittedBytes()));
           contentList.add(v.hasEffectiveUsedSpace() ? 
StringUtils.byteDesc(v.getEffectiveUsedSpace()) : "-");
           contentList.add(formatPercent(v.getUtilization()));
-          contentList.add(formatPercent(Math.abs(v.getUtilization() - ideal)));
+          contentList.add(formatVolumeDensity(p, v, storageTypeInfo));
         }
         formatBuilder.append("%n");
       }
@@ -175,7 +183,7 @@ private String generateReport(
     }
 
     formatBuilder.append("%nNote:%n")
-        .append("  - Aggregate VolumeDataDensity: Sum of per-volume density 
(deviation from ideal);")
+        .append("  - Aggregate VolumeDataDensity: Sum of per-volume density 
from each storage type's ideal;")
         .append(" higher means more imbalance.%n")
         .append("  - IdealUsage: Target utilization (0-100%%) when volumes are 
evenly balanced.%n")
         .append("  - ThresholdRange: Acceptable deviation (percent); volumes 
within")
@@ -196,6 +204,53 @@ private String generateReport(
     return String.format(formatBuilder.toString(), contentList.toArray(new 
Object[0]));
   }
 
+  private static void appendStorageTypeDetails(StringBuilder header,
+      DatanodeDiskBalancerInfoProto report) {
+    double threshold = report.getDiskBalancerConf().getThreshold();
+    header.append("Storage Type Details:").append(System.lineSeparator());
+    for (StorageTypeDiskBalancerInfoProto info : 
report.getStorageTypeInfoList()) {
+      header.append("  ").append(info.getStorageType()).append(": ");
+      if (!info.getBalanceable() || !info.hasIdealUsage()) {
+        header.append("not balanceable (").append(info.getUsableVolumeCount())
+            .append(" usable volume(s))").append(System.lineSeparator());
+        continue;
+      }
+      double idealUsage = info.getIdealUsage();
+      double lowerThreshold = Math.max(0.0, idealUsage - threshold / 100.0);
+      double upperThreshold = Math.min(1.0, idealUsage + threshold / 100.0);
+      header.append("IdealUsage: ").append(formatPercent(idealUsage))
+          .append(" | ThresholdRange: (").append(formatPercent(lowerThreshold))
+          .append(", ").append(formatPercent(upperThreshold)).append(')')
+          .append(" | VolumeDataDensity: ")
+          .append(formatPercent(info.getCurrentVolumeDensitySum()))
+          .append(" | EstBytesToMove: 
").append(StringUtils.byteDesc(info.getBytesToMove()))
+          .append(System.lineSeparator());
+    }
+    header.append(System.lineSeparator()).append("Volume 
Details:").append(System.lineSeparator());
+  }
+
+  private static Map<StorageTypeProto, StorageTypeDiskBalancerInfoProto> 
getStorageTypeInfo(
+      DatanodeDiskBalancerInfoProto report) {
+    Map<StorageTypeProto, StorageTypeDiskBalancerInfoProto> result = new 
LinkedHashMap<>();
+    for (StorageTypeDiskBalancerInfoProto info : 
report.getStorageTypeInfoList()) {
+      result.put(info.getStorageType(), info);
+    }
+    return result;
+  }
+
+  private static String formatVolumeDensity(DatanodeDiskBalancerInfoProto 
report,
+      VolumeReportProto volume,
+      Map<StorageTypeProto, StorageTypeDiskBalancerInfoProto> storageTypeInfo) 
{
+    if (volume.hasStorageType()) {
+      StorageTypeDiskBalancerInfoProto info = 
storageTypeInfo.get(volume.getStorageType());
+      if (info != null && info.hasIdealUsage()) {
+        return formatPercent(Math.abs(volume.getUtilization() - 
info.getIdealUsage()));
+      }
+    }
+    return report.hasIdealUsage()
+        ? formatPercent(Math.abs(volume.getUtilization() - 
report.getIdealUsage())) : "-";
+  }
+
   @Override
   protected String getActionName() {
     return "report";
@@ -218,7 +273,35 @@ private Map<String, Object> toJson(String hostName, 
DatanodeDiskBalancerInfoProt
     result.put("status", "success");
     result.put("volumeDensity", 
formatPercent(report.getCurrentVolumeDensitySum()));
 
-    if (report.hasIdealUsage() && report.hasDiskBalancerConf()
+    Map<StorageTypeProto, StorageTypeDiskBalancerInfoProto> storageTypeInfo =
+        getStorageTypeInfo(report);
+    // Report ideal usage per storage type when the datanode sends it. The 
node-level
+    // idealUsage averages across storage types, which is not a target any 
move can reach
+    // on a datanode with more than one type, so it is only reported as a 
fallback for
+    // datanodes that predate the per-storage-type fields.
+    if (!storageTypeInfo.isEmpty()) {
+      double threshold = report.getDiskBalancerConf().getThreshold();
+      List<Map<String, Object>> storageTypes = new ArrayList<>();
+      for (StorageTypeDiskBalancerInfoProto info : 
report.getStorageTypeInfoList()) {
+        Map<String, Object> storageType = new LinkedHashMap<>();
+        storageType.put("storageType", info.getStorageType().name());
+        storageType.put("balanceable", info.getBalanceable());
+        storageType.put("usableVolumeCount", info.getUsableVolumeCount());
+        storageType.put("volumeDensity", 
formatPercent(info.getCurrentVolumeDensitySum()));
+        storageType.put("estBytesToMove", 
StringUtils.byteDesc(info.getBytesToMove()));
+        if (info.hasIdealUsage()) {
+          double idealUsage = info.getIdealUsage();
+          double lowerThreshold = Math.max(0.0, idealUsage - threshold / 
100.0);
+          double upperThreshold = Math.min(1.0, idealUsage + threshold / 
100.0);
+          storageType.put("idealUsage", formatPercent(idealUsage));
+          storageType.put("thresholdRange", String.format("(%s, %s)",
+              formatPercent(lowerThreshold), formatPercent(upperThreshold)));
+        }
+        storageTypes.add(storageType);
+      }
+      result.put("storageTypes", storageTypes);
+      result.put("threshold %", String.format(Locale.ROOT, PERCENT_FORMAT, 
threshold));
+    } else if (report.hasIdealUsage() && report.hasDiskBalancerConf()
         && report.getDiskBalancerConf().hasThreshold()) {
       double idealUsage = report.getIdealUsage();
       double threshold = report.getDiskBalancerConf().getThreshold();
@@ -231,10 +314,10 @@ private Map<String, Object> toJson(String hostName, 
DatanodeDiskBalancerInfoProt
     }
 
     if (report.getVolumeInfoCount() > 0) {
-      double ideal = report.hasIdealUsage() ? report.getIdealUsage() : 0.0;
       List<Map<String, Object>> vols = new ArrayList<>();
       for (VolumeReportProto v : report.getVolumeInfoList()) {
         Map<String, Object> vm = new LinkedHashMap<>();
+        vm.put("storageType", v.hasStorageType() ? v.getStorageType().name() : 
"-");
         vm.put("storageId", v.getStorageId());
         vm.put("storagePath", v.hasStoragePath() ? v.getStoragePath() : "-");
         vm.put("ozoneCapacity", v.hasTotalCapacity() ? 
StringUtils.byteDesc(v.getTotalCapacity()) : "-");
@@ -244,7 +327,7 @@ private Map<String, Object> toJson(String hostName, 
DatanodeDiskBalancerInfoProt
         vm.put("effectiveUsedSpace", v.hasEffectiveUsedSpace() ?
             StringUtils.byteDesc(v.getEffectiveUsedSpace()) : "-");
         vm.put("utilization", formatPercent(v.getUtilization()));
-        vm.put("volumeDensity", formatPercent(Math.abs(v.getUtilization() - 
ideal)));
+        vm.put("volumeDensity", formatVolumeDensity(report, v, 
storageTypeInfo));
         vols.add(vm);
       }
 
diff --git 
a/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/datanode/DiskBalancerStatusSubcommand.java
 
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/datanode/DiskBalancerStatusSubcommand.java
index 8e1dacd7611..1458c8f87a8 100644
--- 
a/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/datanode/DiskBalancerStatusSubcommand.java
+++ 
b/hadoop-ozone/cli-admin/src/main/java/org/apache/hadoop/hdds/scm/cli/datanode/DiskBalancerStatusSubcommand.java
@@ -17,6 +17,7 @@
 
 package org.apache.hadoop.hdds.scm.cli.datanode;
 
+import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 
 import java.io.IOException;
@@ -28,6 +29,7 @@
 import org.apache.hadoop.hdds.protocol.DiskBalancerProtocol;
 import org.apache.hadoop.hdds.protocol.proto.HddsProtos;
 import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.DatanodeDiskBalancerInfoProto;
+import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.StorageTypeDiskBalancerInfoProto;
 import picocli.CommandLine.Command;
 
 /**
@@ -102,7 +104,7 @@ protected void displayResults(List<String> successNodes, 
List<String> failedNode
   private String generateStatus(
       List<DatanodeDiskBalancerInfoProto> protos, List<String> 
datanodeDisplayNames) {
     StringBuilder formatBuilder = new StringBuilder("Status result:%n" +
-        "%-60s %-10s %-15s %-15s %-10s %-18s %-30s %-12s %-12s %-15s %-18s 
%-20s%n");
+        "%-60s %-10s %-15s %-15s %-10s %-18s %-30s %-12s %-12s %-15s %-18s 
%-40s %-20s%n");
 
     List<String> contentList = new ArrayList<>();
     contentList.add("Datanode");
@@ -116,11 +118,13 @@ private String generateStatus(
     contentList.add("FailureMove");
     contentList.add("BytesMoved(MB)");
     contentList.add("EstBytesToMove(MB)");
+    contentList.add("StorageTypeEstBytesToMove(MB)");
     contentList.add("EstTimeLeft(min)");
 
     for (int i = 0; i < protos.size(); i++) {
       HddsProtos.DatanodeDiskBalancerInfoProto proto = protos.get(i);
-      formatBuilder.append("%-60s %-10s %-15s %-15s %-10s %-18s %-30s %-12s 
%-12s %-15s %-18s %-20s%n");
+      formatBuilder.append(
+          "%-60s %-10s %-15s %-15s %-10s %-18s %-30s %-12s %-12s %-15s %-18s 
%-40s %-20s%n");
       long estimatedTimeLeft = calculateEstimatedTimeLeft(proto);
       long bytesMovedMB = (long) Math.ceil(proto.getBytesMoved() / (1024.0 * 
1024.0));
       long bytesToMoveMB = (long) Math.ceil(proto.getBytesToMove() / (1024.0 * 
1024.0));
@@ -142,6 +146,7 @@ private String generateStatus(
       contentList.add(String.valueOf(proto.getFailureMoveCount()));
       contentList.add(String.valueOf(bytesMovedMB));
       contentList.add(String.valueOf(bytesToMoveMB));
+      contentList.add(formatStorageTypeBytesToMove(proto));
       contentList.add(estimatedTimeLeft >= 0 ? 
String.valueOf(estimatedTimeLeft) : "N/A");
     }
 
@@ -185,11 +190,41 @@ private Map<String, Object> createStatusResult(
     result.put("failureMove", status.getFailureMoveCount());
     result.put("bytesMovedMB", (long) Math.ceil(status.getBytesMoved() / 
(1024.0 * 1024.0)));
     result.put("estBytesToMoveMB", (long) Math.ceil(status.getBytesToMove() / 
(1024.0 * 1024.0)));
+    if (status.getStorageTypeInfoCount() > 0) {
+      result.put("storageTypes", createStorageTypeResults(status));
+    }
     long estimatedTimeLeft = calculateEstimatedTimeLeft(status);
     result.put("estTimeLeftMin", estimatedTimeLeft >= 0 ? estimatedTimeLeft : 
null);
     return result;
   }
 
+  private static String 
formatStorageTypeBytesToMove(DatanodeDiskBalancerInfoProto status) {
+    if (status.getStorageTypeInfoCount() == 0) {
+      return "-";
+    }
+    return status.getStorageTypeInfoList().stream()
+        .map(info -> info.getBalanceable()
+            ? String.format("%s=%d", info.getStorageType(),
+                (long) Math.ceil(info.getBytesToMove() / (1024.0 * 1024.0)))
+            : info.getStorageType() + "=N/A")
+        .collect(joining(", "));
+  }
+
+  private static List<Map<String, Object>> createStorageTypeResults(
+      DatanodeDiskBalancerInfoProto status) {
+    List<Map<String, Object>> storageTypes = new ArrayList<>();
+    for (StorageTypeDiskBalancerInfoProto info : 
status.getStorageTypeInfoList()) {
+      Map<String, Object> storageType = new LinkedHashMap<>();
+      storageType.put("storageType", info.getStorageType().name());
+      storageType.put("balanceable", info.getBalanceable());
+      storageType.put("usableVolumeCount", info.getUsableVolumeCount());
+      storageType.put("estBytesToMoveMB",
+          (long) Math.ceil(info.getBytesToMove() / (1024.0 * 1024.0)));
+      storageTypes.add(storageType);
+    }
+    return storageTypes;
+  }
+
   private long calculateEstimatedTimeLeft(DatanodeDiskBalancerInfoProto proto) 
{
     long bytesToMove = proto.getBytesToMove();
 
diff --git 
a/hadoop-ozone/cli-admin/src/test/java/org/apache/hadoop/hdds/scm/cli/datanode/TestDiskBalancerSubCommands.java
 
b/hadoop-ozone/cli-admin/src/test/java/org/apache/hadoop/hdds/scm/cli/datanode/TestDiskBalancerSubCommands.java
index 1b5ef945ed1..60666684907 100644
--- 
a/hadoop-ozone/cli-admin/src/test/java/org/apache/hadoop/hdds/scm/cli/datanode/TestDiskBalancerSubCommands.java
+++ 
b/hadoop-ozone/cli-admin/src/test/java/org/apache/hadoop/hdds/scm/cli/datanode/TestDiskBalancerSubCommands.java
@@ -53,6 +53,8 @@
 import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.DatanodeDiskBalancerInfoProto;
 import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.DiskBalancerConfigurationProto;
 import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.DiskBalancerRunningStatus;
+import 
org.apache.hadoop.hdds.protocol.proto.HddsProtos.StorageTypeDiskBalancerInfoProto;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos.StorageTypeProto;
 import org.apache.hadoop.hdds.protocol.proto.HddsProtos.VolumeReportProto;
 import org.apache.hadoop.hdds.scm.cli.ContainerOperationClient;
 import org.junit.jupiter.api.AfterEach;
@@ -455,6 +457,8 @@ public void testStatusDiskBalancerWithInServiceDatanodes() 
throws Exception {
 
       String output = outContent.toString(DEFAULT_ENCODING);
       assertTrue(output.contains("Status result"));
+      assertTrue(output.contains("StorageTypeEstBytesToMove(MB)"));
+      assertTrue(output.contains("DISK="));
       assertTrue(output.contains("host-1"));
       assertTrue(output.contains("host-2"));
       assertTrue(output.contains("host-3"));
@@ -483,6 +487,8 @@ public void testStatusDiskBalancerWithJson() throws 
Exception {
       assertTrue(output.contains("\"bandwidthInMB\""));
       assertTrue(output.contains("\"threads\""));
       assertTrue(output.contains("\"stopAfterDiskEven\""));
+      assertTrue(output.contains("\"storageTypes\""));
+      assertTrue(output.contains("\"storageType\""));
     }
   }
 
@@ -885,6 +891,33 @@ public void testReportThresholdRangeClamped(double 
idealUsage,
     }
   }
 
+  /**
+   * On a datanode with more than one storage type, the JSON report must carry 
the per-type ideal
+   * usages and not the node-level average, which is not a target any 
container move can reach.
+   */
+  @Test
+  public void testReportJsonOmitsCrossStorageTypeIdealUsage() throws Exception 
{
+    DiskBalancerReportSubcommand cmd = new DiskBalancerReportSubcommand();
+
+    when(mockProtocol.getDiskBalancerInfo())
+        .thenReturn(createMixedStorageTypeReportProto("host-1"));
+
+    try (DiskBalancerMocks mocks = setupAllMocks()) {
+      CommandLine c = new CommandLine(cmd);
+      c.parseArgs("--json", "host-1");
+      cmd.call();
+
+      String output = outContent.toString(DEFAULT_ENCODING);
+      // Per-type ideal usages are reported.
+      assertThat(output).contains("\"storageTypes\"");
+      assertThat(output).contains("20.00%");
+      assertThat(output).contains("80.00%");
+      // The node-level average across storage types is not.
+      assertThat(output).doesNotContain("50.00%");
+      assertThat(output).doesNotContain("\"thresholdRange\" : \"(40.00%, 
60.00%)\"");
+    }
+  }
+
   @Test
   public void testReportDiskBalancerWithInServiceDatanodes() throws Exception {
     DiskBalancerReportSubcommand cmd = new DiskBalancerReportSubcommand();
@@ -905,6 +938,8 @@ public void testReportDiskBalancerWithInServiceDatanodes() 
throws Exception {
 
       String output = outContent.toString(DEFAULT_ENCODING);
       assertTrue(output.contains("Report result"));
+      assertTrue(output.contains("Storage Type Details:"));
+      assertTrue(output.contains("StorageType"));
       assertTrue(output.contains("host-1"));
       assertTrue(output.contains("host-2"));
       assertTrue(output.contains("host-3"));
@@ -930,7 +965,9 @@ public void testReportDiskBalancerWithJson() throws 
Exception {
       assertTrue(output.contains("\"datanode\""));
       assertTrue(output.contains("\"volumeDensity\""));
       assertTrue(output.contains("\"idealUsage\""));
+      assertTrue(output.contains("\"storageTypes\""));
       assertTrue(output.contains("\"volumes\""));
+      assertTrue(output.contains("\"storageType\""));
       assertTrue(output.contains("\"storageId\""));
       assertTrue(output.contains("\"storagePath\""));
       assertTrue(output.contains("\"ozoneCapacity\""));
@@ -1061,6 +1098,12 @@ private DatanodeDiskBalancerInfoProto 
createStatusProto(String hostname,
         .setFailureMoveCount(failureMove)
         .setBytesMoved(bytesMoved)
         .setBytesToMove(bytesToMove)
+        .addStorageTypeInfo(StorageTypeDiskBalancerInfoProto.newBuilder()
+            .setStorageType(StorageTypeProto.DISK)
+            .setBalanceable(true)
+            .setUsableVolumeCount(2)
+            .setBytesToMove(bytesToMove)
+            .build())
         .build();
   }
 
@@ -1120,6 +1163,7 @@ private DatanodeDiskBalancerInfoProto 
generateRandomReportProto(String hostname)
     String path1 = "/data/hdds-" + hostname + "-1";
     String path2 = "/data/hdds-" + hostname + "-2";
     VolumeReportProto vol1 = VolumeReportProto.newBuilder()
+        .setStorageType(StorageTypeProto.DISK)
         .setStorageId("DISK-" + hostname + "-vol1")
         .setStoragePath(path1)
         .setUtilization(util1)
@@ -1130,6 +1174,7 @@ private DatanodeDiskBalancerInfoProto 
generateRandomReportProto(String hostname)
         .setEffectiveUsedSpace(effective1)
         .build();
     VolumeReportProto vol2 = VolumeReportProto.newBuilder()
+        .setStorageType(StorageTypeProto.DISK)
         .setStorageId("DISK-" + hostname + "-vol2")
         .setStoragePath(path2)
         .setUtilization(util2)
@@ -1145,6 +1190,13 @@ private DatanodeDiskBalancerInfoProto 
generateRandomReportProto(String hostname)
         .setCurrentVolumeDensitySum(volumeDensity)
         .setIdealUsage(idealUsage)
         .setDiskBalancerConf(configProto)
+        .addStorageTypeInfo(StorageTypeDiskBalancerInfoProto.newBuilder()
+            .setStorageType(StorageTypeProto.DISK)
+            .setCurrentVolumeDensitySum(volumeDensity)
+            .setIdealUsage(idealUsage)
+            .setUsableVolumeCount(2)
+            .setBalanceable(true)
+            .build())
         .addVolumeInfo(vol1)
         .addVolumeInfo(vol2)
         .build();
@@ -1169,6 +1221,42 @@ private DatanodeDiskBalancerInfoProto 
createReportProto(String hostname, double
         .build();
   }
 
+  /**
+   * A datanode with SSD volumes at 20% and DISK volumes at 80%. Each storage 
type is balanced
+   * within itself, but the node-level idealUsage averages to 50%, which no 
move can reach.
+   */
+  private DatanodeDiskBalancerInfoProto 
createMixedStorageTypeReportProto(String hostname) {
+    DatanodeDetailsProto nodeProto = DatanodeDetailsProto.newBuilder()
+        .setHostName(hostname)
+        .setIpAddress("127.0.0.1")
+        .addPorts(HddsProtos.Port.newBuilder()
+            .setName("CLIENT_RPC")
+            .setValue(HDDS_DATANODE_CLIENT_PORT_DEFAULT)
+            .build())
+        .build();
+
+    return DatanodeDiskBalancerInfoProto.newBuilder()
+        .setNode(nodeProto)
+        .setCurrentVolumeDensitySum(0.0)
+        .setIdealUsage(0.5)
+        .setDiskBalancerConf(createConfigProto(10.0, 100L, 5, true))
+        .addStorageTypeInfo(StorageTypeDiskBalancerInfoProto.newBuilder()
+            .setStorageType(StorageTypeProto.SSD)
+            .setUsableVolumeCount(2)
+            .setBalanceable(true)
+            .setIdealUsage(0.2)
+            .setCurrentVolumeDensitySum(0.0)
+            .build())
+        .addStorageTypeInfo(StorageTypeDiskBalancerInfoProto.newBuilder()
+            .setStorageType(StorageTypeProto.DISK)
+            .setUsableVolumeCount(2)
+            .setBalanceable(true)
+            .setIdealUsage(0.8)
+            .setCurrentVolumeDensitySum(0.0)
+            .build())
+        .build();
+  }
+
   private DiskBalancerConfigurationProto createConfigProto(double threshold, 
long bandwidthInMB, int parallelThread,
       boolean stopAfterDiskEven) {
     return DiskBalancerConfigurationProto.newBuilder()


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to