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 c689b148989 HDDS-14925. Enhance DN Disk space management with soft and 
hard minfreespace limits. (#10054)
c689b148989 is described below

commit c689b1489897df0d5a83194cf9c0048df71c7531
Author: Ashish Kumar <[email protected]>
AuthorDate: Wed May 13 13:47:13 2026 +0530

    HDDS-14925. Enhance DN Disk space management with soft and hard 
minfreespace limits. (#10054)
---
 .../container/common/helpers/ContainerUtils.java   |  20 +-
 .../common/statemachine/DatanodeConfiguration.java |  81 +++++++-
 .../common/volume/AvailableSpaceFilter.java        |  19 +-
 .../ozone/container/common/volume/HddsVolume.java  |  16 +-
 .../container/common/volume/VolumeInfoMetrics.java |  44 ++++
 .../common/helpers/TestContainerUtils.java         | 222 +++++++++++++++++++++
 .../container/common/impl/TestHddsDispatcher.java  | 109 ++++++++++
 .../statemachine/TestDatanodeConfiguration.java    |  46 ++++-
 .../common/volume/TestAvailableSpaceFilter.java    | 177 ++++++++++++++++
 .../volume/TestCapacityVolumeChoosingPolicy.java   |   2 +-
 .../volume/TestRoundRobinVolumeChoosingPolicy.java |   2 +-
 .../replication/TestReplicationSupervisor.java     |   2 +-
 .../content/design/dn-min-space-configuration.md   | 120 +++++++++++
 .../dn/TestDatanodeMinFreeSpaceIntegration.java    | 121 +++++++++++
 14 files changed, 961 insertions(+), 20 deletions(-)

diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/helpers/ContainerUtils.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/helpers/ContainerUtils.java
index 7d16546fb69..f2817e37b51 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/helpers/ContainerUtils.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/helpers/ContainerUtils.java
@@ -58,6 +58,7 @@
 import org.apache.hadoop.ozone.container.common.impl.ContainerSet;
 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.VolumeInfoMetrics;
 import org.apache.hadoop.ozone.container.keyvalue.KeyValueContainerData;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -367,12 +368,25 @@ public static long getPendingDeletionBlocks(ContainerData 
containerData) {
   public static void assertSpaceAvailability(long containerId, HddsVolume 
volume, int sizeRequested)
       throws StorageContainerException {
     final SpaceUsageSource currentUsage = volume.getCurrentUsage();
-    final long spared = volume.getFreeSpaceToSpare(currentUsage.getCapacity());
+    final long capacity = currentUsage.getCapacity();
+    final long available = currentUsage.getAvailable();
+    final long hardSpare = volume.getFreeSpaceToSpare(capacity);
 
-    if (currentUsage.getAvailable() - spared < sizeRequested) {
+    if (available - hardSpare < sizeRequested) {
+      VolumeInfoMetrics stats = volume.getVolumeInfoStats();
+      if (stats != null) {
+        stats.incNumWriteRequestsRejectedHardMinFreeSpace();
+      }
       throw new StorageContainerException("Failed to write " + sizeRequested + 
" bytes to container "
           + containerId + " due to volume " + volume + " out of space "
-          + currentUsage + ", minimum free space spared="  + spared, 
DISK_OUT_OF_SPACE);
+          + currentUsage + ", minimum free space spared="  + hardSpare, 
DISK_OUT_OF_SPACE);
+    }
+    final long reportedSpare = volume.getReportedFreeSpaceToSpare(capacity);
+    if (available - reportedSpare < sizeRequested) {
+      VolumeInfoMetrics stats = volume.getVolumeInfoStats();
+      if (stats != null) {
+        stats.incNumWriteRequestsInSoftBandMinFreeSpace();
+      }
     }
   }
   
diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/statemachine/DatanodeConfiguration.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/statemachine/DatanodeConfiguration.java
index e47fdbb0c6b..1fd094b55f1 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/statemachine/DatanodeConfiguration.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/statemachine/DatanodeConfiguration.java
@@ -25,6 +25,7 @@
 import static org.apache.hadoop.hdds.conf.ConfigTag.STORAGE;
 import static 
org.apache.hadoop.ozone.container.common.statemachine.DatanodeConfiguration.CONFIG_PREFIX;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.time.Duration;
 import org.apache.commons.lang3.time.DurationFormatUtils;
 import org.apache.hadoop.hdds.conf.Config;
@@ -81,6 +82,10 @@ public class DatanodeConfiguration extends 
ReconfigurableConfig {
   public static final String HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT =
       "hdds.datanode.volume.min.free.space.percent";
   public static final float 
HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT_DEFAULT = 0.02f;
+  public static final String 
HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT =
+      "hdds.datanode.volume.min.free.space.hard.limit.percent";
+  public static final float 
HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT_DEFAULT =
+      0.015f;
 
   public static final String WAIT_ON_ALL_FOLLOWERS = 
"hdds.datanode.wait.on.all.followers";
   public static final String CONTAINER_SCHEMA_V3_ENABLED = 
"hdds.datanode.container.schema.v3.enabled";
@@ -318,11 +323,10 @@ public class DatanodeConfiguration extends 
ReconfigurableConfig {
       defaultValue = "-1",
       type = ConfigType.SIZE,
       tags = { OZONE, CONTAINER, STORAGE, MANAGEMENT },
-      description = "This determines the free space to be used for closing 
containers" +
-          " When the difference between volume capacity and used reaches this 
number," +
-          " containers that reside on this volume will be closed and no new 
containers" +
-          " would be allocated on this volume." +
-          " Max of min.free.space and min.free.space.percent will be used as 
final value."
+      description = "Minimum free space (bytes) applied together with 
min.free.space.percent "
+          + "(reported to SCM in heartbeat as freeSpaceToSpare) and "
+          + "min.free.space.hard.limit.percent (local write enforcement). "
+          + "The effective value for each tier is max(this bytes, capacity * 
ratio)."
   )
   private long minFreeSpace = getDefaultFreeSpace();
 
@@ -330,13 +334,25 @@ public class DatanodeConfiguration extends 
ReconfigurableConfig {
       defaultValue = "0.02", // match 
HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT_DEFAULT
       type = ConfigType.FLOAT,
       tags = { OZONE, CONTAINER, STORAGE, MANAGEMENT },
-      description = "This determines the free space percent to be used for 
closing containers" +
-          " When the difference between volume capacity and used reaches 
(free.space.percent of volume capacity)," +
-          " containers that reside on this volume will be closed and no new 
containers" +
-          " would be allocated on this volume." +
-          " Max of min.free.space or min.free.space.percent will be used as 
final value."
+      description = "Minimum fraction of volume capacity reported to SCM as 
freeSpaceToSpare "
+          + "(heartbeat / storage reports). Local write rejection uses "
+          + "hdds.datanode.volume.min.free.space.hard.limit.percent instead. "
+          + "The soft band is the gap between these two (e.g. 2000GB disk: 2% 
= 40GB reported vs "
+          + "1.5% = 30GB hard → 10GB band) where the DN may send 
close-container actions while "
+          + "writes still succeed."
   )
   private float minFreeSpaceRatio = 
HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT_DEFAULT;
+  @Config(key = "hdds.datanode.volume.min.free.space.hard.limit.percent",
+      defaultValue = "0.015",
+      type = ConfigType.FLOAT,
+      tags = { OZONE, CONTAINER, STORAGE, MANAGEMENT },
+      description = "Minimum fraction of volume capacity reserved for local 
enforcement: "
+          + "writes fail when available space would drop below max(this ratio 
* capacity, "
+          + "hdds.datanode.volume.min.free.space). Should be <= 
min.free.space.percent "
+          + "so SCM can plan for a larger headroom than the DN enforces 
locally."
+  )
+  private float minFreeSpaceHardLimitRatio =
+      HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT_DEFAULT;
 
   @Config(key = "hdds.datanode.periodic.disk.check.interval.minutes",
       defaultValue = "60",
@@ -876,6 +892,23 @@ private void validateMinFreeSpace() {
           minFreeSpaceRatio);
       minFreeSpaceRatio = HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT_DEFAULT;
     }
+    if (minFreeSpaceHardLimitRatio > 1 || minFreeSpaceHardLimitRatio < 0) {
+      LOG.warn("{} = {} is invalid, should be between 0 and 1; resetting to 
default {}",
+          HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT,
+          minFreeSpaceHardLimitRatio,
+          HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT_DEFAULT);
+      minFreeSpaceHardLimitRatio =
+          HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT_DEFAULT;
+    }
+    if (minFreeSpaceHardLimitRatio > minFreeSpaceRatio) {
+      LOG.warn("{} = {} must not exceed {} = {}, setting hard limit to soft 
limit. "
+              + "Set hard.limit.percent <= min.free.space.percent to enable 
the soft band.",
+          HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT,
+          minFreeSpaceHardLimitRatio,
+          HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT,
+          minFreeSpaceRatio);
+      minFreeSpaceHardLimitRatio = minFreeSpaceRatio;
+    }
 
     if (minFreeSpace < 0) {
       minFreeSpace = getDefaultFreeSpace();
@@ -942,10 +975,33 @@ public void setContainerCloseThreads(int 
containerCloseThreads) {
     this.containerCloseThreads = containerCloseThreads;
   }
 
+  /**
+   * Minimum free space reported to SCM (freeSpaceToSpare in storage reports).
+   */
   public long getMinFreeSpace(long capacity) {
     return Math.max((long) (capacity * minFreeSpaceRatio), minFreeSpace);
   }
 
+  /**
+   * Minimum free space enforced locally for writes (disk full / out-of-space)
+   * and for choosing a volume for a new container (same threshold as writes).
+   */
+  public long getHardLimitMinFreeSpace(long capacity) {
+    return Math.max((long) (capacity * minFreeSpaceHardLimitRatio), 
minFreeSpace);
+  }
+
+  /**
+   * Width of the soft band: reported spare minus hard spare. For example, 
with 2000GB capacity,
+   * 2% reported (40GB) and 1.5% hard (30GB), this is 10GB — the gap where the 
DN may send
+   * close-container actions while writes still succeed.
+   */
+  @VisibleForTesting
+  public long getSoftBandMinFreeSpaceWidth(long capacity) {
+    long reported = getMinFreeSpace(capacity);
+    long hard = getHardLimitMinFreeSpace(capacity);
+    return Math.max(0L, reported - hard);
+  }
+
   public long getMinFreeSpace() {
     return minFreeSpace;
   }
@@ -954,6 +1010,11 @@ public float getMinFreeSpaceRatio() {
     return minFreeSpaceRatio;
   }
 
+  @VisibleForTesting
+  public float getMinFreeSpaceHardLimitRatio() {
+    return minFreeSpaceHardLimitRatio;
+  }
+
   public long getPeriodicDiskCheckIntervalMinutes() {
     return periodicDiskCheckIntervalMinutes;
   }
diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/AvailableSpaceFilter.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/AvailableSpaceFilter.java
index bbd2bc97517..5ddc1efa68c 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/AvailableSpaceFilter.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/AvailableSpaceFilter.java
@@ -24,6 +24,9 @@
 
 /**
  * Filter for selecting volumes with enough space for a new container.
+ * Uses the <em>hard</em> min-free spare (same as write checks), not the 
SCM-reported spare in
+ * {@link StorageLocationReport#getFreeSpaceToSpare()}. The gap between 
reported and hard is the
+ * soft band (e.g. 40GB − 30GB on a 2000GB disk with 2% vs 1.5%).
  * Keeps track of ineligible volumes for logging/debug purposes.
  */
 public class AvailableSpaceFilter implements Predicate<HddsVolume> {
@@ -39,9 +42,23 @@ public AvailableSpaceFilter(long requiredSpace) {
   @Override
   public boolean test(HddsVolume vol) {
     StorageLocationReport report = vol.getReport();
-    long available = report.getUsableSpace();
+    long capacity = report.getCapacity();
+    long spareAtHardLimit = vol.getFreeSpaceToSpare(capacity);
+    long available =
+        report.getRemaining() - report.getCommitted() - spareAtHardLimit;
+    long availableAtReportedSpare = report.getUsableSpace();
+
     boolean hasEnoughSpace = available > requiredSpace;
 
+    VolumeInfoMetrics stats = vol.getVolumeInfoStats();
+    if (stats != null) {
+      if (!hasEnoughSpace) {
+        stats.incNumContainerCreateRequestsRejectedHardMinFreeSpace();
+      } else if (availableAtReportedSpare <= requiredSpace) {
+        stats.incNumContainerCreateRequestsInSoftBandMinFreeSpace();
+      }
+    }
+
     mostAvailableSpace = Math.max(available, mostAvailableSpace);
 
     if (!hasEnoughSpace) {
diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/HddsVolume.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/HddsVolume.java
index 8ac48fae748..f1deedc8d33 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/HddsVolume.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/HddsVolume.java
@@ -192,7 +192,7 @@ protected StorageLocationReport.Builder reportBuilder() {
     StorageLocationReport.Builder builder = super.reportBuilder();
     if (!builder.isFailed()) {
       builder.setCommitted(getCommittedBytes())
-          .setFreeSpaceToSpare(getFreeSpaceToSpare(builder.getCapacity()));
+          
.setFreeSpaceToSpare(getReportedFreeSpaceToSpare(builder.getCapacity()));
     }
     return builder;
   }
@@ -388,10 +388,22 @@ public long getCommittedBytes() {
     return committedBytes.get();
   }
 
-  public long getFreeSpaceToSpare(long volumeCapacity) {
+  /**
+   * Minimum free space reported to SCM (heartbeat), from
+   * {@code hdds.datanode.volume.min.free.space.percent}.
+   */
+  public long getReportedFreeSpaceToSpare(long volumeCapacity) {
     return getDatanodeConfig().getMinFreeSpace(volumeCapacity);
   }
 
+  /**
+   * Minimum free space enforced locally for writes (see
+   * {@code hdds.datanode.volume.min.free.space.hard.limit.percent}).
+   */
+  public long getFreeSpaceToSpare(long volumeCapacity) {
+    return getDatanodeConfig().getHardLimitMinFreeSpace(volumeCapacity);
+  }
+
   @Override
   public void setGatherContainerUsages(Function<HddsVolume, Long> 
gatherContainerUsages) {
     this.gatherContainerUsages = gatherContainerUsages;
diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/VolumeInfoMetrics.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/VolumeInfoMetrics.java
index 8340c1c4f7f..0cb0c9d56a9 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/VolumeInfoMetrics.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/volume/VolumeInfoMetrics.java
@@ -76,6 +76,18 @@ public class VolumeInfoMetrics implements MetricsSource {
   @Metric("Number of scans skipped for the volume")
   private MutableCounterLong numScansSkipped;
 
+  @Metric("Write requests allowed while usable space is between the reported 
(soft) and hard min-free-space thresholds")
+  private MutableCounterLong numWriteRequestsInSoftBandMinFreeSpace;
+
+  @Metric("Write requests rejected because the hard min-free-space limit would 
be violated")
+  private MutableCounterLong numWriteRequestsRejectedHardMinFreeSpace;
+  @Metric("Container create allowed while usable space is between the reported 
(soft) " +
+      "and hard min-free-space thresholds")
+  private MutableCounterLong numContainerCreateRequestsInSoftBandMinFreeSpace;
+
+  @Metric("Container create requests rejected because the hard min-free-space 
limit would be violated")
+  private MutableCounterLong 
numContainerCreateRequestsRejectedHardMinFreeSpace;
+
   /**
    * @param identifier Typically, path to volume root. E.g. /data/hdds
    */
@@ -185,6 +197,38 @@ public void incNumScansSkipped() {
     numScansSkipped.incr();
   }
 
+  public long getNumWriteRequestsInSoftBandMinFreeSpace() {
+    return numWriteRequestsInSoftBandMinFreeSpace.value();
+  }
+
+  public void incNumWriteRequestsInSoftBandMinFreeSpace() {
+    numWriteRequestsInSoftBandMinFreeSpace.incr();
+  }
+
+  public long getNumWriteRequestsRejectedHardMinFreeSpace() {
+    return numWriteRequestsRejectedHardMinFreeSpace.value();
+  }
+
+  public void incNumWriteRequestsRejectedHardMinFreeSpace() {
+    numWriteRequestsRejectedHardMinFreeSpace.incr();
+  }
+
+  public long getNumContainerCreateRequestsInSoftBandMinFreeSpace() {
+    return numContainerCreateRequestsInSoftBandMinFreeSpace.value();
+  }
+
+  public void incNumContainerCreateRequestsInSoftBandMinFreeSpace() {
+    numContainerCreateRequestsInSoftBandMinFreeSpace.incr();
+  }
+
+  public long getNumContainerCreateRequestsRejectedHardMinFreeSpace() {
+    return numContainerCreateRequestsRejectedHardMinFreeSpace.value();
+  }
+
+  public void incNumContainerCreateRequestsRejectedHardMinFreeSpace() {
+    numContainerCreateRequestsRejectedHardMinFreeSpace.incr();
+  }
+
   @Override
   public void getMetrics(MetricsCollector collector, boolean all) {
     MetricsRecordBuilder builder = collector.addRecord(metricsSourceName);
diff --git 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/helpers/TestContainerUtils.java
 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/helpers/TestContainerUtils.java
index e262e795aa6..a2ef8f53798 100644
--- 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/helpers/TestContainerUtils.java
+++ 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/helpers/TestContainerUtils.java
@@ -24,11 +24,14 @@
 import static 
org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.Type.ReadChunk;
 import static 
org.apache.hadoop.hdds.scm.protocolPB.ContainerCommandResponseBuilders.getReadChunkResponse;
 import static 
org.apache.hadoop.ozone.container.ContainerTestHelper.getDummyCommandRequestProto;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import java.io.File;
@@ -42,13 +45,18 @@
 import org.apache.commons.lang3.RandomUtils;
 import org.apache.hadoop.hdds.HddsConfigKeys;
 import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.fs.SpaceUsageSource;
 import org.apache.hadoop.hdds.protocol.DatanodeDetails;
 import 
org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ContainerCommandRequestProto;
 import 
org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ContainerCommandResponseProto;
+import org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.Result;
 import org.apache.hadoop.hdds.protocol.proto.HddsProtos;
 import org.apache.hadoop.hdds.scm.ByteStringConversion;
 import org.apache.hadoop.hdds.scm.ScmConfigKeys;
+import 
org.apache.hadoop.hdds.scm.container.common.helpers.StorageContainerException;
 import org.apache.hadoop.ozone.common.ChunkBuffer;
+import org.apache.hadoop.ozone.container.common.volume.HddsVolume;
+import org.apache.hadoop.ozone.container.common.volume.VolumeInfoMetrics;
 import org.apache.ratis.thirdparty.com.google.protobuf.TextFormat;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -227,4 +235,218 @@ private static void assertDetailsEquals(DatanodeDetails 
expected,
     assertEquals(expected.getInitialVersion(), actual.getInitialVersion());
     assertEquals(expected.getIpAddress(), actual.getIpAddress());
   }
+
+  @Test
+  public void 
assertSpaceAvailabilityIncrementsSoftBandWhenBetweenReportedAndHard()
+      throws Exception {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 
900L);
+    when(volume.getCurrentUsage()).thenReturn(usage);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+    when(volume.toString()).thenReturn("mockVolume");
+
+    ContainerUtils.assertSpaceAvailability(1L, volume, 50);
+
+    verify(metrics).incNumWriteRequestsInSoftBandMinFreeSpace();
+    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
+  }
+
+  @Test
+  public void 
assertSpaceAvailabilityIncrementsHardRejectWhenHardLimitViolated() {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 
900L);
+    when(volume.getCurrentUsage()).thenReturn(usage);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+    when(volume.toString()).thenReturn("mockVolume");
+
+    StorageContainerException ex = 
assertThrows(StorageContainerException.class,
+        () -> ContainerUtils.assertSpaceAvailability(1L, volume, 80));
+    assertEquals(Result.DISK_OUT_OF_SPACE, ex.getResult());
+
+    verify(metrics).incNumWriteRequestsRejectedHardMinFreeSpace();
+    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
+  }
+
+  /**
+   * available(100) - hardSpare(30) = 70 == sizeRequested(70).
+   * The check is strict less-than, so the write passes at the exact boundary.
+   * The volume is still inside the soft band (available - softSpare = 0 < 70),
+   * so the soft-band metric fires.
+   */
+  @Test
+  public void assertSpaceAvailabilityPassesAtExactHardBoundary() throws 
Exception {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 
900L);
+    when(volume.getCurrentUsage()).thenReturn(usage);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+    when(volume.toString()).thenReturn("mockVolume");
+
+    // available(100) - hardSpare(30) = 70 == sizeRequested → passes (< not <=)
+    assertDoesNotThrow(() -> ContainerUtils.assertSpaceAvailability(1L, 
volume, 70));
+
+    verify(metrics).incNumWriteRequestsInSoftBandMinFreeSpace();
+    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
+  }
+
+  /**
+   * available(100) - hardSpare(30) = 70 < sizeRequested(71).
+   * One byte past the hard boundary; write must be rejected.
+   */
+  @Test
+  public void assertSpaceAvailabilityRejectsOneByteOverHardBoundary() {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 
900L);
+    when(volume.getCurrentUsage()).thenReturn(usage);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+    when(volume.toString()).thenReturn("mockVolume");
+
+    // available(100) - hardSpare(30) = 70 < 71 → rejected
+    StorageContainerException ex = 
assertThrows(StorageContainerException.class,
+        () -> ContainerUtils.assertSpaceAvailability(1L, volume, 71));
+    assertEquals(Result.DISK_OUT_OF_SPACE, ex.getResult());
+
+    verify(metrics).incNumWriteRequestsRejectedHardMinFreeSpace();
+    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
+  }
+
+  /**
+   * available(200) is well above both soft(100) and hard(30) spares.
+   * available - hardSpare = 170 > sizeRequested(50): write passes.
+   * available - softSpare = 100 > sizeRequested(50): not in soft band.
+   * Neither metric should fire.
+   */
+  @Test
+  public void assertSpaceAvailabilityFiresNoMetricWhenWellAboveBothLimits() 
throws Exception {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 200L, 
800L);
+    when(volume.getCurrentUsage()).thenReturn(usage);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+    when(volume.toString()).thenReturn("mockVolume");
+
+    // available(200) - hardSpare(30) = 170 > 50, and 200 - softSpare(100) = 
100 > 50
+    ContainerUtils.assertSpaceAvailability(1L, volume, 50);
+
+
+    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
+    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
+  }
+
+  /**
+   * When VolumeInfoMetrics is null (volume not yet initialised or metrics 
disabled),
+   * assertSpaceAvailability must not throw NullPointerException on the 
soft-band path.
+   */
+  @Test
+  public void assertSpaceAvailabilityHandlesNullMetrics() {
+    HddsVolume volume = mock(HddsVolume.class);
+    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 
900L);
+    when(volume.getCurrentUsage()).thenReturn(usage);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
+    when(volume.getVolumeInfoStats()).thenReturn(null);
+    when(volume.toString()).thenReturn("mockVolume");
+
+    assertDoesNotThrow(() -> ContainerUtils.assertSpaceAvailability(1L, 
volume, 50));
+  }
+
+  /**
+   * When VolumeInfoMetrics is null and the hard limit is violated,
+   * assertSpaceAvailability must throw DISK_OUT_OF_SPACE without 
NullPointerException
+   * when trying to increment the hard-reject metric.
+   */
+  @Test
+  public void assertSpaceAvailabilityHandlesNullMetricsOnHardReject() {
+    HddsVolume volume = mock(HddsVolume.class);
+    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 
900L);
+    when(volume.getCurrentUsage()).thenReturn(usage);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getVolumeInfoStats()).thenReturn(null);
+    when(volume.toString()).thenReturn("mockVolume");
+
+    // available(100) - hardSpare(30) = 70 < 80 → hard reject; null metrics 
must not NPE
+    StorageContainerException ex = 
assertThrows(StorageContainerException.class,
+        () -> ContainerUtils.assertSpaceAvailability(1L, volume, 80));
+    assertEquals(Result.DISK_OUT_OF_SPACE, ex.getResult());
+  }
+
+  /**
+   * Exact soft boundary: available(150) - softSpare(100) == sizeRequested(50).
+   * The soft-band check is strict {@code <}, so at equality it must NOT fire.
+   * The hard check: 150 - 30 = 120 > 50, so the write passes.
+   */
+  @Test
+  public void assertSpaceAvailabilityNoSoftBandMetricAtExactSoftBoundary() 
throws Exception {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 150L, 
850L);
+    when(volume.getCurrentUsage()).thenReturn(usage);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+    when(volume.toString()).thenReturn("mockVolume");
+
+    // available(150) - softSpare(100) = 50 == sizeRequested(50): boundary is 
exclusive, no soft metric
+    ContainerUtils.assertSpaceAvailability(1L, volume, 50);
+
+    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
+    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
+  }
+
+  /**
+   * Zero-byte write: sizeRequested == 0 should always pass regardless of 
available space,
+   * and must not fire any metric.
+   */
+  @Test
+  public void assertSpaceAvailabilityNoMetricsForZeroSizeRequest() throws 
Exception {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 
900L);
+    when(volume.getCurrentUsage()).thenReturn(usage);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+    when(volume.toString()).thenReturn("mockVolume");
+
+    ContainerUtils.assertSpaceAvailability(1L, volume, 0);
+
+    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
+    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
+  }
+
+  /**
+   * When hard spare == soft spare the soft band is effectively disabled.
+   * A write that passes the hard check must not trigger the soft-band metric
+   * even though the usable space is tight.
+   * available(100) - spare(30) = 70 > 50: passes; soft band width = 0.
+   */
+  @Test
+  public void assertSpaceAvailabilityNoSoftBandWhenHardAndSoftSpareAreEqual() 
throws Exception {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 
900L);
+    when(volume.getCurrentUsage()).thenReturn(usage);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(30L);  // same 
as hard → no soft band
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+    when(volume.toString()).thenReturn("mockVolume");
+
+    ContainerUtils.assertSpaceAvailability(1L, volume, 50);
+
+    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
+    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
+  }
 }
diff --git 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/impl/TestHddsDispatcher.java
 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/impl/TestHddsDispatcher.java
index da84d23d3e5..b5d4ea10647 100644
--- 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/impl/TestHddsDispatcher.java
+++ 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/impl/TestHddsDispatcher.java
@@ -856,6 +856,115 @@ public void verify(Token<?> token,
     }
   }
 
+  /**
+   * Verifies the soft/hard min-free-space split on the write path:
+   *
+   * <p>Setup (capacity=500 bytes):
+   * <pre>
+   *   minFreeSpace bytes floor = 1  (ratio always dominates)
+   *   softRatio = 10%  → softSpare = 50 bytes (reported to SCM)
+   *   hardRatio =  6%  → hardSpare = 30 bytes (local write enforcement)
+   *   softBand          = 20 bytes
+   *   writeChunk size   ≈ 36 bytes (UUID string)
+   * </pre>
+   *
+   * <p>Three scenarios exercised in sequence using the same volume by calling
+   * {@code hddsVolume.incrementUsedSpace(delta)} to update the 
CachingSpaceUsageSource cache:
+   * <ol>
+   *   <li>Well above both limits (usedSpace=400, available=100): write 
passes, no metric fires.</li>
+   *   <li>Inside the soft band (usedSpace=425, available=75): write passes 
(75-30=45 &gt; 36),
+   *       {@code numWriteRequestsInSoftBandMinFreeSpace} incremented 
(75-50=25 &lt; 36).</li>
+   *   <li>Below hard limit (usedSpace=465, available=35): write rejected with 
DISK_OUT_OF_SPACE
+   *       (35-30=5 &lt; 36), {@code numWriteRequestsRejectedHardMinFreeSpace} 
incremented.</li>
+   * </ol>
+   */
+  @ContainerLayoutTestInfo.ContainerTest
+  public void testWriteChunkEnforcesSoftHardMinFreeSpace(
+      ContainerLayoutVersion layoutVersion) throws Exception {
+    String testDirPath = testDir.getPath();
+    OzoneConfiguration conf = new OzoneConfiguration();
+    // 1-byte floor so the percentage ratios always dominate
+    
conf.setStorageSize(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE,
+        1.0, StorageUnit.BYTES);
+    // soft spare = 10% of 500 = 50 bytes; hard spare = 6% of 500 = 30 bytes; 
band = 20 bytes
+    
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT,
 0.1f);
+    
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT,
 0.06f);
+    conf.set(HDDS_DATANODE_DIR_KEY, testDirPath);
+    conf.set(OzoneConfigKeys.OZONE_METADATA_DIRS, testDirPath);
+    DatanodeDetails dd = randomDatanodeDetails();
+    UUID scmId = UUID.randomUUID();
+    AtomicLong usedSpace = new AtomicLong(400); // available = 100, well above 
both limits
+    SpaceUsageSource spaceUsage = MockSpaceUsageSource.of(500, usedSpace);
+    SpaceUsageCheckFactory factory = MockSpaceUsageCheckFactory.of(
+        spaceUsage, Duration.ZERO, inMemory(new AtomicLong(0)));
+    HddsVolume.Builder volumeBuilder =
+        new HddsVolume.Builder(testDirPath).datanodeUuid(dd.getUuidString())
+            
.conf(conf).usageCheckFactory(MockSpaceUsageCheckFactory.NONE).clusterID("test");
+    volumeBuilder.usageCheckFactory(factory);
+    MutableVolumeSet volumeSet = mock(MutableVolumeSet.class);
+    when(volumeSet.getVolumesList())
+        .thenReturn(Collections.singletonList(volumeBuilder.build()));
+    
volumeSet.getVolumesList().get(0).setState(StorageVolume.VolumeState.NORMAL);
+    volumeSet.getVolumesList().get(0).start();
+    HddsVolume hddsVolume = StorageVolumeUtil
+        .getHddsVolumesList(volumeSet.getVolumesList()).get(0);
+    try {
+      KeyValueContainerData containerData = new KeyValueContainerData(1L,
+          layoutVersion, 50, UUID.randomUUID().toString(), dd.getUuidString());
+      Container container = new KeyValueContainer(containerData, conf);
+      StorageVolumeUtil.getHddsVolumesList(volumeSet.getVolumesList())
+          .forEach(v -> v.setDbParentDir(tempDir.toFile()));
+      container.create(volumeSet, new RoundRobinVolumeChoosingPolicy(), 
scmId.toString());
+      ContainerSet containerSet = newContainerSet();
+      containerSet.addContainer(container);
+      StateContext context = ContainerTestUtils.getMockContext(dd, conf);
+      ContainerMetrics metrics = ContainerMetrics.create(conf);
+      Map<ContainerType, Handler> handlers = Maps.newHashMap();
+      for (ContainerType containerType : ContainerType.values()) {
+        handlers.put(containerType,
+            Handler.getHandlerForContainerType(containerType, conf,
+                dd.getUuidString(), containerSet, volumeSet, 
volumeChoosingPolicy,
+                metrics, NO_OP_ICR_SENDER, new 
ContainerChecksumTreeManager(conf)));
+      }
+      HddsDispatcher hddsDispatcher = new HddsDispatcher(
+          conf, containerSet, volumeSet, handlers, context, metrics, null);
+      hddsDispatcher.setClusterId(scmId.toString());
+      // --- Scenario 1: well above both limits (available=100) ---
+      // available(100) - hardSpare(30) = 70 > writeSize(~36): passes
+      // available(100) - softSpare(50) = 50 > writeSize(~36): not in soft band
+      ContainerCommandResponseProto response =
+          hddsDispatcher.dispatch(getWriteChunkRequest(dd.getUuidString(), 1L, 
1L), null);
+      assertEquals(ContainerProtos.Result.SUCCESS, response.getResult());
+      assertEquals(0,
+          
hddsVolume.getVolumeInfoStats().getNumWriteRequestsInSoftBandMinFreeSpace());
+      assertEquals(0,
+          
hddsVolume.getVolumeInfoStats().getNumWriteRequestsRejectedHardMinFreeSpace());
+      // --- Scenario 2: inside the soft band (usedSpace → 425, available=75) 
---
+      // available(75) - hardSpare(30) = 45 > writeSize(~36): passes hard check
+      // available(75) - softSpare(50) = 25 < writeSize(~36): soft-band metric 
fires
+      // Use incrementUsedSpace so the CachingSpaceUsageSource internal cache 
is updated;
+      hddsVolume.incrementUsedSpace(25); // 400 → 425
+      response = 
hddsDispatcher.dispatch(getWriteChunkRequest(dd.getUuidString(), 1L, 2L), null);
+      assertEquals(ContainerProtos.Result.SUCCESS, response.getResult());
+      assertEquals(1,
+          
hddsVolume.getVolumeInfoStats().getNumWriteRequestsInSoftBandMinFreeSpace());
+      assertEquals(0,
+          
hddsVolume.getVolumeInfoStats().getNumWriteRequestsRejectedHardMinFreeSpace());
+      // --- Scenario 3: below hard limit (usedSpace → 465, available=35) ---
+      // available(35) - hardSpare(30) = 5 < writeSize(~36): DISK_OUT_OF_SPACE
+      hddsVolume.incrementUsedSpace(40); // 425 → 465
+      response = 
hddsDispatcher.dispatch(getWriteChunkRequest(dd.getUuidString(), 1L, 3L), null);
+      assertEquals(ContainerProtos.Result.DISK_OUT_OF_SPACE, 
response.getResult());
+      assertEquals(1,
+          
hddsVolume.getVolumeInfoStats().getNumWriteRequestsInSoftBandMinFreeSpace());
+      assertEquals(1,
+          
hddsVolume.getVolumeInfoStats().getNumWriteRequestsRejectedHardMinFreeSpace());
+    } finally {
+      volumeSet.shutdown();
+      ContainerMetrics.remove();
+    }
+  }
+
   static DispatcherContext newContext(Op op) {
     return newContext(op, WriteChunkStage.COMBINED);
   }
diff --git 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/statemachine/TestDatanodeConfiguration.java
 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/statemachine/TestDatanodeConfiguration.java
index b12c6575960..59e90ba30b3 100644
--- 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/statemachine/TestDatanodeConfiguration.java
+++ 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/statemachine/TestDatanodeConfiguration.java
@@ -31,6 +31,7 @@
 import static 
org.apache.hadoop.ozone.container.common.statemachine.DatanodeConfiguration.FAILED_VOLUMES_TOLERATED_DEFAULT;
 import static 
org.apache.hadoop.ozone.container.common.statemachine.DatanodeConfiguration.GRPC_SO_BACKLOG_DEFAULT;
 import static 
org.apache.hadoop.ozone.container.common.statemachine.DatanodeConfiguration.GRPC_SO_BACKLOG_KEY;
+import static 
org.apache.hadoop.ozone.container.common.statemachine.DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT_DEFAULT;
 import static 
org.apache.hadoop.ozone.container.common.statemachine.DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT_DEFAULT;
 import static 
org.apache.hadoop.ozone.container.common.statemachine.DatanodeConfiguration.PERIODIC_DISK_CHECK_INTERVAL_MINUTES_DEFAULT;
 import static 
org.apache.hadoop.ozone.container.common.statemachine.DatanodeConfiguration.PERIODIC_DISK_CHECK_INTERVAL_MINUTES_KEY;
@@ -186,12 +187,22 @@ public void isCreatedWitDefaultValues() {
         subject.getBlockDeleteCommandWorkerInterval());
     assertEquals(DatanodeConfiguration.getDefaultFreeSpace(), 
subject.getMinFreeSpace());
     assertEquals(HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT_DEFAULT, 
subject.getMinFreeSpaceRatio());
+    
assertEquals(HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT_DEFAULT,
+        subject.getMinFreeSpaceHardLimitRatio());
     final long oneGB = 1024 * 1024 * 1024;
     // capacity is less, consider default min_free_space
     assertEquals(DatanodeConfiguration.getDefaultFreeSpace(), 
subject.getMinFreeSpace(oneGB));
+    assertEquals(DatanodeConfiguration.getDefaultFreeSpace(), 
subject.getHardLimitMinFreeSpace(oneGB));
+    assertEquals(0L, subject.getSoftBandMinFreeSpaceWidth(oneGB));
     // capacity is large, consider min_free_space_percent, max(min_free_space, 
min_free_space_percent * capacity)ß
     assertEquals(HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT_DEFAULT * oneGB * 
oneGB,
         subject.getMinFreeSpace(oneGB * oneGB));
+    
assertEquals(HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT_DEFAULT * 
oneGB * oneGB,
+        subject.getHardLimitMinFreeSpace(oneGB * oneGB));
+    // e.g. 2000GB: 40GB reported − 30GB hard = 10GB soft bandwidth (derived, 
not configured)
+    assertEquals(
+        subject.getMinFreeSpace(oneGB * oneGB) - 
subject.getHardLimitMinFreeSpace(oneGB * oneGB),
+        subject.getSoftBandMinFreeSpaceWidth(oneGB * oneGB));
 
     // Verify that no warnings were logged when using default values
     String logOutput = logCapturer.getOutput();
@@ -226,6 +237,34 @@ void useMaxIfBothMinFreeSpacePropertiesSet() {
     }
   }
 
+  /**
+   * If hard limit percent is greater than soft (reported) percent, {@link 
DatanodeConfiguration}
+   * uses the hard threshold for SCM-reported spare as well, so there is no 
negative "soft band".
+   */
+  @Test
+  void whenHardRatioExceedsSoftRatioReportedSpareMatchesHardOnly() {
+    OzoneConfiguration conf = new OzoneConfiguration();
+    conf.unset(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE);
+    
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT,
 0.01f);
+    
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT,
 0.02f);
+
+    DatanodeConfiguration subject = 
conf.getObject(DatanodeConfiguration.class);
+    long capacityBytes = 1000L * 1024 * 1024 * 1024;
+
+    assertEquals(subject.getHardLimitMinFreeSpace(capacityBytes),
+        subject.getMinFreeSpace(capacityBytes));
+    assertEquals(0L, subject.getSoftBandMinFreeSpaceWidth(capacityBytes));
+  }
+
+  @Test
+  void rejectsInvalidMinFreeSpaceHardLimitRatio() {
+    OzoneConfiguration conf = new OzoneConfiguration();
+    
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT,
 1.5f);
+    DatanodeConfiguration subject = 
conf.getObject(DatanodeConfiguration.class);
+    
assertEquals(HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT_DEFAULT,
+        subject.getMinFreeSpaceHardLimitRatio());
+  }
+
   @ParameterizedTest
   @ValueSource(longs = {1_000, 10_000, 100_000})
   void usesFixedMinFreeSpace(long bytes) {
@@ -233,6 +272,8 @@ void usesFixedMinFreeSpace(long bytes) {
     conf.setLong(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE, 
bytes);
     // keeping %cent low so that min free space is picked up
     
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT,
 0.00001f);
+    
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT,
+        0.00001f);
 
     DatanodeConfiguration subject = 
conf.getObject(DatanodeConfiguration.class);
 
@@ -249,7 +290,10 @@ void calculatesMinFreeSpaceRatio(int percent) {
     OzoneConfiguration conf = new OzoneConfiguration();
     // keeping min free space low so that %cent is picked up after calculation
     conf.set(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE, 
"1000"); // set in ozone-site.xml
-    
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT,
 percent / 100.0f);
+    float softRatio = percent / 100.0f;
+    
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT,
 softRatio);
+    
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT,
+        Math.min(softRatio, 0.01f));
 
     DatanodeConfiguration subject = 
conf.getObject(DatanodeConfiguration.class);
 
diff --git 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/volume/TestAvailableSpaceFilter.java
 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/volume/TestAvailableSpaceFilter.java
new file mode 100644
index 00000000000..dade7f4b570
--- /dev/null
+++ 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/volume/TestAvailableSpaceFilter.java
@@ -0,0 +1,177 @@
+/*
+ * 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.ozone.container.common.volume;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.apache.hadoop.ozone.container.common.impl.StorageLocationReport;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link AvailableSpaceFilter}.
+ */
+public class TestAvailableSpaceFilter {
+
+  @Test
+  public void testIncrementsSoftBandWhenBetweenReportedAndHard() {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    StorageLocationReport report = mock(StorageLocationReport.class);
+    when(volume.getReport()).thenReturn(report);
+    when(report.getCapacity()).thenReturn(1000L);
+    when(report.getRemaining()).thenReturn(100L);
+    when(report.getCommitted()).thenReturn(0L);
+    when(report.getUsableSpace()).thenReturn(0L);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+
+    AvailableSpaceFilter filter = new AvailableSpaceFilter(50L);
+    assertTrue(filter.test(volume));
+
+    verify(metrics).incNumContainerCreateRequestsInSoftBandMinFreeSpace();
+    verify(metrics, 
never()).incNumContainerCreateRequestsRejectedHardMinFreeSpace();
+  }
+
+  @Test
+  public void testIncrementsHardRejectWhenHardLimitViolated() {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    StorageLocationReport report = mock(StorageLocationReport.class);
+    when(volume.getReport()).thenReturn(report);
+    when(report.getCapacity()).thenReturn(1000L);
+    when(report.getRemaining()).thenReturn(100L);
+    when(report.getCommitted()).thenReturn(0L);
+    when(report.getUsableSpace()).thenReturn(0L);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+
+    AvailableSpaceFilter filter = new AvailableSpaceFilter(80L);
+    assertFalse(filter.test(volume));
+
+    verify(metrics).incNumContainerCreateRequestsRejectedHardMinFreeSpace();
+    verify(metrics, 
never()).incNumContainerCreateRequestsInSoftBandMinFreeSpace();
+  }
+
+  @Test
+  public void testNoMetricIncrementWhenWellAboveSoftBand() {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    StorageLocationReport report = mock(StorageLocationReport.class);
+    when(volume.getReport()).thenReturn(report);
+    when(report.getCapacity()).thenReturn(1000L);
+    when(report.getRemaining()).thenReturn(1000L);
+    when(report.getCommitted()).thenReturn(0L);
+    when(report.getUsableSpace()).thenReturn(900L);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+
+    AvailableSpaceFilter filter = new AvailableSpaceFilter(50L);
+    assertTrue(filter.test(volume));
+
+    verify(metrics, 
never()).incNumContainerCreateRequestsInSoftBandMinFreeSpace();
+    verify(metrics, 
never()).incNumContainerCreateRequestsRejectedHardMinFreeSpace();
+  }
+
+  /**
+   * Without committed bytes: remaining(200) - hardSpare(30) = 170 > 
requiredSpace(60),
+   * and 200 - softSpare(100) = 100 > 60 → well above both limits, no metric.
+   * With committed(80): 200 - 80 - hardSpare(30) = 90 > 60 → still passes 
hard,
+   * but 200 - 80 - softSpare(100) = 20 ≤ 60 → now inside the soft band.
+   * Committed bytes representing in-flight pipeline allocations push the 
volume into
+   * the soft band even though raw remaining space looks healthy.
+   */
+  @Test
+  public void testCommittedBytesCanPushVolumeIntoSoftBand() {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    StorageLocationReport report = mock(StorageLocationReport.class);
+    when(volume.getReport()).thenReturn(report);
+    when(report.getCapacity()).thenReturn(1000L);
+    when(report.getRemaining()).thenReturn(200L);
+    when(report.getCommitted()).thenReturn(80L);
+    when(report.getUsableSpace()).thenReturn(20L);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+
+    // available = 200 - 80 - 30 = 90 > requiredSpace(60) → passes hard check
+    // getUsableSpace = 200 - 80 - spareOnReport(100) = 20 <= 60 → inside soft 
band
+    AvailableSpaceFilter filter = new AvailableSpaceFilter(60L);
+    assertTrue(filter.test(volume));
+
+    verify(metrics).incNumContainerCreateRequestsInSoftBandMinFreeSpace();
+    verify(metrics, 
never()).incNumContainerCreateRequestsRejectedHardMinFreeSpace();
+  }
+
+  /**
+   * Committed bytes representing in-flight pipeline allocations cause a hard 
reject
+   * that would not occur if committed were zero.
+   * remaining(130) - committed(80) - hardSpare(30) = 20 < requiredSpace(50) → 
rejected.
+   * Without committed: 130 - 0 - 30 = 100 > 50 → would have passed.
+   */
+  @Test
+  public void testCommittedBytesCanCauseHardReject() {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    StorageLocationReport report = mock(StorageLocationReport.class);
+    when(volume.getReport()).thenReturn(report);
+    when(report.getCapacity()).thenReturn(1000L);
+    when(report.getRemaining()).thenReturn(130L);
+    when(report.getCommitted()).thenReturn(80L);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+
+    // available = 130 - 80 - 30 = 20 < requiredSpace(50) → hard rejected
+    AvailableSpaceFilter filter = new AvailableSpaceFilter(50L);
+    assertFalse(filter.test(volume));
+
+    verify(metrics).incNumContainerCreateRequestsRejectedHardMinFreeSpace();
+    verify(metrics, 
never()).incNumContainerCreateRequestsInSoftBandMinFreeSpace();
+  }
+
+  /**
+   * Even with non-zero committed bytes, if remaining space is large enough,
+   * the volume remains well above both limits and no metric fires.
+   * remaining(300) - committed(50) - hardSpare(30) = 220 > requiredSpace(50): 
passes hard.
+   * 300 - 50 - softSpare(100) = 150 > 50: not in soft band.
+   */
+  @Test
+  public void testCommittedBytesDoNotAffectMetricsWhenVolumeStillHealthy() {
+    HddsVolume volume = mock(HddsVolume.class);
+    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
+    StorageLocationReport report = mock(StorageLocationReport.class);
+    when(volume.getReport()).thenReturn(report);
+    when(report.getCapacity()).thenReturn(1000L);
+    when(report.getRemaining()).thenReturn(300L);
+    when(report.getCommitted()).thenReturn(50L);
+    when(report.getUsableSpace()).thenReturn(150L);
+    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
+    when(volume.getVolumeInfoStats()).thenReturn(metrics);
+
+    // available = 300 - 50 - 30 = 220 > 50; getUsableSpace = 300 - 50 - 100 = 
150 > 50
+    AvailableSpaceFilter filter = new AvailableSpaceFilter(50L);
+    assertTrue(filter.test(volume));
+
+    verify(metrics, 
never()).incNumContainerCreateRequestsInSoftBandMinFreeSpace();
+    verify(metrics, 
never()).incNumContainerCreateRequestsRejectedHardMinFreeSpace();
+  }
+}
diff --git 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/volume/TestCapacityVolumeChoosingPolicy.java
 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/volume/TestCapacityVolumeChoosingPolicy.java
index deae4f83951..7917ebf80bd 100644
--- 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/volume/TestCapacityVolumeChoosingPolicy.java
+++ 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/volume/TestCapacityVolumeChoosingPolicy.java
@@ -129,7 +129,7 @@ public void 
throwsDiskOutOfSpaceIfRequestMoreThanAvailable() {
     String msg = e.getMessage();
     assertThat(msg)
         .contains("No volumes have enough space for a new container.  " +
-            "Most available space: 240 bytes");
+            "Most available space: 243 bytes");
   }
 
   @Test
diff --git 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/volume/TestRoundRobinVolumeChoosingPolicy.java
 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/volume/TestRoundRobinVolumeChoosingPolicy.java
index 36fabff1fe8..91bc9caa2c2 100644
--- 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/volume/TestRoundRobinVolumeChoosingPolicy.java
+++ 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/volume/TestRoundRobinVolumeChoosingPolicy.java
@@ -114,7 +114,7 @@ public void 
throwsDiskOutOfSpaceIfRequestMoreThanAvailable() {
 
     String msg = e.getMessage();
     assertThat(msg).contains("No volumes have enough space for a new 
container.  " +
-        "Most available space: 140 bytes");
+        "Most available space: 143 bytes");
   }
 
   @Test
diff --git 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/replication/TestReplicationSupervisor.java
 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/replication/TestReplicationSupervisor.java
index 9ceb0a99e9f..abfef6fbffd 100644
--- 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/replication/TestReplicationSupervisor.java
+++ 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/replication/TestReplicationSupervisor.java
@@ -419,7 +419,7 @@ public void 
testReplicationImportReserveSpace(ContainerLayoutVersion layout)
     assertEquals(0, usedSpace);
     // Increase committed bytes so that volume has only remaining 3 times 
container size space
     long minFreeSpace =
-        
conf.getObject(DatanodeConfiguration.class).getMinFreeSpace(vol1.getCurrentUsage().getCapacity());
+        
conf.getObject(DatanodeConfiguration.class).getHardLimitMinFreeSpace(vol1.getCurrentUsage().getCapacity());
     long initialCommittedBytes = vol1.getCurrentUsage().getCapacity() - 
containerMaxSize * 3 - minFreeSpace;
     vol1.incCommittedBytes(initialCommittedBytes);
     ContainerReplicator replicator =
diff --git a/hadoop-hdds/docs/content/design/dn-min-space-configuration.md 
b/hadoop-hdds/docs/content/design/dn-min-space-configuration.md
index ab62e51428d..037ad6da4cf 100644
--- a/hadoop-hdds/docs/content/design/dn-min-space-configuration.md
+++ b/hadoop-hdds/docs/content/design/dn-min-space-configuration.md
@@ -105,4 +105,124 @@ This case is more useful for test environment where disk 
space is less and no ne
 - So Approach 1 is selected considering advantage where higher free space can 
be configured by default.
 2. Min Space will be 20GB as default
 
+---
+
+# Soft and Hard Min-Free-Space Limits
+
+## Overview
+
+The min-free-space value chosen above is split into two distinct thresholds 
that serve different purposes:
+a **soft** (reported) limit and a **hard** (locally-enforced) limit. The gap 
between them is the
+*soft band* — a warning zone where writes are still accepted but the Datanode 
is already signalling
+to SCM that it is running low.
+
+## Configuration
+
+| Key | Default | Purpose |
+|-----|---------|---------|
+| `hdds.datanode.volume.min.free.space` | `20GB` | Absolute floor shared by 
both tiers. Effective spare = `max(this, capacity × ratio)`. |
+| `hdds.datanode.volume.min.free.space.percent` | `2%` | **Soft limit ratio** 
— reported to SCM via `freeSpaceToSpare` in storage heartbeats. |
+| `hdds.datanode.volume.min.free.space.hard.limit.percent` | `1.5%` | **Hard 
limit ratio** — enforced locally for write rejection and new container 
placement. |
+
+Rules:
+- `hard.limit.percent` must be ≤ `min.free.space.percent`. If it is set 
higher, it is silently
+  clamped down to the soft ratio, making the soft band width zero (effectively 
disabling it).
+- Setting both ratios to the same value disables the soft band entirely — 
behaviour is then identical
+  to the pre-split single-threshold code.
+
+### Effective spare values for a 2 TB volume
+
+| Threshold | Calculation | Result |
+|-----------|-------------|--------|
+| Soft (reported) | `max(20 GB, 2 TB × 2%)` | **40 GB** |
+| Hard (local)    | `max(20 GB, 2 TB × 1.5%)` | **30 GB** |
+| Soft band width | `40 GB − 30 GB` | **10 GB** |
+
+## How Each Limit Is Used
+
+### Hard limit — local write enforcement and new container placement
+
+`getFreeSpaceToSpare(capacity)` returns the hard spare. It is checked in two 
places:
+
+1. **Write rejection** (`ContainerUtils.assertSpaceAvailability`): a write is 
rejected with
+   `DISK_OUT_OF_SPACE` when `available − hardSpare < requestedBytes`. This is 
the definitive gate
+   that prevents disk exhaustion.
+
+2. **New container placement** (`AvailableSpaceFilter`, 
`PendingContainerTracker`): SCM only
+   schedules a new container on a volume when `available − committed − 
hardSpare > maxContainerSize`.
+   This ensures that containers are placed only where writes will actually 
succeed.
+
+### Soft limit — SCM visibility and proactive close-container actions
+
+`getReportedFreeSpaceToSpare(capacity)` returns the soft spare. It appears in 
two places:
+
+1. **Storage heartbeat** (`HddsVolume.reportBuilder`): each storage report 
sent from the Datanode to
+   SCM carries `freeSpaceToSpare = softSpare`. SCM uses this value to gauge 
how much usable space
+   remains on the volume when making placement decisions for new pipelines and 
replication.
+
+2. **Soft-band metric** (`ContainerUtils.assertSpaceAvailability`, 
`AvailableSpaceFilter`):
+   when a write (or container placement attempt) succeeds the hard check but 
`available − softSpare`
+   is below the required size, the Datanode increments
+   `numWriteRequestsInSoftBandMinFreeSpace` (for writes) or
+   `numContainerCreateRequestsInSoftBandMinFreeSpace` (for container 
placement). These metrics
+   alert operators that the volume is inside the warning zone.
+
+## The Soft Band: Improving Write Continuity Near Capacity Limits
+
+Without a soft band, a single threshold would have to serve two conflicting 
goals:
+- **High enough** to give SCM enough lead time to stop routing work to a 
nearly-full node.
+- **Low enough** to maximise usable disk space and avoid premature write 
rejection.
+
+Splitting the threshold resolves the conflict:
+
+```
+  Disk capacity
+  ─────────────────────────────────────────────────────────────────
+  usable data space (writes accepted here)
+  ─────────────────────────────────────────── ← hard spare (30 GB)
+  soft band (10 GB warning zone):
+    writes still accepted, but DN metrics fire and SCM starts
+    steering new work away from this volume
+  ─────────────────────────────────────────── ← soft spare (40 GB)
+  reserved buffer (SCM sees this as "full")
+  ─────────────────────────────────────────────────────────────────
+```
+
+**What happens as a volume fills up:**
+
+1. **Above soft spare (> 40 GB free):** Normal operation.
+
+2. **Inside the soft band (30 – 40 GB free):** Writes to existing open 
containers still succeed.
+   The `InSoftBand` metrics increment, and because SCM already sees 
`freeSpaceToSpare = 40 GB` via
+   the heartbeat, it stops preferring this volume for new pipelines. The 
Datanode may send
+   close-container actions (see [Full Volume 
Handling](full-volume-handling.md)) to speed up
+   migration.
+
+3. **Below hard spare (< 30 GB free):** Writes are rejected. The 
`RejectedHardMinFreeSpace` metrics
+   increment. No new containers are placed on this volume by SCM.
+
+**Why this improves write continuity:** without the band, SCM would only learn 
a volume is nearly
+full when write rejections start happening. With the band, SCM gets advance 
warning via a smaller
+reported `freeSpaceToSpare` and can route new containers to other volumes 
*before* the hard limit is
+hit, reducing client-visible write failures.
+
+## Turning Off the Soft Band
+
+To disable the soft band (equivalent to single-threshold behaviour), set the 
two ratios to the same
+value:
+
+```xml
+<property>
+  <name>hdds.datanode.volume.min.free.space.percent</name>
+  <value>0.02</value>
+</property>
+<property>
+  <name>hdds.datanode.volume.min.free.space.hard.limit.percent</name>
+  <value>0.02</value>
+</property>
+```
+
+When both ratios are equal, `softSpare == hardSpare`, the soft band width is 
zero, and the
+`InSoftBand` metrics never fire.
+
 
diff --git 
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/dn/TestDatanodeMinFreeSpaceIntegration.java
 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/dn/TestDatanodeMinFreeSpaceIntegration.java
new file mode 100644
index 00000000000..ca0e231a207
--- /dev/null
+++ 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/dn/TestDatanodeMinFreeSpaceIntegration.java
@@ -0,0 +1,121 @@
+/*
+ * 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.ozone.dn;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.apache.hadoop.hdds.HddsConfigKeys.HDDS_HEARTBEAT_INTERVAL;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.function.BooleanSupplier;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.protocol.DatanodeDetails;
+import 
org.apache.hadoop.hdds.protocol.proto.StorageContainerDatanodeProtocolProtos.StorageReportProto;
+import org.apache.hadoop.hdds.scm.node.DatanodeInfo;
+import org.apache.hadoop.hdds.scm.node.NodeManager;
+import org.apache.hadoop.hdds.scm.server.StorageContainerManager;
+import org.apache.hadoop.ozone.HddsDatanodeService;
+import org.apache.hadoop.ozone.MiniOzoneCluster;
+import 
org.apache.hadoop.ozone.container.common.statemachine.DatanodeConfiguration;
+import org.apache.ozone.test.GenericTestUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+/**
+ * Integration tests: For min free space as hard and soft limit.
+ */
+@Timeout(300)
+public class TestDatanodeMinFreeSpaceIntegration {
+
+  @Test
+  public void storageReportsAtScmMatchSoftMinFreeSpaceFromConfig() throws 
Exception {
+    OzoneConfiguration conf = new OzoneConfiguration();
+    conf.unset(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE);
+    
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_PERCENT,
 0.03f);
+    
conf.setFloat(DatanodeConfiguration.HDDS_DATANODE_VOLUME_MIN_FREE_SPACE_HARD_LIMIT_PERCENT,
 0.015f);
+    conf.setTimeDuration(HDDS_HEARTBEAT_INTERVAL, 2, SECONDS);
+
+    DatanodeConfiguration dnConf = conf.getObject(DatanodeConfiguration.class);
+
+    try (MiniOzoneCluster cluster = MiniOzoneCluster.newBuilder(conf)
+        .setNumDatanodes(1)
+        .build()) {
+      cluster.waitForClusterToBeReady();
+      cluster.waitTobeOutOfSafeMode();
+
+      HddsDatanodeService dnService = cluster.getHddsDatanodes().get(0);
+      DatanodeDetails dn = dnService.getDatanodeDetails();
+
+      StorageContainerManager scm = cluster.getStorageContainerManager();
+      NodeManager nm = scm.getScmNodeManager();
+
+      BooleanSupplier softSpareVisibleAtScm =
+          () -> storageReportsMatchSoftMinFree(nm, dn, dnConf);
+      GenericTestUtils.waitFor(softSpareVisibleAtScm, 500, 120_000);
+
+      DatanodeInfo info = nm.getDatanodeInfo(dn);
+      assertNotNull(info);
+      assertFalse(info.getStorageReports().isEmpty());
+
+      for (StorageReportProto report : info.getStorageReports()) {
+        if (report.getFailed()) {
+          continue;
+        }
+        long capacity = report.getCapacity();
+        assertTrue(capacity > 0, "data volume should have positive capacity");
+
+        long expectedSoft = dnConf.getMinFreeSpace(capacity);
+        long expectedHard = dnConf.getHardLimitMinFreeSpace(capacity);
+        long expectedBand = dnConf.getSoftBandMinFreeSpaceWidth(capacity);
+
+        assertEquals(expectedSoft, report.getFreeSpaceToSpare(),
+            "freeSpaceToSpare in SCM storage report should match soft min-free 
for capacity");
+        assertThat(expectedSoft).isGreaterThanOrEqualTo(expectedHard);
+        assertThat(expectedBand).isGreaterThan(0L);
+        assertEquals(expectedBand, expectedSoft - expectedHard);
+      }
+    }
+  }
+
+  /**
+   * SCM has caught up with DN heartbeats: every non-failed data report's 
{@code freeSpaceToSpare}
+   * equals the configured soft min-free for that volume capacity.
+   */
+  private static boolean storageReportsMatchSoftMinFree(
+      NodeManager nm, DatanodeDetails dn, DatanodeConfiguration dnConf) {
+    DatanodeInfo info = nm.getDatanodeInfo(dn);
+    if (info == null || info.getStorageReports().isEmpty()) {
+      return false;
+    }
+    boolean anyDataVolume = false;
+    for (StorageReportProto r : info.getStorageReports()) {
+      if (r.getFailed() || r.getCapacity() <= 0) {
+        continue;
+      }
+      anyDataVolume = true;
+      long expectedSoft = dnConf.getMinFreeSpace(r.getCapacity());
+      if (expectedSoft != r.getFreeSpaceToSpare()) {
+        return false;
+      }
+    }
+    return anyDataVolume;
+  }
+}


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

Reply via email to