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

jojochuang 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 7fe5962ce04 HDDS-16220. Reduce OM bucket write-lock hold time during 
FSO directory purge (#11118)
7fe5962ce04 is described below

commit 7fe5962ce0474559a710ad87f823f9ca7d856516
Author: Andrey Yarovoy <[email protected]>
AuthorDate: Tue Sep 22 03:10:03 2026 -0400

    HDDS-16220. Reduce OM bucket write-lock hold time during FSO directory 
purge (#11118)
    
    Generated-by: Claude Opus 4.8 <[email protected]>
---
 .../apache/hadoop/hdds/utils/db/CodecBuffer.java   |   9 +
 .../TestOmMixedWorkloadUnderDeletionBench.java     | 725 +++++++++++++++++++++
 .../java/org/apache/hadoop/ozone/om/OMMetrics.java |   4 +-
 .../key/OMDirectoriesPurgeRequestWithFSO.java      | 564 +++++++++++-----
 .../ozone/om/service/DirectoryDeletingService.java |  58 +-
 .../key/TestOMDirectoriesPurgeApplyPerf.java       | 226 +++++++
 .../TestOMDirectoriesPurgeRequestAndResponse.java  |  86 +++
 .../om/service/TestDirectoryDeletingService.java   |  60 +-
 8 files changed, 1554 insertions(+), 178 deletions(-)

diff --git 
a/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/utils/db/CodecBuffer.java
 
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/utils/db/CodecBuffer.java
index e9108112bd4..00de3bd2525 100644
--- 
a/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/utils/db/CodecBuffer.java
+++ 
b/hadoop-hdds/common/src/main/java/org/apache/hadoop/hdds/utils/db/CodecBuffer.java
@@ -114,6 +114,15 @@ public static void enableLeakDetection() {
     Factory.set(LeakDetector::newCodecBuffer, "LeakDetector::newCodecBuffer");
   }
 
+  /**
+   * Restore the default (non-leak-detecting) buffer factory, reverting a 
previous
+   * {@link #enableLeakDetection()}. Intended for performance-sensitive 
measurements that need to avoid the
+   * per-allocation finalizer registration the leak detector adds.
+   */
+  public static void disableLeakDetection() {
+    Factory.set(CodecBuffer::new, "CodecBuffer::new");
+  }
+
   /** The size of a buffer. */
   public static class Capacity {
     private final Object name;
diff --git 
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/om/service/TestOmMixedWorkloadUnderDeletionBench.java
 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/om/service/TestOmMixedWorkloadUnderDeletionBench.java
new file mode 100644
index 00000000000..2f86b90eb99
--- /dev/null
+++ 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/om/service/TestOmMixedWorkloadUnderDeletionBench.java
@@ -0,0 +1,725 @@
+/*
+ * 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.om.service;
+
+import static 
org.apache.hadoop.fs.CommonConfigurationKeysPublic.FS_DEFAULT_NAME_KEY;
+import static 
org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_BLOCK_DELETING_SERVICE_INTERVAL;
+import static 
org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_FS_ITERATE_BATCH_SIZE;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.security.AccessController;
+import java.security.PrivilegedActionException;
+import java.security.PrivilegedExceptionAction;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.hadoop.fs.FSDataInputStream;
+import org.apache.hadoop.fs.FSDataOutputStream;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.protocol.StorageType;
+import org.apache.hadoop.hdds.utils.db.CodecBuffer;
+import org.apache.hadoop.hdds.utils.db.Table;
+import org.apache.hadoop.ozone.MiniOzoneCluster;
+import org.apache.hadoop.ozone.OzoneConsts;
+import org.apache.hadoop.ozone.client.BucketArgs;
+import org.apache.hadoop.ozone.client.OzoneBucket;
+import org.apache.hadoop.ozone.client.OzoneClient;
+import org.apache.hadoop.ozone.client.OzoneVolume;
+import org.apache.hadoop.ozone.om.OMConfigKeys;
+import org.apache.hadoop.ozone.om.OMMetadataManager;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
+import org.apache.hadoop.util.concurrent.HadoopExecutors;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * End-to-end benchmark reproducing the interactive-workload degradation seen 
when a large background deletion backlog
+ * is being reclaimed on the same bucket that clients are actively writing to.
+ *
+ * <p>Every OM write is applied on a single serial state-machine apply thread 
under a per-bucket write lock. Reclaiming
+ * a deletion backlog drives {@code PurgeDirectories} transactions through 
that thread; when the deleted directories are
+ * densely populated, a single purge batch moves a large number of 
sub-files/sub-dirs under one write-lock hold, so the
+ * apply thread — and the bucket write lock — is occupied for the whole batch. 
Concurrent user {@code create},
+ * {@code mkdir} and {@code rename} contend for that same lock and thread, and 
reads contend for the bucket read lock,
+ * so their latency degrades while the backlog drains.
+ *
+ * <p>This benchmark stages two sets under a single volume and FSO bucket: a 
densely-populated <em>backlog</em> subtree
+ * that is recursively deleted and drained, and a separate stable 
<em>workload</em> set that is never deleted during the
+ * run (a pre-staged dataset the read ops resolve against, plus a per-thread 
scratch area the write ops create into).
+ * Sharing the bucket is intentional — deletion and the client workload 
contend on the same bucket lock, while the
+ * workload always hits live paths. It then compares a mixed client workload 
on that bucket — create, mkdir, rename and
+ * a data-plane file write (create + write a block), plus a data-plane file 
read and the metadata read RPCs that take
+ * the bucket read lock (getFileStatus, listStatus, getBucketInfo, lookupKey) 
— in two conditions:
+ * <ul>
+ *   <li><b>control</b> — no deletion running, and</li>
+ *   <li><b>under load</b> — the same workload while the backlog subtree is 
recursively deleted and fully purged from
+ *       OM in the background (both FSO phases: moved into the deletedTable, 
then purged back out),</li>
+ * </ul>
+ * reporting per-operation p50/p99 latency and the under-load degradation, so 
the two code versions can be compared on
+ * how much apply-thread purge work bleeds into interactive latency. It also 
reports the phase-1 drain time — the
+ * {@code DirectoryDeletingService} move into the deletedTable via {@code 
OMDirectoriesPurgeRequestWithFSO}, the apply
+ * path this change optimizes — separately from the full both-phase drain, so 
pure apply-thread throughput can be
+ * compared alongside the interactive degradation.
+ *
+ * <p>Deletion is configured with production-representative per-task limits so 
batches are large. The {@code benchmark}
+ * tag is excluded from {@code mvn test} and CI by default, so it must be 
re-enabled explicitly to run on demand
+ * (rebuild the reactor first to avoid stale-class errors):
+ * <pre>
+ *   mvn -pl :ozone-integration-test test -DskipShade -DskipRecon \
+ *     -Dtest=TestOmMixedWorkloadUnderDeletionBench -Dgroups=benchmark 
-Dexcluded-test-groups= \
+ *     -Dsurefire.failIfNoSpecifiedTests=false 
-Djunit.jupiter.execution.timeout.default=20m
+ * </pre>
+ * <p>The timeout override belongs in the run command rather than a per-test 
annotation: the repository-wide
+ * {@code junit.jupiter.execution.timeout.default} is 5 minutes, which is 
shorter than staging the backlog plus the
+ * benchmark's own 900s phase-1 drain deadline, so without it a run is killed 
mid-drain before that deadline reports
+ * anything.
+ *
+ * <p>Tunables: {@code bench.backlogDirs} (default 80), {@code 
bench.backlogFilesPerDir} (default 1000),
+ * {@code bench.backlogNonEmptyEvery} (default 3 — every 3rd backlog file is 
written with a block, the rest are
+ * empty so a large backlog stays cheap to stage), {@code bench.workloadDirs} 
(default 20) and
+ * {@code bench.workloadFilesPerDir} (default 100) sizing the stable dataset 
the read ops resolve against,
+ * {@code bench.fileBytes} (default 1 MiB) sizing the data-plane file 
write/read payload (and the block-bearing
+ * staged files the reads pull), {@code bench.clientThreads} (default 4),
+ * {@code bench.opsPerThread} (default 400), {@code 
bench.pathDeletingLimitPerTask} (default 2000) and
+ * {@code bench.keyDeletingLimitPerTask} (default 40000). The last two size 
how much a single deletion round gathers;
+ * with the Ratis appender byte limit non-binding at these entry sizes, a 
round's paths pack into one purge
+ * transaction, so raising them makes each apply move far more entries under a 
single bucket write-lock hold — the
+ * regime where the apply-thread per-entry cost dominates interactive latency.
+ *
+ * <p>Adding {@code -Dbench.profile.event=<cpu|lock|wall|alloc>} profiles only 
the under-load window with
+ * async-profiler, loaded reflectively from a local install whose paths must 
be supplied via
+ * {@code -Dbench.profiler.jar} (the async-profiler jar) and {@code 
-Dbench.profiler.lib} (its native library); the
+ * JFR is written under {@code -Dbench.profile.out}, default {@code /tmp}. For 
accurate leaf frames also pass
+ * {@code -DargLine="-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints"}.
+ */
+@Tag("benchmark")
+public class TestOmMixedWorkloadUnderDeletionBench {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(TestOmMixedWorkloadUnderDeletionBench.class);
+
+  private static final String OP_CREATE = "create";
+  private static final String OP_MKDIR = "mkdir";
+  private static final String OP_RENAME = "rename";
+  private static final String OP_FILEWRITE = "filewrite";
+  private static final String OP_FILEREAD = "fileread";
+  private static final String OP_GETFILESTATUS = "getfilestatus";
+  private static final String OP_LISTSTATUS = "liststatus";
+  private static final String OP_INFOBUCKET = "infobucket";
+  private static final String OP_GETKEYINFO = "getkeyinfo";
+  private static final String[] OPS =
+      {OP_CREATE, OP_MKDIR, OP_RENAME, OP_FILEWRITE, OP_FILEREAD,
+       OP_GETFILESTATUS, OP_LISTSTATUS, OP_INFOBUCKET, OP_GETKEYINFO};
+
+  // The three sandboxes share the parent /workload/bucket but are separate 
subtrees; only the backlog is deleted.
+  // The deletion set (built, recursively deleted, then drained through the 
apply thread).
+  private static final String BACKLOG_ROOT = "workload/bucket/backlog";
+  // The workload set — never deleted during the test: a stable pre-staged 
dataset the read ops resolve against, plus
+  // a scratch area the write ops create into. Keeping this separate from the 
deletion set means the concurrent
+  // operations always hit live paths while the backlog drains.
+  private static final String WORKLOAD_DATA_ROOT = "workload/bucket/data";
+  private static final String WORKLOAD_SCRATCH_ROOT = 
"workload/bucket/scratch";
+
+  // Every bench.backlogNonEmptyEvery-th backlog file is written with a single 
block so its KeyInfo carries a
+  // key-location list, exercising the block-metadata parse/serialize the 
purge apply and flush paths hit in
+  // production. The rest are left empty because block-bearing files are far 
more expensive to stage (block
+  // allocation + datanode write + commit), and a large backlog is what 
actually stresses the apply thread.
+  private static final byte[] FILE_CONTENT = new byte[4];
+
+  /**
+   * Removes test-harness-only overhead that would otherwise distort the 
apply/flush cost under measurement: the
+   * mini-cluster unconditionally enables {@link CodecBuffer} leak detection 
(a per-allocation finalizer), and the test
+   * log config runs the {@code CodecBuffer}/managed-RocksDB loggers at 
DEBUG/TRACE, which capture a full stack trace on
+   * every buffer allocation. Neither happens in a production OM running at 
INFO.
+   *
+   * <p>Both settings are JVM-global, so the returned action must be run once 
the benchmark is done to keep them from
+   * leaking into any later test sharing the same Maven fork.
+   */
+  private static Runnable stripTestOnlyOverhead() {
+    org.apache.log4j.Logger codecBufferLogger =
+        
org.apache.log4j.Logger.getLogger("org.apache.hadoop.hdds.utils.db.CodecBuffer");
+    org.apache.log4j.Logger managedRocksLogger =
+        
org.apache.log4j.Logger.getLogger("org.apache.hadoop.hdds.utils.db.managed");
+    org.apache.log4j.Level codecBufferLevel = codecBufferLogger.getLevel();
+    org.apache.log4j.Level managedRocksLevel = managedRocksLogger.getLevel();
+
+    CodecBuffer.disableLeakDetection();
+    codecBufferLogger.setLevel(org.apache.log4j.Level.INFO);
+    managedRocksLogger.setLevel(org.apache.log4j.Level.INFO);
+
+    return () -> {
+      CodecBuffer.enableLeakDetection();
+      codecBufferLogger.setLevel(codecBufferLevel);
+      managedRocksLogger.setLevel(managedRocksLevel);
+    };
+  }
+
+  @Test
+  public void benchmarkMixedWorkloadUnderDeletionLoad() throws Exception {
+    final String profileEvent = System.getProperty("bench.profile.event", "");
+
+    // Number of FSO buckets (in one volume) the backlog and workload are 
spread across. With more than one bucket a
+    // background purge round gathers deleted dirs from several buckets, so an 
ungrouped DirectoryDeletingService packs
+    // multiple buckets into one purge transaction and the apply path holds 
all their write locks together; per-bucket
+    // grouping keeps each transaction single-bucket. backlogDirs below is the 
TOTAL across buckets, split evenly.
+    final int numBuckets = Integer.getInteger("bench.numBuckets", 4);
+    final int backlogDirs = Integer.getInteger("bench.backlogDirs", 80);
+    final int backlogFilesPerDir = 
Integer.getInteger("bench.backlogFilesPerDir", 1000);
+    final int nonEmptyEvery = Integer.getInteger("bench.backlogNonEmptyEvery", 
3);
+    // Size of the stable workload dataset the read ops resolve against (never 
deleted during the test).
+    final int workloadDirs = Integer.getInteger("bench.workloadDirs", 20);
+    final int workloadFilesPerDir = 
Integer.getInteger("bench.workloadFilesPerDir", 100);
+    // Payload for the data-plane write/read ops and the block-bearing staged 
workload files those reads hit.
+    final int fileBytes = Integer.getInteger("bench.fileBytes", 1024 * 1024);
+    final int clientThreads = Integer.getInteger("bench.clientThreads", 4);
+    final int opsPerThread = Integer.getInteger("bench.opsPerThread", 400);
+    final int pathDeletingLimit = 
Integer.getInteger("bench.pathDeletingLimitPerTask", 2000);
+    final int keyDeletingLimit = 
Integer.getInteger("bench.keyDeletingLimitPerTask", 40000);
+    // Phase-1 (DirectoryDeletingService) cadence. The interval is read in 
MILLISECONDS (KeyManagerImpl), so 1000
+    // gives a genuine 1s gate between purge rounds: each round moves up to 
pathDeletingLimit paths under one bucket
+    // write lock — the apply path this change optimizes — and gating spreads 
phase 1 into a long, sampled window.
+    final int dirDeletingIntervalMs = 
Integer.getInteger("bench.dirDeletingIntervalMs", 1000);
+    // Phase-2 (KeyDeletingService) is unchanged by this optimization; push 
its interval past the phase-1 window so it
+    // does not run during measurement and only phase-1 contention is sampled.
+    final int blockDeletingIntervalSec = 
Integer.getInteger("bench.blockDeletingIntervalSec", 600);
+    final String ratisAppenderByteLimit = 
System.getProperty("bench.ratisAppenderByteLimit",
+        OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT_DEFAULT);
+    final int perBucketBacklogDirs = Math.max(1, backlogDirs / numBuckets);
+    final int backlogFiles = numBuckets * perBucketBacklogDirs * 
backlogFilesPerDir;
+
+    OzoneConfiguration conf = new OzoneConfiguration();
+    // Gate phase 1 (DirectoryDeletingService) at a genuine 1s interval so it 
moves pathDeletingLimit paths per round
+    // under one apply-thread bucket write-lock hold — the path this change 
optimizes — spreading phase 1 into a long,
+    // sampled window while the client workload runs. Phase 2 
(KeyDeletingService) is unchanged by the optimization,
+    // so its interval is pushed past the window to keep it out of measurement.
+    conf.setInt(OMConfigKeys.OZONE_DIR_DELETING_SERVICE_INTERVAL, 
dirDeletingIntervalMs);
+    conf.setTimeDuration(OZONE_BLOCK_DELETING_SERVICE_INTERVAL, 
blockDeletingIntervalSec, TimeUnit.SECONDS);
+    conf.setInt(OMConfigKeys.OZONE_PATH_DELETING_LIMIT_PER_TASK, 
pathDeletingLimit);
+    conf.setInt(OMConfigKeys.OZONE_KEY_DELETING_LIMIT_PER_TASK, 
keyDeletingLimit);
+    conf.set(OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 
ratisAppenderByteLimit);
+    conf.setInt(OZONE_FS_ITERATE_BATCH_SIZE, 1000);
+
+    MiniOzoneCluster cluster = MiniOzoneCluster.newBuilder(conf)
+        .setNumDatanodes(3)
+        .build();
+    // Strip after build: MiniOzoneClusterImpl's static initializer enables 
CodecBuffer leak detection when the class
+    // is first loaded here, so disabling it earlier would be undone before 
any measurement.
+    Runnable restoreTestOnlyOverhead = stripTestOnlyOverhead();
+    try {
+      cluster.waitForClusterToBeReady();
+      DirectoryDeletingService dds = 
cluster.getOzoneManager().getKeyManager().getDirDeletingService();
+      OMMetadataManager mm = cluster.getOzoneManager().getMetadataManager();
+
+      try (OzoneClient client = cluster.newClient()) {
+        BucketFs env = createBuckets(client, conf, numBuckets);
+        try {
+          // Stage the deletion set (recursively deleted and drained below) 
and the stable workload set — a dataset the
+          // read ops resolve against plus a per-thread scratch area the write 
ops create into — in every bucket, so the
+          // workload always hits live paths in the same buckets the purge is 
draining. Backlog files carry only a tiny
+          // block (one key-location for the purge to parse); workload files 
carry the full fileBytes payload.
+          byte[] fileContent = new byte[fileBytes];
+          for (int b = 0; b < numBuckets; b++) {
+            buildDenseTree(env.fs[b], new Path("/" + BACKLOG_ROOT), 
perBucketBacklogDirs, backlogFilesPerDir,
+                nonEmptyEvery, FILE_CONTENT);
+            buildDenseTree(env.fs[b], new Path("/" + WORKLOAD_DATA_ROOT), 
workloadDirs, workloadFilesPerDir,
+                nonEmptyEvery, fileContent);
+            env.fs[b].mkdirs(new Path("/" + WORKLOAD_SCRATCH_ROOT));
+          }
+          WorkloadDataset dataset = new WorkloadDataset(workloadDirs, 
workloadFilesPerDir, nonEmptyEvery, fileContent);
+
+          // Control: interactive latency on the hot buckets with no deletion 
running.
+          Percentiles[] control;
+          try (RunningWorkload controlWl = startMixedWorkload(env.fs, 
env.volume, env.buckets, dataset, clientThreads,
+              opsPerThread, "control")) {
+            control = toPercentiles(controlWl.await(), "control");
+          }
+
+          // Profile only the under-load window when 
-Dbench.profile.event=<cpu|lock|wall|alloc> is set. async-profiler
+          // is loaded reflectively from -Dbench.profiler.jar / 
-Dbench.profiler.lib and a JFR recording is written to
+          // -Dbench.profile.out (default /tmp). For accurate leaf frames add
+          // -DargLine="-XX:+UnlockDiagnosticVMOptions 
-XX:+DebugNonSafepoints".
+          Profiler profiler = profileEvent.isEmpty() ? null : Profiler.load();
+          String profileOut = null;
+          if (profiler != null) {
+            profileOut = System.getProperty("bench.profile.out", "/tmp") + 
"/prof-mixed-" + profileEvent + ".jfr";
+            profiler.start(profileEvent, profileOut);
+          }
+
+          // Run the interactive workload continuously in background threads, 
sampling throughout phase 1, and stop it
+          // the moment phase 1 completes so the under-load percentiles 
reflect only the optimized, contended window.
+          // Phase 1 (DirectoryDeletingService moving sub-files/sub-dirs into 
the deletedTable) takes the bucket write
+          // lock on the apply thread — the path this change optimizes; every 
bucket's backlog is deleted here so purge
+          // rounds span buckets. Phase 2 (KeyDeletingService draining the 
deletedTable) is unchanged by the change and
+          // is kept out of the window by a long block-deleting interval, so 
it is neither sampled nor waited on.
+          Table<String, ?> deletedDirTable = mm.getDeletedDirTable();
+          long movedFilesBefore = dds.getMovedFilesCount();
+          long start = System.nanoTime();
+          long drainDeadline = start + TimeUnit.SECONDS.toNanos(900);
+          try (RunningWorkload underLoadWl = startMixedWorkload(env.fs, 
env.volume, env.buckets, dataset,
+              clientThreads, 0, "under-load")) {
+            for (int b = 0; b < numBuckets; b++) {
+              env.fs[b].delete(new Path("/" + BACKLOG_ROOT), true);
+            }
+            LOG.info("delete(recursive) issued on {} buckets; backlog draining 
in background", numBuckets);
+
+            long phase1DrainNanos;
+            try {
+              phase1DrainNanos = awaitPhase1Drain(dds, mm, deletedDirTable, 
movedFilesBefore, backlogFiles,
+                  underLoadWl, start, drainDeadline);
+            } finally {
+              underLoadWl.stop();
+              if (profiler != null) {
+                profiler.stop();
+              }
+            }
+            double phase1DrainMs = phase1DrainNanos / 1_000_000.0;
+            List<List<Long>> underLoadSamples = underLoadWl.await();
+            Percentiles[] underLoad = toPercentiles(underLoadSamples, 
"under-load");
+            long underLoadOps = totalOps(underLoadSamples);
+            String header = String.format(Locale.ROOT,
+                "BENCH mixed numBuckets=%d backlogFiles=%d threads=%d 
opsPerThread=%d ratisByteLimit=%s "
+                    + "underLoadOps=%d phase1DrainMs=%.1f",
+                numBuckets, backlogFiles, clientThreads, opsPerThread, 
ratisAppenderByteLimit, underLoadOps,
+                phase1DrainMs);
+            printBenchLine(control, underLoad, header);
+            if (profileOut != null) {
+              System.out.printf(Locale.ROOT, "BENCH profile event=%s 
out=%s%n", profileEvent, profileOut);
+            }
+          }
+        } finally {
+          for (FileSystem f : env.fs) {
+            org.apache.hadoop.io.IOUtils.closeStream(f);
+          }
+        }
+      }
+    } finally {
+      // Restore last, so the buffer-heavy cluster shutdown still runs without 
the leak detector's per-allocation
+      // finalizer, and restore unconditionally so a failing shutdown cannot 
leak the stripped state into later tests.
+      try {
+        cluster.shutdown();
+      } finally {
+        restoreTestOnlyOverhead.run();
+      }
+    }
+  }
+
+  /**
+   * Blocks until phase 1 (DirectoryDeletingService) finishes: every backlog 
sub-file has been moved (cheap counter)
+   * and the deletedDirTable has been fully drained of sub-dirs. 
countRowsInTable scans the table, so it is only probed
+   * once the moved-files counter shows the sub-files are all moved. Phase 2 
(deletedTable purge) is out of scope.
+   * Returns the drain duration in nanos, or -1 if the workload failed before 
the drain completed.
+   */
+  @SuppressWarnings("checkstyle:ParameterNumber")
+  private static long awaitPhase1Drain(DirectoryDeletingService dds, 
OMMetadataManager mm,
+      Table<String, ?> deletedDirTable, long movedFilesBefore, long 
backlogFiles, RunningWorkload underLoadWl,
+      long start, long drainDeadline) throws Exception {
+    while (true) {
+      long moved = dds.getMovedFilesCount() - movedFilesBefore;
+      if (moved >= backlogFiles && mm.countRowsInTable(deletedDirTable) == 0) {
+        return System.nanoTime() - start;
+      }
+      if (underLoadWl.failed()) {
+        return -1;
+      }
+      if (System.nanoTime() > drainDeadline) {
+        throw new IllegalStateException("phase 1 did not drain within 900s: 
moved=" + moved
+            + " expected=" + backlogFiles
+            + " deletedDirTableRows=" + mm.countRowsInTable(deletedDirTable));
+      }
+      Thread.sleep(200);
+    }
+  }
+
+  private void buildDenseTree(FileSystem fs, Path root, int dirs, int 
filesPerDir, int nonEmptyEvery,
+      byte[] blockContent) throws Exception {
+    long buildStart = System.nanoTime();
+    // Staging is client/RPC-round-trip bound, not apply-thread bound, so more 
concurrent creators speed it up
+    // nearly linearly; a large backlog is otherwise the long pole of the run. 
Setup-only — does not affect the
+    // measured control/under-load workload.
+    ExecutorService pool = 
Executors.newFixedThreadPool(Integer.getInteger("bench.stagingThreads", 48));
+    List<Future<?>> futures = new ArrayList<>(dirs);
+    for (int d = 0; d < dirs; d++) {
+      final Path dir = new Path(root, "dir" + d);
+      futures.add(pool.submit(() -> {
+        fs.mkdirs(dir);
+        for (int f = 0; f < filesPerDir; f++) {
+          Path file = new Path(dir, "f" + f);
+          if (nonEmptyEvery > 0 && f % nonEmptyEvery == 0) {
+            try (FSDataOutputStream os = fs.create(file, true)) {
+              os.write(blockContent);
+            }
+          } else {
+            fs.create(file, true).close();
+          }
+        }
+        return null;
+      }));
+    }
+    try {
+      for (Future<?> future : futures) {
+        future.get();
+      }
+    } finally {
+      HadoopExecutors.shutdown(pool, LOG, 120, TimeUnit.SECONDS);
+    }
+    LOG.info("Built dense backlog: {} files in {} dirs, every {}-th with a 
block ({} ms)",
+        dirs * filesPerDir, dirs, nonEmptyEvery, (System.nanoTime() - 
buildStart) / 1_000_000);
+  }
+
+  /** Creates one volume with {@code numBuckets} FSO buckets and one {@code 
o3fs} FileSystem rooted at each bucket, so
+   * the backlog and workload can be spread across buckets and a purge round 
can gather deleted dirs from several of
+   * them. Returned FileSystems must be closed by the caller. */
+  private BucketFs createBuckets(OzoneClient client, OzoneConfiguration conf, 
int numBuckets) throws IOException {
+    OzoneBucket first = 
org.apache.hadoop.ozone.DataTestUtil.createVolumeAndBucket(client,
+        BucketLayout.FILE_SYSTEM_OPTIMIZED);
+    OzoneVolume volume = 
client.getObjectStore().getVolume(first.getVolumeName());
+    OzoneBucket[] buckets = new OzoneBucket[numBuckets];
+    FileSystem[] fs = new FileSystem[numBuckets];
+    buckets[0] = first;
+    BucketArgs args = BucketArgs.newBuilder().setStorageType(StorageType.DISK)
+        .setBucketLayout(BucketLayout.FILE_SYSTEM_OPTIMIZED).build();
+    for (int b = 1; b < numBuckets; b++) {
+      String name = first.getName() + "-" + b;
+      volume.createBucket(name, args);
+      buckets[b] = volume.getBucket(name);
+    }
+    for (int b = 0; b < numBuckets; b++) {
+      OzoneConfiguration bucketConf = new OzoneConfiguration(conf);
+      bucketConf.set(FS_DEFAULT_NAME_KEY, String.format("%s://%s.%s/", 
OzoneConsts.OZONE_URI_SCHEME,
+          buckets[b].getName(), volume.getName()));
+      fs[b] = FileSystem.get(bucketConf);
+    }
+    return new BucketFs(volume, buckets, fs);
+  }
+
+  /** One volume, its FSO buckets, and the per-bucket {@code o3fs} FileSystems 
(index-aligned with {@code buckets}). */
+  private static final class BucketFs {
+    private final OzoneVolume volume;
+    private final OzoneBucket[] buckets;
+    private final FileSystem[] fs;
+
+    BucketFs(OzoneVolume volume, OzoneBucket[] buckets, FileSystem[] fs) {
+      this.volume = volume;
+      this.buckets = buckets;
+      this.fs = fs;
+    }
+  }
+
+  /**
+   * Starts {@code threads} client threads spread round-robin across the hot 
buckets (thread {@code t} is pinned to
+   * bucket {@code t % buckets.length} and its FileSystem), each cycling 
through the {@link #OPS} mix (create, mkdir,
+   * rename and a data-plane file write, plus a data-plane file read and the 
metadata read RPCs that contend for the
+   * bucket read lock: getFileStatus, listStatus, getBucketInfo and lookupKey) 
and recording per-operation nanosecond
+   * samples. When {@code opsPerThread > 0} each thread runs that fixed number 
of ops (control pass); when
+   * {@code opsPerThread <= 0} each thread runs continuously until {@link 
RunningWorkload#stop()} is called (the
+   * under-load drain window). Call {@link RunningWorkload#await()} to join 
the threads and collect the samples
+   * grouped by operation index (aligned with {@link #OPS}).
+   */
+  private RunningWorkload startMixedWorkload(FileSystem[] fs, OzoneVolume 
volume, OzoneBucket[] buckets,
+      WorkloadDataset dataset, int threads, int opsPerThread, String label) {
+    ExecutorService pool = Executors.newFixedThreadPool(threads);
+    CountDownLatch startLatch = new CountDownLatch(1);
+    AtomicBoolean failed = new AtomicBoolean(false);
+    AtomicBoolean running = new AtomicBoolean(true);
+    List<Future<List<long[]>>> futures = new ArrayList<>(threads);
+    for (int t = 0; t < threads; t++) {
+      final int threadId = t;
+      final int bucketId = t % buckets.length;
+      final FileSystem tfs = fs[bucketId];
+      final OzoneBucket bucket = buckets[bucketId];
+      final String bucketName = bucket.getName();
+      futures.add(pool.submit((Callable<List<long[]>>) () -> {
+        List<long[]> latencies = new ArrayList<>(Math.max(16, opsPerThread));
+        // Write ops create into this thread's private scratch subtree; read 
ops resolve against the pre-staged
+        // dataset. Both live under the workload set (same volume and bucket 
as the backlog), which is never deleted.
+        Path scratch = new Path("/" + WORKLOAD_SCRATCH_ROOT, label + "-" + 
threadId);
+        tfs.mkdirs(scratch);
+        Path lastCreated = null;
+        byte[] readBuf = new byte[64 * 1024];
+        startLatch.await();
+        // Fixed batch when opsPerThread > 0 (control); otherwise loop until 
stop() clears running (under-load).
+        for (int i = 0; running.get() && (opsPerThread <= 0 || i < 
opsPerThread); i++) {
+          int opIdx = i % OPS.length;
+          // rename operates on the file created in this thread's previous 
create op; fall back to a create when none
+          // is pending so the cycle never renames a missing path.
+          if (opIdx == 2 && lastCreated == null) {
+            opIdx = 0;
+          }
+          // Read ops target an existing dataset entry; the index spread keeps 
threads off a single dir.
+          int dataDir = (threadId + i) % dataset.dirs;
+          int dataFile = i % dataset.filesPerDir;
+          // The data-plane read must hit a staged file that carries a block 
(every nonEmptyEvery-th file).
+          int blockFile = (i % Math.max(1, dataset.filesPerDir / 
dataset.nonEmptyEvery)) * dataset.nonEmptyEvery;
+          long t0 = System.nanoTime();
+          try {
+            switch (opIdx) {
+            case 0:
+              Path created = new Path(scratch, "f" + i);
+              tfs.create(created, true).close();
+              lastCreated = created;
+              break;
+            case 1:
+              tfs.mkdirs(new Path(scratch, "d" + i));
+              break;
+            case 2:
+              tfs.rename(lastCreated, new Path(scratch, "f" + i + "-r"));
+              lastCreated = null;
+              break;
+            case 3:
+              // File write: create + write a real block, exercising the 
data-plane write path (block allocation,
+              // datanode write, commit) on top of the OM key create/commit.
+              try (FSDataOutputStream os = tfs.create(new Path(scratch, "w" + 
i), true)) {
+                os.write(dataset.fileContent);
+              }
+              break;
+            case 4:
+              // File read: open a staged block-bearing file and read it 
fully, exercising the data-plane read path
+              // (block-location lookup + datanode read).
+              readFully(tfs, new Path("/" + WORKLOAD_DATA_ROOT + "/dir" + 
dataDir, "f" + blockFile), readBuf);
+              break;
+            case 5:
+              tfs.getFileStatus(new Path("/" + WORKLOAD_DATA_ROOT + "/dir" + 
dataDir, "f" + dataFile));
+              break;
+            case 6:
+              tfs.listStatus(new Path("/" + WORKLOAD_DATA_ROOT, "dir" + 
dataDir));
+              break;
+            case 7:
+              volume.getBucket(bucketName);
+              break;
+            default:
+              bucket.getKey(WORKLOAD_DATA_ROOT + "/dir" + dataDir + "/f" + 
dataFile);
+              break;
+            }
+          } catch (IOException | RuntimeException e) {
+            failed.set(true);
+            throw e;
+          }
+          latencies.add(new long[] {opIdx, System.nanoTime() - t0});
+        }
+        return latencies;
+      }));
+    }
+    startLatch.countDown();
+    return new RunningWorkload(pool, futures, running, failed, label);
+  }
+
+  /**
+   * Handle to a running {@link #startMixedWorkload} run: stop the threads, 
then await and collect their samples.
+   * Closing stops the threads and shuts the pool down, so a failure anywhere 
in the measured window cannot leave the
+   * non-daemon client threads running and keep the Maven fork alive.
+   */
+  private static final class RunningWorkload implements AutoCloseable {
+    private final ExecutorService pool;
+    private final List<Future<List<long[]>>> futures;
+    private final AtomicBoolean running;
+    private final AtomicBoolean failed;
+    private final String label;
+
+    RunningWorkload(ExecutorService pool, List<Future<List<long[]>>> futures, 
AtomicBoolean running,
+        AtomicBoolean failed, String label) {
+      this.pool = pool;
+      this.futures = futures;
+      this.running = running;
+      this.failed = failed;
+      this.label = label;
+    }
+
+    void stop() {
+      running.set(false);
+    }
+
+    boolean failed() {
+      return failed.get();
+    }
+
+    @Override
+    public void close() {
+      stop();
+      HadoopExecutors.shutdown(pool, LOG, 180, TimeUnit.SECONDS);
+    }
+
+    /** Joins the client threads and returns per-operation nanosecond samples 
grouped by operation index. */
+    List<List<Long>> await() throws Exception {
+      List<List<Long>> byOp = new ArrayList<>(OPS.length);
+      for (String ignored : OPS) {
+        byOp.add(new ArrayList<>());
+      }
+      for (Future<List<long[]>> future : futures) {
+        for (long[] sample : future.get()) {
+          byOp.get((int) sample[0]).add(sample[1]);
+        }
+      }
+      if (failed.get()) {
+        throw new IllegalStateException("client thread failed during " + 
label);
+      }
+      for (int op = 0; op < OPS.length; op++) {
+        LOG.info("{} {}: ops={}", label, OPS[op], byOp.get(op).size());
+      }
+      return byOp;
+    }
+  }
+
+  /** Total number of samples across all operations (how many ops backed the 
reported percentiles). */
+  private static long totalOps(List<List<Long>> byOp) {
+    long total = 0;
+    for (List<Long> opSamples : byOp) {
+      total += opSamples.size();
+    }
+    return total;
+  }
+
+  /** Reduces per-operation nanosecond samples to {@link Percentiles} 
(index-aligned with {@link #OPS}). */
+  private static Percentiles[] toPercentiles(List<List<Long>> byOp, String 
label) {
+    Percentiles[] result = new Percentiles[OPS.length];
+    for (int op = 0; op < OPS.length; op++) {
+      result[op] = Percentiles.of(byOp.get(op));
+      LOG.info("{} {}: ops={} p50Ms={} p99Ms={}", label, OPS[op], 
byOp.get(op).size(),
+          result[op].p50, result[op].p99);
+    }
+    return result;
+  }
+
+  private static double safeRatio(double num, double den) {
+    return den <= 0 ? Double.NaN : num / den;
+  }
+
+  /** Appends the per-operation control/under-load percentiles and degradation 
ratios to {@code header} and prints
+   * the single machine-readable {@code BENCH mixed ...} line the A/B driver 
greps for. */
+  private static void printBenchLine(Percentiles[] control, Percentiles[] 
underLoad, String header) {
+    StringBuilder line = new StringBuilder(header);
+    for (int op = 0; op < OPS.length; op++) {
+      line.append(String.format(Locale.ROOT, " %s[control_p50=%.2f 
control_p99=%.2f "
+              + "underLoad_p50=%.2f underLoad_p99=%.2f deg50=%.2fx 
deg99=%.2fx]",
+          OPS[op], control[op].p50, control[op].p99, underLoad[op].p50, 
underLoad[op].p99,
+          safeRatio(underLoad[op].p50, control[op].p50), 
safeRatio(underLoad[op].p99, control[op].p99)));
+    }
+    System.out.println(line);
+  }
+
+  /** Reads {@code p} to end into the reusable {@code buf}, discarding the 
bytes — the benchmark measures the read
+   * path (block-location lookup + datanode read), not the payload. */
+  private static void readFully(FileSystem fs, Path p, byte[] buf) throws 
IOException {
+    try (FSDataInputStream in = fs.open(p)) {
+      int n;
+      do {
+        n = in.read(buf);
+      } while (n != -1);
+    }
+  }
+
+  /** Shape of the stable workload dataset the read ops resolve against 
({@code dirs} directories, each holding
+   * {@code filesPerDir} files under {@link #WORKLOAD_DATA_ROOT}, every {@code 
nonEmptyEvery}-th carrying a
+   * {@code fileContent} block), plus the payload the data-plane file write op 
writes. */
+  private static final class WorkloadDataset {
+    private final int dirs;
+    private final int filesPerDir;
+    private final int nonEmptyEvery;
+    private final byte[] fileContent;
+
+    WorkloadDataset(int dirs, int filesPerDir, int nonEmptyEvery, byte[] 
fileContent) {
+      this.dirs = dirs;
+      this.filesPerDir = filesPerDir;
+      this.nonEmptyEvery = nonEmptyEvery;
+      this.fileContent = fileContent;
+    }
+  }
+
+  /**
+   * Thin reflective wrapper over async-profiler's {@code 
one.profiler.AsyncProfiler} API, loaded at runtime from the
+   * configured jar so the benchmark carries no compile-time or Maven 
dependency on async-profiler.
+   */
+  private static final class Profiler {
+    private final Object delegate;
+    private final Method execute;
+
+    private Profiler(Object delegate, Method execute) {
+      this.delegate = delegate;
+      this.execute = execute;
+    }
+
+    static Profiler load() throws Exception {
+      String jar = requireProfilerPath("bench.profiler.jar");
+      String lib = requireProfilerPath("bench.profiler.lib");
+      try {
+        return 
AccessController.doPrivileged((PrivilegedExceptionAction<Profiler>) () -> {
+          URLClassLoader loader = new URLClassLoader(new URL[] {new 
File(jar).toURI().toURL()},
+              Profiler.class.getClassLoader());
+          Class<?> clazz = Class.forName("one.profiler.AsyncProfiler", true, 
loader);
+          Object instance = clazz.getMethod("getInstance", 
String.class).invoke(null, lib);
+          return new Profiler(instance, clazz.getMethod("execute", 
String.class));
+        });
+      } catch (PrivilegedActionException e) {
+        throw (Exception) e.getCause();
+      }
+    }
+
+    private static String requireProfilerPath(String property) {
+      String value = System.getProperty(property);
+      if (value == null || value.isEmpty()) {
+        throw new IllegalStateException("bench.profile.event is set but " + 
property + " is not; point it at your "
+            + "local async-profiler install (jar and native library) to enable 
profiling");
+      }
+      return value;
+    }
+
+    void start(String event, String jfrFile) throws Exception {
+      // JFR output (inferred from the .jfr extension) records cpu/wall sample 
intervals and every contended lock/alloc
+      // event, so a single recording converts to both a flamegraph and 
collapsed stacks afterwards.
+      execute.invoke(delegate, String.format(Locale.ROOT, 
"start,event=%s,interval=1ms,file=%s", event, jfrFile));
+    }
+
+    void stop() throws Exception {
+      execute.invoke(delegate, "stop");
+    }
+  }
+
+  /** p50/p99 in milliseconds over a set of nanosecond latency samples. */
+  private static final class Percentiles {
+    private final double p50;
+    private final double p99;
+
+    private Percentiles(double p50, double p99) {
+      this.p50 = p50;
+      this.p99 = p99;
+    }
+
+    static Percentiles of(List<Long> samplesNs) {
+      if (samplesNs.isEmpty()) {
+        return new Percentiles(Double.NaN, Double.NaN);
+      }
+      long[] sorted = 
samplesNs.stream().mapToLong(Long::longValue).sorted().toArray();
+      double p50 = sorted[(int) (sorted.length * 0.50)] / 1_000_000.0;
+      double p99 = sorted[Math.min(sorted.length - 1, (int) (sorted.length * 
0.99))] / 1_000_000.0;
+      return new Percentiles(p50, p99);
+    }
+  }
+}
diff --git 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OMMetrics.java
 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OMMetrics.java
index 426501c1866..57697fb4b01 100644
--- 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OMMetrics.java
+++ 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OMMetrics.java
@@ -883,8 +883,8 @@ public void incNumKeyDeletes() {
     numKeyDeletes.incr();
   }
 
-  public void incNumKeyDeletesInternal() {
-    numKeyDeletes.incr();
+  public void incNumKeyDeletesInternal(long count) {
+    numKeyDeletes.incr(count);
   }
 
   public void incNumKeyDeletes(int count) {
diff --git 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMDirectoriesPurgeRequestWithFSO.java
 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMDirectoriesPurgeRequestWithFSO.java
index e2ca3a76136..34a47f1c256 100644
--- 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMDirectoriesPurgeRequestWithFSO.java
+++ 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMDirectoriesPurgeRequestWithFSO.java
@@ -23,6 +23,7 @@
 import static 
org.apache.hadoop.ozone.om.snapshot.SnapshotUtils.validatePreviousSnapshotId;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
@@ -34,6 +35,8 @@
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.apache.commons.lang3.tuple.Pair;
+import org.apache.hadoop.hdds.client.ReplicationConfig;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos;
 import org.apache.hadoop.hdds.utils.TransactionInfo;
 import org.apache.hadoop.hdds.utils.db.cache.CacheKey;
 import org.apache.hadoop.hdds.utils.db.cache.CacheValue;
@@ -42,7 +45,6 @@
 import org.apache.hadoop.ozone.audit.AuditLoggerType;
 import org.apache.hadoop.ozone.audit.OMSystemAction;
 import org.apache.hadoop.ozone.om.DeletingServiceMetrics;
-import org.apache.hadoop.ozone.om.OMMetadataManager;
 import org.apache.hadoop.ozone.om.OMMetadataManager.VolumeBucketId;
 import org.apache.hadoop.ozone.om.OMMetrics;
 import org.apache.hadoop.ozone.om.OmMetadataManagerImpl;
@@ -52,6 +54,8 @@
 import org.apache.hadoop.ozone.om.helpers.BucketLayout;
 import org.apache.hadoop.ozone.om.helpers.OmBucketInfo;
 import org.apache.hadoop.ozone.om.helpers.OmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.OzoneFSUtils;
+import org.apache.hadoop.ozone.om.helpers.QuotaUtil;
 import org.apache.hadoop.ozone.om.helpers.SnapshotInfo;
 import org.apache.hadoop.ozone.om.request.util.OmResponseUtil;
 import org.apache.hadoop.ozone.om.response.OMClientResponse;
@@ -81,7 +85,6 @@ public OMDirectoriesPurgeRequestWithFSO(OMRequest omRequest) {
   }
 
   @Override
-  @SuppressWarnings("methodlength")
   public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, 
ExecutionContext context) {
     PurgeDirectoriesRequest purgeDirsRequest =
         getOmRequest().getPurgeDirectoriesRequest();
@@ -90,41 +93,38 @@ public OMClientResponse validateAndUpdateCache(OzoneManager 
ozoneManager, Execut
 
     List<OzoneManagerProtocolProtos.PurgePathRequest> purgeRequests =
         purgeDirsRequest.getDeletedPathList();
-    Map<Pair<String, String>, OmBucketInfo> volBucketInfoMap = new HashMap<>();
     OmMetadataManagerImpl omMetadataManager = (OmMetadataManagerImpl) 
ozoneManager.getMetadataManager();
-    Map<String, OmKeyInfo> openKeyInfoMap = new HashMap<>();
     OMMetrics omMetrics = ozoneManager.getMetrics();
     DeletingServiceMetrics deletingServiceMetrics = 
ozoneManager.getDeletionMetrics();
     OMResponse.Builder omResponse = OmResponseUtil.getOMResponseBuilder(
         getOmRequest());
     final SnapshotInfo fromSnapshotInfo;
 
-    Set<String> subDirNames = new HashSet<>();
-    Set<String> subFileNames = new HashSet<>();
-    Set<String> deletedDirNames = new HashSet<>();
-
     try {
-      fromSnapshotInfo = fromSnapshot != null ? 
SnapshotUtils.getSnapshotInfo(ozoneManager,
-          fromSnapshot) : null;
-      // Checking if this request is an old request or new one.
-      if (purgeDirsRequest.hasExpectedPreviousSnapshotID()) {
-        // Validating previous snapshot since while purging deletes, a 
snapshot create request could make this purge
-        // directory request invalid on AOS since the deletedDirectory would 
be in the newly created snapshot. Adding
-        // subdirectories could lead to not being able to reclaim sub-files 
and subdirectories since the
-        // file/directory would be present in the newly created snapshot.
-        // Validating previous snapshot can ensure the chain hasn't changed.
-        UUID expectedPreviousSnapshotId = 
purgeDirsRequest.getExpectedPreviousSnapshotID().hasUuid()
-            ? 
fromProtobuf(purgeDirsRequest.getExpectedPreviousSnapshotID().getUuid()) : null;
-        validatePreviousSnapshotId(fromSnapshotInfo, 
omMetadataManager.getSnapshotChainManager(),
-            expectedPreviousSnapshotId);
-      }
+      fromSnapshotInfo = resolveFromSnapshotInfo(ozoneManager, 
omMetadataManager, purgeDirsRequest, fromSnapshot);
     } catch (IOException e) {
-      LOG.error("Error occurred while performing OMDirectoriesPurge. ", e);
-      if (LOG.isDebugEnabled()) {
-        
AUDIT.logWriteFailure(ozoneManager.buildAuditMessageForFailure(OMSystemAction.DIRECTORY_DELETION,
 null, e));
+      return directoryPurgeFailure(ozoneManager, omResponse, e);
+    }
+    // Phase 1 (no lock): parse every purge entry and precompute its delete 
key, path key, replicated size and any
+    // hsync open-key name. None of this depends on the bucket write lock, so 
doing it up front keeps the string and
+    // protobuf work out of the critical section and shortens the write-lock 
hold.
+    Map<VolumeBucketId, BucketNameInfo> volumeBucketIdMap = 
purgeDirsRequest.getBucketNameInfosList().stream()
+        .collect(Collectors.toMap(bucketNameInfo ->
+                new VolumeBucketId(bucketNameInfo.getVolumeId(), 
bucketNameInfo.getBucketId()),
+            Function.identity()));
+    List<PreparedEntry> preparedSubDirs = new ArrayList<>();
+    List<PreparedEntry> preparedSubFiles = new ArrayList<>();
+    List<PreparedDirPurge> preparedDirPurges = new ArrayList<>();
+    for (OzoneManagerProtocolProtos.PurgePathRequest path : purgeRequests) {
+      preparedSubDirs.addAll(prepareMarkDeletedSubDirs(path, 
omMetadataManager));
+      preparedSubFiles.addAll(prepareMoveDeletedSubFiles(path, 
omMetadataManager));
+      if (path.hasDeletedDir()) {
+        BucketNameInfo bucketNameInfo = volumeBucketIdMap.get(new 
VolumeBucketId(path.getVolumeId(),
+            path.getBucketId()));
+        preparedDirPurges.add(prepareDeletedDir(path, bucketNameInfo));
       }
-      return new 
OMDirectoriesPurgeResponseWithFSO(createErrorOMResponse(omResponse, e));
     }
+
     List<String[]> bucketLockKeys = getBucketLockKeySet(purgeDirsRequest);
     
mergeOmLockDetails(omMetadataManager.getLock().acquireWriteLocks(BUCKET_LOCK, 
bucketLockKeys));
     boolean lockAcquired = getOmLockDetails().isLockAcquired();
@@ -135,102 +135,27 @@ public OMClientResponse 
validateAndUpdateCache(OzoneManager ozoneManager, Execut
       
AUDIT.logWriteFailure(ozoneManager.buildAuditMessageForFailure(OMSystemAction.DIRECTORY_DELETION,
 null, oe));
       return new 
OMDirectoriesPurgeResponseWithFSO(createErrorOMResponse(omResponse, oe));
     }
+    PurgeApplyResult result = new PurgeApplyResult();
     try {
-      int numSubDirMoved = 0, numSubFilesMoved = 0, numDirsDeleted = 0;
-      Map<VolumeBucketId, BucketNameInfo> volumeBucketIdMap = 
purgeDirsRequest.getBucketNameInfosList().stream()
-          .collect(Collectors.toMap(bucketNameInfo ->
-                  new VolumeBucketId(bucketNameInfo.getVolumeId(), 
bucketNameInfo.getBucketId()),
-              Function.identity()));
-      for (OzoneManagerProtocolProtos.PurgePathRequest path : purgeRequests) {
-        for (OzoneManagerProtocolProtos.KeyInfo key : 
path.getMarkDeletedSubDirsList()) {
-          ProcessedKeyInfo processed = processDeleteKey(key, path, 
omMetadataManager);
-          subDirNames.add(processed.deleteKey);
-
-          omMetrics.decNumKeys();
-          omMetrics.incNumKeyDeletesInternal();
-          OmBucketInfo omBucketInfo = accumulatedBucketInfo(volBucketInfoMap, 
omMetadataManager,
-              processed.volumeName, processed.bucketName);
-          // bucketInfo can be null in case of delete volume or bucket
-          // or key does not belong to bucket as bucket is recreated
-          if (null != omBucketInfo && omBucketInfo.getObjectID() == 
path.getBucketId()) {
-            omBucketInfo.decrUsedNamespace(1L, true);
-            String ozoneDbKey = 
omMetadataManager.getOzonePathKey(path.getVolumeId(),
-                path.getBucketId(), processed.keyInfo.getParentObjectID(),
-                processed.keyInfo.getFileName());
-            omMetadataManager.getDirectoryTable().addCacheEntry(new 
CacheKey<>(ozoneDbKey),
-                CacheValue.get(context.getIndex()));
-            volBucketInfoMap.putIfAbsent(processed.volBucketPair, 
omBucketInfo);
-          }
-        }
-
-        for (OzoneManagerProtocolProtos.KeyInfo key : 
path.getDeletedSubFilesList()) {
-          ProcessedKeyInfo processed = processDeleteKey(key, path, 
omMetadataManager);
-          subFileNames.add(processed.deleteKey);
-
-          // If omKeyInfo has hsync metadata, delete its corresponding open 
key as well
-          String dbOpenKey;
-          String hsyncClientId = 
processed.keyInfo.getMetadata().get(OzoneConsts.HSYNC_CLIENT_ID);
-          if (hsyncClientId != null) {
-            long parentId = processed.keyInfo.getParentObjectID();
-            dbOpenKey = omMetadataManager.getOpenFileName(path.getVolumeId(), 
path.getBucketId(),
-                parentId, processed.keyInfo.getFileName(), hsyncClientId);
-            OmKeyInfo openKeyInfo = 
omMetadataManager.getOpenKeyTable(getBucketLayout()).get(dbOpenKey);
-            if (openKeyInfo != null) {
-              openKeyInfo = openKeyInfo.withMetadataMutations(
-                  metadata -> metadata.put(DELETED_HSYNC_KEY, "true"));
-              openKeyInfoMap.put(dbOpenKey, openKeyInfo);
-            }
-          }
-
-          omMetrics.decNumKeys();
-          omMetrics.incNumKeyDeletesInternal();
-          numSubFilesMoved++;
-          OmBucketInfo omBucketInfo = accumulatedBucketInfo(volBucketInfoMap, 
omMetadataManager,
-              processed.volumeName, processed.bucketName);
-          // bucketInfo can be null in case of delete volume or bucket
-          // or key does not belong to bucket as bucket is recreated
-          if (null != omBucketInfo
-              && omBucketInfo.getObjectID() == path.getBucketId()) {
-            long totalSize = sumBlockLengths(processed.keyInfo);
-            omBucketInfo.decrUsedBytes(totalSize, true);
-            omBucketInfo.decrUsedNamespace(1L, true);
-            String ozoneDbKey = 
omMetadataManager.getOzonePathKey(path.getVolumeId(),
-                path.getBucketId(), processed.keyInfo.getParentObjectID(),
-                processed.keyInfo.getFileName());
-            omMetadataManager.getFileTable().addCacheEntry(new 
CacheKey<>(ozoneDbKey),
-                CacheValue.get(context.getIndex()));
-            volBucketInfoMap.putIfAbsent(processed.volBucketPair, 
omBucketInfo);
-          }
-        }
-        if (path.hasDeletedDir()) {
-          deletedDirNames.add(path.getDeletedDir());
-          BucketNameInfo bucketNameInfo = volumeBucketIdMap.get(new 
VolumeBucketId(path.getVolumeId(),
-              path.getBucketId()));
-          OmBucketInfo omBucketInfo = accumulatedBucketInfo(volBucketInfoMap, 
omMetadataManager,
-              bucketNameInfo.getVolumeName(), bucketNameInfo.getBucketName());
-          if (omBucketInfo != null && omBucketInfo.getObjectID() == 
path.getBucketId()) {
-            omBucketInfo.purgeSnapshotUsedNamespace(1);
-            volBucketInfoMap.put(Pair.of(omBucketInfo.getVolumeName(), 
omBucketInfo.getBucketName()), omBucketInfo);
-          }
-          numDirsDeleted++;
-        }
-      }
+      // Phase 2 (under the bucket write lock): apply the prepared cache 
tombstones, hsync open-key cleanup and
+      // aggregated per-bucket quota changes.
+      applyPreparedEntries(preparedSubDirs, preparedSubFiles, 
preparedDirPurges, omMetadataManager,
+          context.getIndex(), result);
 
-      // Remove deletedDirNames from subDirNames to avoid duplication
-      subDirNames.removeAll(deletedDirNames);
-      numSubDirMoved = subDirNames.size();
-      deletingServiceMetrics.incrNumSubDirectoriesMoved(numSubDirMoved);
-      deletingServiceMetrics.incrNumSubFilesMoved(numSubFilesMoved);
-      deletingServiceMetrics.incrNumDirPurged(numDirsDeleted);
-
-      // All bucket locks are still held here, and every purge path has been 
processed, so the
-      // accumulated copies can be published as a whole.
-      for (Map.Entry<Pair<String, String>, OmBucketInfo> entry : 
volBucketInfoMap.entrySet()) {
+      // All bucket locks are still held here, and every purge path has been 
processed, so the accumulated
+      // copies can be published as a whole. Publishing only here, after all 
fallible apply work has
+      // succeeded, keeps a failed purge from changing the live bucket usage 
(HDDS-16161).
+      for (Map.Entry<Pair<String, String>, OmBucketInfo> entry : 
result.volBucketInfoMap.entrySet()) {
         omMetadataManager.getBucketTable().addCacheEntry(
             omMetadataManager.getBucketKey(entry.getKey().getLeft(), 
entry.getKey().getRight()),
             entry.getValue(), context.getIndex());
       }
 
+      // The per-entry global OM metric mutations were unconditional, so the 
count is the total number of prepared
+      // sub-directories and sub-files.
+      long numKeysProcessed = (long) preparedSubDirs.size() + 
preparedSubFiles.size();
+      int numSubDirMoved = recordDeletionMetrics(deletingServiceMetrics, 
omMetrics, numKeysProcessed, result);
+
       TransactionInfo transactionInfo = 
TransactionInfo.valueOf(context.getTermIndex());
       if (fromSnapshotInfo != null) {
         
fromSnapshotInfo.setLastTransactionInfo(transactionInfo.toByteString());
@@ -243,17 +168,7 @@ public OMClientResponse 
validateAndUpdateCache(OzoneManager ozoneManager, Execut
       }
 
       if (LOG.isDebugEnabled()) {
-        Map<String, String> auditParams = new LinkedHashMap<>();
-        if (fromSnapshotInfo != null) {
-          auditParams.put(AUDIT_PARAM_SNAPSHOT_ID, 
fromSnapshotInfo.getSnapshotId().toString());
-        }
-        auditParams.put(AUDIT_PARAM_DIRS_DELETED, 
String.valueOf(numDirsDeleted));
-        auditParams.put(AUDIT_PARAM_SUBDIRS_MOVED, 
String.valueOf(numSubDirMoved));
-        auditParams.put(AUDIT_PARAM_SUBFILES_MOVED, 
String.valueOf(numSubFilesMoved));
-        auditParams.put(AUDIT_PARAM_DIRS_DELETED_LIST, String.join(",", 
deletedDirNames));
-        auditParams.put(AUDIT_PARAM_SUBDIRS_MOVED_LIST, String.join(",", 
subDirNames));
-        auditParams.put(AUDIT_PARAM_SUBFILES_MOVED_LIST, String.join(",", 
subFileNames));
-        
AUDIT.logWriteSuccess(ozoneManager.buildAuditMessageForSuccess(OMSystemAction.DIRECTORY_DELETION,
 auditParams));
+        logDirectoryDeletionAuditSuccess(ozoneManager, fromSnapshotInfo, 
numSubDirMoved, result);
       }
     } catch (IOException ex) {
       // Case of IOException for fromProtobuf will not happen
@@ -264,69 +179,420 @@ public OMClientResponse 
validateAndUpdateCache(OzoneManager ozoneManager, Execut
       }
       throw new IllegalStateException(ex);
     } finally {
+      if (lockAcquired) {
+        
mergeOmLockDetails(omMetadataManager.getLock().releaseWriteLocks(BUCKET_LOCK, 
bucketLockKeys));
+      }
+      // Snapshot the mutated bucket infos for the response after releasing 
the lock. The single apply thread is the
+      // only writer, so no other transaction can mutate them between release 
and copy.
       for (Map.Entry<Pair<String, String>, OmBucketInfo> entry :
-          volBucketInfoMap.entrySet()) {
+          result.volBucketInfoMap.entrySet()) {
         entry.setValue(entry.getValue().copyObject());
       }
-      if (lockAcquired) {
-        
mergeOmLockDetails(omMetadataManager.getLock().releaseWriteLocks(BUCKET_LOCK, 
bucketLockKeys));
-      }  
     }
 
     return new OMDirectoriesPurgeResponseWithFSO(
         omResponse.build(), purgeRequests,
-        getBucketLayout(), volBucketInfoMap, fromSnapshotInfo, openKeyInfoMap);
+        getBucketLayout(), result.volBucketInfoMap, fromSnapshotInfo, 
result.openKeyInfoMap);
+  }
+
+  /**
+   * De-duplicates purged directories from the moved sub-directory set and 
updates the deletion service metrics for
+   * this transaction, returning the resulting number of sub-directories moved 
(used for the success audit).
+   */
+  private int recordDeletionMetrics(DeletingServiceMetrics 
deletingServiceMetrics, OMMetrics omMetrics,
+      long numKeysProcessed, PurgeApplyResult result) {
+    // Apply the per-entry global OM metric mutations in bulk (one increment 
each instead of once per entry) to
+    // minimize work done while holding the bucket write lock.
+    omMetrics.decNumKeys(numKeysProcessed);
+    omMetrics.incNumKeyDeletesInternal(numKeysProcessed);
+    // Remove deletedDirNames from subDirNames to avoid duplication
+    result.subDirNames.removeAll(result.deletedDirNames);
+    int numSubDirMoved = result.subDirNames.size();
+    deletingServiceMetrics.incrNumSubDirectoriesMoved(numSubDirMoved);
+    deletingServiceMetrics.incrNumSubFilesMoved(result.numSubFilesMoved);
+    deletingServiceMetrics.incrNumDirPurged(result.numDirsDeleted);
+    return numSubDirMoved;
+  }
+
+  /**
+   * Builds and emits the success audit message for a directory purge. Kept 
separate so the per-entry parameter map is
+   * only assembled when debug audit logging is enabled.
+   */
+  private void logDirectoryDeletionAuditSuccess(OzoneManager ozoneManager, 
SnapshotInfo fromSnapshotInfo,
+      int numSubDirMoved, PurgeApplyResult result) {
+    Map<String, String> auditParams = new LinkedHashMap<>();
+    if (fromSnapshotInfo != null) {
+      auditParams.put(AUDIT_PARAM_SNAPSHOT_ID, 
fromSnapshotInfo.getSnapshotId().toString());
+    }
+    auditParams.put(AUDIT_PARAM_DIRS_DELETED, 
String.valueOf(result.numDirsDeleted));
+    auditParams.put(AUDIT_PARAM_SUBDIRS_MOVED, String.valueOf(numSubDirMoved));
+    auditParams.put(AUDIT_PARAM_SUBFILES_MOVED, 
String.valueOf(result.numSubFilesMoved));
+    auditParams.put(AUDIT_PARAM_DIRS_DELETED_LIST, String.join(",", 
result.deletedDirNames));
+    auditParams.put(AUDIT_PARAM_SUBDIRS_MOVED_LIST, String.join(",", 
result.subDirNames));
+    auditParams.put(AUDIT_PARAM_SUBFILES_MOVED_LIST, String.join(",", 
result.subFileNames));
+    
AUDIT.logWriteSuccess(ozoneManager.buildAuditMessageForSuccess(OMSystemAction.DIRECTORY_DELETION,
 auditParams));
+  }
+
+  /**
+   * Logs, audits and builds the error response for a failure while resolving 
the {@code fromSnapshot}. Extracted so
+   * the {@link #validateAndUpdateCache} catch block reads as a single call.
+   */
+  private OMClientResponse directoryPurgeFailure(OzoneManager ozoneManager, 
OMResponse.Builder omResponse,
+      IOException e) {
+    LOG.error("Error occurred while performing OMDirectoriesPurge. ", e);
+    if (LOG.isDebugEnabled()) {
+      
AUDIT.logWriteFailure(ozoneManager.buildAuditMessageForFailure(OMSystemAction.DIRECTORY_DELETION,
 null, e));
+    }
+    return new 
OMDirectoriesPurgeResponseWithFSO(createErrorOMResponse(omResponse, e));
+  }
+
+  /**
+   * Resolves the {@code fromSnapshot} this purge runs against and, for 
new-format requests, validates that the
+   * previous snapshot chain has not changed. Extracted so {@link 
#validateAndUpdateCache} reads as setup → per-path
+   * apply → bookkeeping.
+   */
+  private SnapshotInfo resolveFromSnapshotInfo(OzoneManager ozoneManager, 
OmMetadataManagerImpl omMetadataManager,
+      PurgeDirectoriesRequest purgeDirsRequest, String fromSnapshot) throws 
IOException {
+    SnapshotInfo fromSnapshotInfo = fromSnapshot != null
+        ? SnapshotUtils.getSnapshotInfo(ozoneManager, fromSnapshot) : null;
+    // Checking if this request is an old request or new one.
+    if (purgeDirsRequest.hasExpectedPreviousSnapshotID()) {
+      // Validating previous snapshot since while purging deletes, a snapshot 
create request could make this purge
+      // directory request invalid on AOS since the deletedDirectory would be 
in the newly created snapshot. Adding
+      // subdirectories could lead to not being able to reclaim sub-files and 
subdirectories since the
+      // file/directory would be present in the newly created snapshot.
+      // Validating previous snapshot can ensure the chain hasn't changed.
+      UUID expectedPreviousSnapshotId = 
purgeDirsRequest.getExpectedPreviousSnapshotID().hasUuid()
+          ? 
fromProtobuf(purgeDirsRequest.getExpectedPreviousSnapshotID().getUuid()) : null;
+      validatePreviousSnapshotId(fromSnapshotInfo, 
omMetadataManager.getSnapshotChainManager(),
+          expectedPreviousSnapshotId);
+    }
+    return fromSnapshotInfo;
+  }
+
+  /**
+   * Phase 1 (no lock): parses a path's sub-directories into prepared entries, 
precomputing each delete/path key. The
+   * directory-table tombstones and bucket namespace decrements are applied 
later under the lock in
+   * {@link #applyPreparedEntries}.
+   */
+  private List<PreparedEntry> 
prepareMarkDeletedSubDirs(OzoneManagerProtocolProtos.PurgePathRequest path,
+      OmMetadataManagerImpl omMetadataManager) {
+    List<PreparedEntry> preparedSubDirs = new ArrayList<>();
+    for (OzoneManagerProtocolProtos.KeyInfo key : 
path.getMarkDeletedSubDirsList()) {
+      ProcessedKeyInfo processed = processDeleteKey(key, path, 
omMetadataManager);
+      preparedSubDirs.add(new PreparedEntry(processed, path.getBucketId(), 0L, 
null));
+    }
+    return preparedSubDirs;
   }
 
   /**
-   * Return the copy this request accumulates deltas on, fetching it on first 
use. All three purge
-   * paths can touch the same bucket, so they must share one copy or earlier 
deltas are lost.
+   * Phase 1 (no lock): parses a path's sub-files into prepared entries, 
precomputing each delete/path key, its
+   * replicated size and, for hsync files, the open-key name to clean up. The 
file-table tombstones, hsync open-key
+   * cleanup and bucket quota decrements are applied later under the lock in 
{@link #applyPreparedEntries}.
    */
-  private OmBucketInfo accumulatedBucketInfo(Map<Pair<String, String>, 
OmBucketInfo> volBucketInfoMap,
-      OMMetadataManager omMetadataManager, String volumeName, String 
bucketName) {
-    OmBucketInfo omBucketInfo = volBucketInfoMap.get(Pair.of(volumeName, 
bucketName));
+  private List<PreparedEntry> 
prepareMoveDeletedSubFiles(OzoneManagerProtocolProtos.PurgePathRequest path,
+      OmMetadataManagerImpl omMetadataManager) {
+    List<PreparedEntry> preparedSubFiles = new ArrayList<>();
+    for (OzoneManagerProtocolProtos.KeyInfo key : 
path.getDeletedSubFilesList()) {
+      ProcessedKeyInfo processed = processDeleteKey(key, path, 
omMetadataManager);
+      long replicatedSize = sumBlockLengths(key);
+      // If omKeyInfo has hsync metadata, its corresponding open key is 
cleaned up under the lock.
+      String hsyncClientId = getHsyncClientId(key);
+      String dbOpenKey = hsyncClientId == null ? null
+          : omMetadataManager.getOpenFileName(path.getVolumeId(), 
path.getBucketId(),
+          processed.parentObjectID, processed.fileName, hsyncClientId);
+      preparedSubFiles.add(new PreparedEntry(processed, path.getBucketId(), 
replicatedSize, dbOpenKey));
+    }
+    return preparedSubFiles;
+  }
 
-    return omBucketInfo != null ? omBucketInfo
-        : getBucketInfoForUpdate(omMetadataManager, volumeName, bucketName);
+  /**
+   * Phase 1 (no lock): records a path's deleted directory for later purge 
under the lock in
+   * {@link #applyPreparedEntries}.
+   */
+  private PreparedDirPurge 
prepareDeletedDir(OzoneManagerProtocolProtos.PurgePathRequest path,
+      BucketNameInfo bucketNameInfo) {
+    return new PreparedDirPurge(bucketNameInfo.getVolumeName(), 
bucketNameInfo.getBucketName(),
+        path.getBucketId(), path.getDeletedDir());
+  }
+
+  /**
+   * Phase 2 (under the bucket write lock): applies the prepared 
directory/file tombstones, hsync open-key cleanup and
+   * per-bucket quota changes. Quota deltas are accumulated per bucket and 
applied once, instead of once per entry, to
+   * minimize the mutations done while holding the write lock.
+   */
+  private void applyPreparedEntries(List<PreparedEntry> preparedSubDirs, 
List<PreparedEntry> preparedSubFiles,
+      List<PreparedDirPurge> preparedDirPurges, OmMetadataManagerImpl 
omMetadataManager, long trxnLogIndex,
+      PurgeApplyResult result) throws IOException {
+    // Holds one mutable bucket-info copy per bucket for this apply so that a 
purge transaction touching many keys of
+    // the same bucket resolves it once and accumulates every quota delta on 
the same copy.
+    Map<Pair<String, String>, OmBucketInfo> bucketInfoCache = new HashMap<>();
+    // Quota deltas are accumulated per bucket across all three entry loops 
and applied once at the end.
+    Map<Pair<String, String>, QuotaDelta> quotaDeltas = new HashMap<>();
+
+    applyMarkedSubDirs(preparedSubDirs, omMetadataManager, trxnLogIndex, 
result, bucketInfoCache, quotaDeltas);
+    applyMovedSubFiles(preparedSubFiles, omMetadataManager, trxnLogIndex, 
result, bucketInfoCache, quotaDeltas);
+    applyDirPurges(preparedDirPurges, omMetadataManager, result, 
bucketInfoCache, quotaDeltas);
+    applyQuotaDeltas(quotaDeltas);
+  }
+
+  /**
+   * Phase 2 (under the bucket write lock): tombstones each prepared 
sub-directory in the directory table and
+   * accumulates its namespace quota decrement, when its bucket still matches 
the prepared bucket id.
+   */
+  private void applyMarkedSubDirs(List<PreparedEntry> preparedSubDirs, 
OmMetadataManagerImpl omMetadataManager,
+      long trxnLogIndex, PurgeApplyResult result, Map<Pair<String, String>, 
OmBucketInfo> bucketInfoCache,
+      Map<Pair<String, String>, QuotaDelta> quotaDeltas) {
+    for (PreparedEntry entry : preparedSubDirs) {
+      ProcessedKeyInfo processed = entry.processed;
+      result.subDirNames.add(processed.deleteKey);
+      OmBucketInfo omBucketInfo = getBucketInfoCached(omMetadataManager,
+          bucketInfoCache, processed.volumeName, processed.bucketName);
+      // bucketInfo can be null in case of delete volume or bucket
+      // or key does not belong to bucket as bucket is recreated
+      if (null != omBucketInfo && omBucketInfo.getObjectID() == 
entry.bucketId) {
+        omMetadataManager.getDirectoryTable().addCacheEntry(new 
CacheKey<>(processed.pathKey),
+            CacheValue.get(trxnLogIndex));
+        result.volBucketInfoMap.putIfAbsent(processed.volBucketPair, 
omBucketInfo);
+        quotaDeltas.computeIfAbsent(processed.volBucketPair, k -> new 
QuotaDelta(omBucketInfo)).usedNamespace += 1L;
+      }
+    }
+  }
+
+  /**
+   * Phase 2 (under the bucket write lock): tombstones each prepared sub-file 
in the file table, cleans up its hsync
+   * open key when present, and accumulates its byte and namespace quota 
decrements, when its bucket still matches the
+   * prepared bucket id.
+   */
+  private void applyMovedSubFiles(List<PreparedEntry> preparedSubFiles, 
OmMetadataManagerImpl omMetadataManager,
+      long trxnLogIndex, PurgeApplyResult result, Map<Pair<String, String>, 
OmBucketInfo> bucketInfoCache,
+      Map<Pair<String, String>, QuotaDelta> quotaDeltas) throws IOException {
+    for (PreparedEntry entry : preparedSubFiles) {
+      ProcessedKeyInfo processed = entry.processed;
+      result.subFileNames.add(processed.deleteKey);
+
+      // If omKeyInfo has hsync metadata, delete its corresponding open key as 
well
+      if (entry.dbOpenKey != null) {
+        OmKeyInfo openKeyInfo = 
omMetadataManager.getOpenKeyTable(getBucketLayout()).get(entry.dbOpenKey);
+        if (openKeyInfo != null) {
+          openKeyInfo = openKeyInfo.withMetadataMutations(
+              metadata -> metadata.put(DELETED_HSYNC_KEY, "true"));
+          result.openKeyInfoMap.put(entry.dbOpenKey, openKeyInfo);
+        }
+      }
+
+      result.numSubFilesMoved++;
+      OmBucketInfo omBucketInfo = getBucketInfoCached(omMetadataManager,
+          bucketInfoCache, processed.volumeName, processed.bucketName);
+      // bucketInfo can be null in case of delete volume or bucket
+      // or key does not belong to bucket as bucket is recreated
+      if (null != omBucketInfo && omBucketInfo.getObjectID() == 
entry.bucketId) {
+        omMetadataManager.getFileTable().addCacheEntry(new 
CacheKey<>(processed.pathKey),
+            CacheValue.get(trxnLogIndex));
+        result.volBucketInfoMap.putIfAbsent(processed.volBucketPair, 
omBucketInfo);
+        QuotaDelta quotaDelta = 
quotaDeltas.computeIfAbsent(processed.volBucketPair, k -> new 
QuotaDelta(omBucketInfo));
+        quotaDelta.usedBytes += entry.replicatedSize;
+        quotaDelta.usedNamespace += 1L;
+      }
+    }
+  }
+
+  /**
+   * Phase 2 (under the bucket write lock): records each purged directory and 
accumulates its snapshot-namespace purge,
+   * when its bucket still matches the prepared bucket id.
+   */
+  private void applyDirPurges(List<PreparedDirPurge> preparedDirPurges, 
OmMetadataManagerImpl omMetadataManager,
+      PurgeApplyResult result, Map<Pair<String, String>, OmBucketInfo> 
bucketInfoCache,
+      Map<Pair<String, String>, QuotaDelta> quotaDeltas) {
+    for (PreparedDirPurge dirPurge : preparedDirPurges) {
+      result.deletedDirNames.add(dirPurge.deletedDir);
+      OmBucketInfo omBucketInfo = getBucketInfoCached(omMetadataManager,
+          bucketInfoCache, dirPurge.volumeName, dirPurge.bucketName);
+      if (omBucketInfo != null && omBucketInfo.getObjectID() == 
dirPurge.bucketId) {
+        Pair<String, String> volBucketPair = 
Pair.of(omBucketInfo.getVolumeName(), omBucketInfo.getBucketName());
+        result.volBucketInfoMap.put(volBucketPair, omBucketInfo);
+        quotaDeltas.computeIfAbsent(volBucketPair, k -> new 
QuotaDelta(omBucketInfo)).snapshotNamespacePurge += 1L;
+      }
+      result.numDirsDeleted++;
+    }
+  }
+
+  /**
+   * Phase 2 (under the bucket write lock): applies the per-bucket accumulated 
quota changes once each, instead of once
+   * per entry, to minimize the mutations done while holding the bucket write 
lock.
+   */
+  private void applyQuotaDeltas(Map<Pair<String, String>, QuotaDelta> 
quotaDeltas) {
+    for (QuotaDelta quotaDelta : quotaDeltas.values()) {
+      if (quotaDelta.usedBytes != 0L) {
+        quotaDelta.bucketInfo.decrUsedBytes(quotaDelta.usedBytes, true);
+      }
+      if (quotaDelta.usedNamespace != 0L) {
+        quotaDelta.bucketInfo.decrUsedNamespace(quotaDelta.usedNamespace, 
true);
+      }
+      if (quotaDelta.snapshotNamespacePurge != 0L) {
+        
quotaDelta.bucketInfo.purgeSnapshotUsedNamespace(quotaDelta.snapshotNamespacePurge);
+      }
+    }
+  }
+
+  /**
+   * Phase 2 output accumulated by {@link #applyPreparedEntries} under the 
bucket write lock: the mutated bucket infos
+   * and hsync open keys used to build the response, plus the moved 
sub-dir/sub-file/deleted-dir names and counts used
+   * for the deletion metrics and the success audit.
+   */
+  private static final class PurgeApplyResult {
+    private final Map<Pair<String, String>, OmBucketInfo> volBucketInfoMap = 
new HashMap<>();
+    private final Map<String, OmKeyInfo> openKeyInfoMap = new HashMap<>();
+    private final Set<String> subDirNames = new HashSet<>();
+    private final Set<String> subFileNames = new HashSet<>();
+    private final Set<String> deletedDirNames = new HashSet<>();
+    private int numSubFilesMoved;
+    private int numDirsDeleted;
   }
 
   /**
    * Helper class to hold processed key information.
    */
   private static class ProcessedKeyInfo {
-    private final OmKeyInfo keyInfo;
     private final String deleteKey;
+    private final String pathKey;
     private final String volumeName;
     private final String bucketName;
+    private final long parentObjectID;
+    private final String fileName;
     private final Pair<String, String> volBucketPair;
 
-    ProcessedKeyInfo(OmKeyInfo keyInfo, String deleteKey, String volumeName, 
String bucketName) {
-      this.keyInfo = keyInfo;
+    ProcessedKeyInfo(String deleteKey, String pathKey, String volumeName,
+                     String bucketName, long parentObjectID, String fileName) {
       this.deleteKey = deleteKey;
+      this.pathKey = pathKey;
       this.volumeName = volumeName;
       this.bucketName = bucketName;
+      this.parentObjectID = parentObjectID;
+      this.fileName = fileName;
       this.volBucketPair = Pair.of(volumeName, bucketName);
     }
   }
 
+  /**
+   * A sub-directory or sub-file prepared (lock-free) in phase 1 for 
application under the bucket write lock in phase
+   * 2. {@code replicatedSize} is the file's replicated byte usage (0 for 
directories) and {@code dbOpenKey} is the
+   * hsync open-key to clean up, or {@code null} when the entry is not an 
hsync file.
+   */
+  private static final class PreparedEntry {
+    private final ProcessedKeyInfo processed;
+    private final long bucketId;
+    private final long replicatedSize;
+    private final String dbOpenKey;
+
+    PreparedEntry(ProcessedKeyInfo processed, long bucketId, long 
replicatedSize, String dbOpenKey) {
+      this.processed = processed;
+      this.bucketId = bucketId;
+      this.replicatedSize = replicatedSize;
+      this.dbOpenKey = dbOpenKey;
+    }
+  }
+
+  /**
+   * A deleted directory prepared (lock-free) in phase 1 for its 
snapshot-namespace purge under the bucket write lock
+   * in phase 2.
+   */
+  private static final class PreparedDirPurge {
+    private final String volumeName;
+    private final String bucketName;
+    private final long bucketId;
+    private final String deletedDir;
+
+    PreparedDirPurge(String volumeName, String bucketName, long bucketId, 
String deletedDir) {
+      this.volumeName = volumeName;
+      this.bucketName = bucketName;
+      this.bucketId = bucketId;
+      this.deletedDir = deletedDir;
+    }
+  }
+
+  /**
+   * Accumulates a single bucket's quota changes across all entries in a purge 
so they can be applied once under the
+   * write lock instead of once per entry.
+   */
+  private static final class QuotaDelta {
+    private final OmBucketInfo bucketInfo;
+    private long usedBytes;
+    private long usedNamespace;
+    private long snapshotNamespacePurge;
+
+    QuotaDelta(OmBucketInfo bucketInfo) {
+      this.bucketInfo = bucketInfo;
+    }
+  }
+
   /**
    * Process delete key info.
-   * Returns ProcessedKeyInfo containing all the processed information.
+   * Reads only the fields the purge apply path needs directly from the 
protobuf, instead of building a full
+   * {@link OmKeyInfo} (with its key-location, ACL, tag, encryption and 
checksum objects) for every entry on the
+   * single-threaded apply path.
    */
   private ProcessedKeyInfo processDeleteKey(OzoneManagerProtocolProtos.KeyInfo 
key,
                                             
OzoneManagerProtocolProtos.PurgePathRequest path,
                                             OmMetadataManagerImpl 
omMetadataManager) {
-    OmKeyInfo keyInfo = OmKeyInfo.getFromProtobuf(key);
+    long objectID = key.hasObjectID() ? key.getObjectID() : 0L;
+    long parentObjectID = key.hasParentID() ? key.getParentID() : 0L;
+    String fileName = OzoneFSUtils.getFileName(key.getKeyName());
 
     String pathKey = omMetadataManager.getOzonePathKey(path.getVolumeId(),
-        path.getBucketId(), keyInfo.getParentObjectID(), 
keyInfo.getFileName());
-    String deleteKey = omMetadataManager.getOzoneDeletePathKey(
-        keyInfo.getObjectID(), pathKey);
+        path.getBucketId(), parentObjectID, fileName);
+    String deleteKey = omMetadataManager.getOzoneDeletePathKey(objectID, 
pathKey);
+
+    return new ProcessedKeyInfo(deleteKey, pathKey, key.getVolumeName(), 
key.getBucketName(),
+        parentObjectID, fileName);
+  }
 
-    String volumeName = keyInfo.getVolumeName();
-    String bucketName = keyInfo.getBucketName();
+  /**
+   * Returns a single mutable bucket-info copy for the given volume/bucket, 
memoized within one apply so that all three
+   * purge paths touching the same bucket share one copy (or earlier quota 
deltas would be lost) and resolve the bucket
+   * cache lookup once. The copy comes from {@link #getBucketInfoForUpdate}, 
so quota mutations stay off the live cache
+   * until they are published on the success path. {@code null} results 
(deleted bucket) are memoized too.
+   */
+  private static OmBucketInfo getBucketInfoCached(OmMetadataManagerImpl 
omMetadataManager,
+      Map<Pair<String, String>, OmBucketInfo> cache, String volumeName, String 
bucketName) {
+    Pair<String, String> cacheKey = Pair.of(volumeName, bucketName);
+    if (cache.containsKey(cacheKey)) {
+      return cache.get(cacheKey);
+    }
+    OmBucketInfo omBucketInfo = getBucketInfoForUpdate(omMetadataManager, 
volumeName, bucketName);
+    cache.put(cacheKey, omBucketInfo);
+    return omBucketInfo;
+  }
 
-    return new ProcessedKeyInfo(keyInfo, deleteKey, volumeName, bucketName);
+  /**
+   * Reads the HSYNC client id directly from the key's protobuf metadata, 
mirroring
+   * {@code KeyValueUtil.getFromProtobuf(...).get(HSYNC_CLIENT_ID)} without 
building the full metadata map.
+   */
+  private static String getHsyncClientId(OzoneManagerProtocolProtos.KeyInfo 
key) {
+    for (HddsProtos.KeyValue kv : key.getMetadataList()) {
+      if (OzoneConsts.HSYNC_CLIENT_ID.equals(kv.getKey())) {
+        return kv.getValue();
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Computes replicated byte usage for a file directly from its protobuf key 
locations. This is equivalent to
+   * {@link OMKeyRequest#sumBlockLengths(OmKeyInfo)} on the parsed key: {@code 
getFromProtobuf} only regroups the same
+   * locations by create-version, so summing every {@code KeyLocation} length 
yields the identical total while avoiding
+   * the full OmKeyInfo build on the apply path.
+   */
+  private static long sumBlockLengths(OzoneManagerProtocolProtos.KeyInfo key) {
+    ReplicationConfig replicationConfig = 
ReplicationConfig.fromProto(key.getType(), key.getFactor(),
+        key.getEcReplicationConfig());
+    long bytesUsed = 0;
+    for (OzoneManagerProtocolProtos.KeyLocationList group : 
key.getKeyLocationListList()) {
+      for (OzoneManagerProtocolProtos.KeyLocation location : 
group.getKeyLocationsList()) {
+        bytesUsed += QuotaUtil.getReplicatedSize(location.getLength(), 
replicationConfig);
+      }
+    }
+    return bytesUsed;
   }
 
   private List<String[]> getBucketLockKeySet(PurgeDirectoriesRequest 
purgeDirsRequest) {
diff --git 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/DirectoryDeletingService.java
 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/DirectoryDeletingService.java
index 9b3f2b2a7a3..ef3bd91a3e2 100644
--- 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/DirectoryDeletingService.java
+++ 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/service/DirectoryDeletingService.java
@@ -34,6 +34,7 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -536,38 +537,49 @@ private OzoneManagerProtocolProtos.PurgePathRequest 
wrapPurgeRequest(
   private List<OzoneManagerProtocolProtos.OMResponse> 
submitPurgePathsWithBatching(List<PurgePathRequest> requests,
       String snapTableKey, UUID expectedPreviousSnapshotId, 
Map<VolumeBucketId, BucketNameInfo> bucketNameInfoMap) {
 
+    // Group purge paths by their owning bucket so that every submitted purge 
transaction contains paths from a single
+    // bucket only. This keeps the apply side acquiring exactly one bucket 
write lock per transaction, so a large
+    // background directory purge cannot block readers on other buckets.
+    Map<VolumeBucketId, List<PurgePathRequest>> requestsByBucket = new 
LinkedHashMap<>();
+    for (PurgePathRequest req : requests) {
+      requestsByBucket.computeIfAbsent(new VolumeBucketId(req.getVolumeId(), 
req.getBucketId()),
+          k -> new ArrayList<>()).add(req);
+    }
+
     List<OzoneManagerProtocolProtos.OMResponse> responses = new ArrayList<>();
-    List<PurgePathRequest> purgePathRequestBatch = new ArrayList<>();
-    long batchBytes = 0;
+    for (List<PurgePathRequest> bucketRequests : requestsByBucket.values()) {
+      List<PurgePathRequest> purgePathRequestBatch = new ArrayList<>();
+      long batchBytes = 0;
+
+      for (PurgePathRequest req : bucketRequests) {
+        int reqSize = req.getSerializedSize();
+
+        // If adding this request would exceed the limit, flush the current 
batch first
+        if (batchBytes + reqSize > ratisByteLimit && 
!purgePathRequestBatch.isEmpty()) {
+          OzoneManagerProtocolProtos.OMResponse resp =
+              submitPurgeRequest(snapTableKey, expectedPreviousSnapshotId, 
bucketNameInfoMap, purgePathRequestBatch);
+          if (resp == null || !resp.getSuccess()) {
+            return Collections.emptyList();
+          }
+          responses.add(resp);
+          purgePathRequestBatch.clear();
+          batchBytes = 0;
+        }
 
-    for (PurgePathRequest req : requests) {
-      int reqSize = req.getSerializedSize();
+        // Add current request to batch
+        purgePathRequestBatch.add(req);
+        batchBytes += reqSize;
+      }
 
-      // If adding this request would exceed the limit, flush the current 
batch first
-      if (batchBytes + reqSize > ratisByteLimit && 
!purgePathRequestBatch.isEmpty()) {
+      // Flush remaining batch for this bucket if any
+      if (!purgePathRequestBatch.isEmpty()) {
         OzoneManagerProtocolProtos.OMResponse resp =
             submitPurgeRequest(snapTableKey, expectedPreviousSnapshotId, 
bucketNameInfoMap, purgePathRequestBatch);
-        if (!resp.getSuccess()) {
+        if (resp == null || !resp.getSuccess()) {
           return Collections.emptyList();
         }
         responses.add(resp);
-        purgePathRequestBatch.clear();
-        batchBytes = 0;
-      }
-
-      // Add current request to batch
-      purgePathRequestBatch.add(req);
-      batchBytes += reqSize;
-    }
-
-    // Flush remaining batch if any
-    if (!purgePathRequestBatch.isEmpty()) {
-      OzoneManagerProtocolProtos.OMResponse resp =
-          submitPurgeRequest(snapTableKey, expectedPreviousSnapshotId, 
bucketNameInfoMap, purgePathRequestBatch);
-      if (!resp.getSuccess()) {
-        return Collections.emptyList();
       }
-      responses.add(resp);
     }
 
     return responses;
diff --git 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMDirectoriesPurgeApplyPerf.java
 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMDirectoriesPurgeApplyPerf.java
new file mode 100644
index 00000000000..0debbb9e7e7
--- /dev/null
+++ 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMDirectoriesPurgeApplyPerf.java
@@ -0,0 +1,226 @@
+/*
+ * 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.om.request.key;
+
+import static 
org.apache.hadoop.ozone.om.request.file.OMFileRequest.getOmKeyInfo;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+import org.apache.hadoop.hdds.client.BlockID;
+import org.apache.hadoop.hdds.client.RatisReplicationConfig;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos;
+import org.apache.hadoop.hdds.utils.db.cache.CacheKey;
+import org.apache.hadoop.hdds.utils.db.cache.CacheValue;
+import org.apache.hadoop.ozone.ClientVersion;
+import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
+import org.apache.hadoop.ozone.om.helpers.OmBucketInfo;
+import org.apache.hadoop.ozone.om.helpers.OmDirectoryInfo;
+import org.apache.hadoop.ozone.om.helpers.OmKeyInfo;
+import org.apache.hadoop.ozone.om.helpers.OmKeyLocationInfo;
+import org.apache.hadoop.ozone.om.helpers.OmKeyLocationInfoGroup;
+import org.apache.hadoop.ozone.om.request.OMRequestTestUtils;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos;
+import 
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.BucketNameInfo;
+import 
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest;
+import 
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.PurgePathRequest;
+import org.apache.hadoop.util.Time;
+import org.apache.ozone.test.GenericTestUtils;
+import org.apache.ratis.server.protocol.TermIndex;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.event.Level;
+
+/**
+ * Handler-level microbenchmark for {@link 
OMDirectoriesPurgeRequestWithFSO#validateAndUpdateCache}.
+ *
+ * <p>The OM applies write transactions on a single serial apply thread, so 
the wall time of
+ * {@code validateAndUpdateCache} directly bounds how long all other 
writes/renames/mkdirs wait behind a background
+ * directory purge. This benchmark times that apply call over a single large 
single-bucket {@code PurgeDirectories}
+ * transaction, isolating the apply-thread CPU that the workspace changes 
reduce:
+ * <ul>
+ *   <li>sub-entries are read as scalars straight from the protobuf instead of 
building a full {@link OmKeyInfo}
+ *       (with its key-location, ACL, metadata and encryption objects) per 
entry, and</li>
+ *   <li>the owning bucket's cache entry is memoized once per apply instead of 
looked up per entry.</li>
+ * </ul>
+ *
+ * <p>The {@code benchmark} tag is excluded from {@code mvn test} and CI by 
default, so it must be re-enabled
+ * explicitly to run on demand:
+ * <pre>
+ *   mvn -pl :ozone-manager test -DskipShade -DskipRecon -DskipDocs \
+ *     -Dtest=TestOMDirectoriesPurgeApplyPerf -Dgroups=benchmark 
-Dexcluded-test-groups= \
+ *     -Dsurefire.failIfNoSpecifiedTests=false
+ * </pre>
+ * Tunables (system properties): {@code bench.subEntries} (default 1000), 
{@code bench.iters} (default 20),
+ * {@code bench.warmup} (default 5).
+ */
+@Tag("benchmark")
+public class TestOMDirectoriesPurgeApplyPerf extends OMKeyRequestTests {
+
+  private static final long DIR_OBJECT_ID = 1L;
+  private static final long BLOCK_LENGTH = 100L;
+  private static final int VERSIONS_PER_FILE = 2;
+  private static final int BLOCKS_PER_VERSION = 3;
+
+  @Test
+  public void benchmarkApplyThreadTime() throws Exception {
+    // Match production: the debug-guarded success audit is off by default, so 
it should not colour the timing.
+    GenericTestUtils.setLogLevel(OMKeyRequest.class, Level.INFO);
+
+    final int subEntries = Integer.getInteger("bench.subEntries", 1000);
+    final int iters = Integer.getInteger("bench.iters", 20);
+    final int warmup = Integer.getInteger("bench.warmup", 5);
+    final long applies = (long) (iters + warmup);
+
+    when(ozoneManager.getDefaultReplicationConfig())
+        
.thenReturn(RatisReplicationConfig.getInstance(HddsProtos.ReplicationFactor.THREE));
+
+    String bucket = "bucket" + UUID.randomUUID();
+    OMRequestTestUtils.addVolumeAndBucketToDB(volumeName, bucket, 
omMetadataManager,
+        BucketLayout.FILE_SYSTEM_OPTIMIZED);
+    String bucketKey = omMetadataManager.getBucketKey(volumeName, bucket);
+    OmBucketInfo bucketInfo = 
omMetadataManager.getBucketTable().get(bucketKey);
+    long volumeId = omMetadataManager.getVolumeId(volumeName);
+    long bucketId = bucketInfo.getObjectID();
+
+    // Seed enough quota that the per-apply decrements (repeated across warmup 
+ measured runs) never underflow.
+    long perFileReplicatedBytes = replicatedBytesPerFile();
+    bucketInfo.incrUsedBytes(applies * subEntries * perFileReplicatedBytes * 
2);
+    bucketInfo.incrUsedNamespace(applies * subEntries * 2L * 2);
+    omMetadataManager.getBucketTable().addCacheEntry(new 
CacheKey<>(bucketKey), CacheValue.get(1L, bucketInfo));
+    omMetadataManager.getBucketTable().put(bucketKey, bucketInfo);
+
+    OMRequest preExecuted = preExecutePurgeRequest(
+        buildSingleBucketPurgeRequest(volumeId, bucketId, bucket, subEntries));
+
+    // Warmup (let the JIT compile the apply path) then measure.
+    for (int i = 0; i < warmup; i++) {
+      applyOnce(preExecuted);
+    }
+    long[] samplesNs = new long[iters];
+    long blackhole = 0;
+    for (int i = 0; i < iters; i++) {
+      long t0 = System.nanoTime();
+      blackhole += applyOnce(preExecuted);
+      samplesNs[i] = System.nanoTime() - t0;
+    }
+
+    Arrays.sort(samplesNs);
+    long sum = 0;
+    for (long s : samplesNs) {
+      sum += s;
+    }
+    double meanUs = sum / (double) iters / 1000.0;
+    double p50Us = samplesNs[iters / 2] / 1000.0;
+    double p90Us = samplesNs[Math.min(iters - 1, (int) Math.ceil(iters * 0.9) 
- 1)] / 1000.0;
+    double minUs = samplesNs[0] / 1000.0;
+    double maxUs = samplesNs[iters - 1] / 1000.0;
+
+    // Single grep-able line so baseline and changed runs can be compared 
directly.
+    System.out.printf("BENCH apply subEntries=%d (subFiles+subDirs) iters=%d 
warmup=%d "
+            + "meanUs=%.1f p50Us=%.1f p90Us=%.1f minUs=%.1f maxUs=%.1f 
totalMs=%.1f blackhole=%d%n",
+        subEntries, iters, warmup, meanUs, p50Us, p90Us, minUs, maxUs, sum / 
1_000_000.0, blackhole);
+  }
+
+  private long applyOnce(OMRequest preExecuted) {
+    OMDirectoriesPurgeRequestWithFSO request = new 
OMDirectoriesPurgeRequestWithFSO(preExecuted);
+    // Only validateAndUpdateCache runs on the apply thread; the RocksDB batch 
flush happens off-thread later, so it is
+    // intentionally excluded here.
+    ExecutionContext context = ExecutionContext.of(100L, TermIndex.valueOf(1L, 
100L));
+    // Consume the (always non-null) response as a blackhole so the JIT cannot 
elide the apply call.
+    return 
System.identityHashCode(request.validateAndUpdateCache(ozoneManager, context));
+  }
+
+  private long replicatedBytesPerFile() {
+    // getReplicatedSize(len, RATIS THREE) == 3 * len; VERSIONS_PER_FILE * 
BLOCKS_PER_VERSION blocks per file.
+    return (long) VERSIONS_PER_FILE * BLOCKS_PER_VERSION * BLOCK_LENGTH * 3;
+  }
+
+  private OMRequest preExecutePurgeRequest(OMRequest omRequest) throws 
java.io.IOException {
+    return new OMKeyPurgeRequest(omRequest).preExecute(ozoneManager);
+  }
+
+  /**
+   * Builds one {@link PurgePathRequest} that marks {@code subEntries} 
sub-directories deleted and moves
+   * {@code subEntries} sub-files, all under a single bucket, wrapped in a 
{@code PurgeDirectories} {@link OMRequest}.
+   */
+  private OMRequest buildSingleBucketPurgeRequest(long volumeId, long 
bucketId, String bucket, int subEntries) {
+    PurgePathRequest.Builder path = PurgePathRequest.newBuilder()
+        .setVolumeId(volumeId)
+        .setBucketId(bucketId);
+
+    long objectId = 100L;
+    for (int i = 0; i < subEntries; i++) {
+      OmDirectoryInfo subdir = OmDirectoryInfo.newBuilder()
+          .setName("subdir" + i)
+          .setCreationTime(Time.now())
+          .setModificationTime(Time.now())
+          .setObjectID(objectId++)
+          .setParentObjectID(DIR_OBJECT_ID)
+          .setUpdateID(0)
+          .build();
+      OmKeyInfo subDirKey = getOmKeyInfo(volumeName, bucket, subdir, "dir1/" + 
subdir.getName());
+      
path.addMarkDeletedSubDirs(subDirKey.getProtobuf(ClientVersion.CURRENT_VERSION));
+
+      OmKeyInfo subFile = OMRequestTestUtils.createOmKeyInfo(volumeName, 
bucket, "dir1/file" + i,
+              
RatisReplicationConfig.getInstance(HddsProtos.ReplicationFactor.THREE))
+          .setObjectID(objectId++)
+          .setParentObjectID(DIR_OBJECT_ID)
+          .setUpdateID(100L)
+          .build();
+      subFile.setKeyLocationVersions(buildLocationVersions());
+      path.addDeletedSubFiles(subFile.getProtobuf(true, 
ClientVersion.CURRENT_VERSION));
+    }
+
+    OzoneManagerProtocolProtos.PurgeDirectoriesRequest.Builder purgeDir =
+        OzoneManagerProtocolProtos.PurgeDirectoriesRequest.newBuilder()
+            .addDeletedPath(path.build())
+            .addBucketNameInfos(BucketNameInfo.newBuilder()
+                .setVolumeName(volumeName).setBucketName(bucket)
+                .setVolumeId(volumeId).setBucketId(bucketId).build());
+
+    return OMRequest.newBuilder()
+        .setCmdType(OzoneManagerProtocolProtos.Type.PurgeDirectories)
+        .setPurgeDirectoriesRequest(purgeDir)
+        .setClientId(UUID.randomUUID().toString())
+        .build();
+  }
+
+  private List<OmKeyLocationInfoGroup> buildLocationVersions() {
+    List<OmKeyLocationInfoGroup> versions = new ArrayList<>(VERSIONS_PER_FILE);
+    for (int v = 0; v < VERSIONS_PER_FILE; v++) {
+      List<OmKeyLocationInfo> locations = new ArrayList<>(BLOCKS_PER_VERSION);
+      for (int b = 0; b < BLOCKS_PER_VERSION; b++) {
+        locations.add(new OmKeyLocationInfo.Builder()
+            .setLength(BLOCK_LENGTH)
+            .setBlockID(new BlockID(1 + v, 1 + b))
+            .build());
+      }
+      versions.add(new OmKeyLocationInfoGroup(v, locations, false));
+    }
+    return versions;
+  }
+
+  @Override
+  public BucketLayout getBucketLayout() {
+    return BucketLayout.FILE_SYSTEM_OPTIMIZED;
+  }
+}
diff --git 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMDirectoriesPurgeRequestAndResponse.java
 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMDirectoriesPurgeRequestAndResponse.java
index 8c74321f848..f5442acfdc7 100644
--- 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMDirectoriesPurgeRequestAndResponse.java
+++ 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMDirectoriesPurgeRequestAndResponse.java
@@ -56,6 +56,7 @@
 import org.apache.hadoop.hdds.utils.db.cache.CacheKey;
 import org.apache.hadoop.hdds.utils.db.cache.CacheValue;
 import org.apache.hadoop.ozone.ClientVersion;
+import org.apache.hadoop.ozone.OzoneConsts;
 import org.apache.hadoop.ozone.om.OMMetadataManager;
 import org.apache.hadoop.ozone.om.OmSnapshot;
 import org.apache.hadoop.ozone.om.helpers.BucketLayout;
@@ -618,6 +619,91 @@ public void 
testValidateAndUpdateCacheQuotaBucketRecreated()
     validateDeletedKeys(omMetadataManager, deletedKeyNames);
   }
 
+  /**
+   * Guards the apply-path light parse in {@link 
OMDirectoriesPurgeRequestWithFSO}: for a sub-file the request reads the
+   * replicated byte sum and the hsync client id directly from the protobuf 
instead of building a full
+   * {@link OmKeyInfo}. This asserts the two subtle spots stay equivalent to 
the full parse: a multi-version,
+   * replication-factor-three block sum decrements bucket quota to exactly 
zero, and the matching open key is marked
+   * deleted via its hsync metadata.
+   */
+  @Test
+  public void testLightParsePreservesReplicatedByteSumAndHsyncForSubFiles() 
throws Exception {
+    when(ozoneManager.getDefaultReplicationConfig())
+        
.thenReturn(RatisReplicationConfig.getInstance(HddsProtos.ReplicationFactor.THREE));
+    String bucket = "bucket" + RandomUtils.secure().randomInt();
+    OMRequestTestUtils.addVolumeAndBucketToDB(volumeName, bucket,
+        omMetadataManager, BucketLayout.FILE_SYSTEM_OPTIMIZED);
+    String bucketKey = omMetadataManager.getBucketKey(volumeName, bucket);
+    OmBucketInfo bucketInfo = 
omMetadataManager.getBucketTable().get(bucketKey);
+    long volumeId = omMetadataManager.getVolumeId(volumeName);
+    long bucketId = bucketInfo.getObjectID();
+
+    OmDirectoryInfo dir1 = OmDirectoryInfo.newBuilder()
+        .setName("dir1")
+        .setCreationTime(Time.now())
+        .setModificationTime(Time.now())
+        .setObjectID(1)
+        .setParentObjectID(bucketId)
+        .setUpdateID(0)
+        .build();
+    OMRequestTestUtils.addDirKeyToDirTable(false, dir1, volumeName, bucket, 
1L, omMetadataManager);
+
+    // Sub-file with RATIS THREE replication, two block-location versions of 
different lengths, and hsync metadata.
+    String hsyncClientId = UUID.randomUUID().toString();
+    OmKeyInfo subFile = OMRequestTestUtils.createOmKeyInfo(volumeName, bucket, 
"file1",
+            
RatisReplicationConfig.getInstance(HddsProtos.ReplicationFactor.THREE))
+        .setObjectID(2L)
+        .setParentObjectID(dir1.getObjectID())
+        .setUpdateID(100L)
+        .addMetadata(OzoneConsts.HSYNC_CLIENT_ID, hsyncClientId)
+        .build();
+    List<OmKeyLocationInfoGroup> locationVersions = new ArrayList<>();
+    locationVersions.add(new OmKeyLocationInfoGroup(1, 
Collections.singletonList(
+        new OmKeyLocationInfo.Builder().setLength(100L).setBlockID(new 
BlockID(1, 1)).build()), false));
+    locationVersions.add(new OmKeyLocationInfoGroup(2, 
Collections.singletonList(
+        new OmKeyLocationInfo.Builder().setLength(250L).setBlockID(new 
BlockID(1, 2)).build()), false));
+    subFile.setKeyLocationVersions(locationVersions);
+
+    long expectedReplicatedBytes = OMKeyRequest.sumBlockLengths(subFile);
+    assertEquals((100L + 250L) * 3, expectedReplicatedBytes);
+
+    // Seed bucket quota to exactly the full-parse byte sum so a correct 
light-parse decrement lands on zero.
+    bucketInfo.incrUsedBytes(expectedReplicatedBytes);
+    bucketInfo.incrUsedNamespace(1L);
+    omMetadataManager.getBucketTable().addCacheEntry(new 
CacheKey<>(bucketKey), CacheValue.get(1L, bucketInfo));
+    omMetadataManager.getBucketTable().put(bucketKey, bucketInfo);
+
+    // Seed the matching open key that the hsync purge leg should mark deleted.
+    String dbOpenKey = omMetadataManager.getOpenFileName(volumeId, bucketId, 
dir1.getObjectID(), "file1",
+        hsyncClientId);
+    OmKeyInfo openKeyInfo = OMRequestTestUtils.createOmKeyInfo(volumeName, 
bucket, "file1",
+            
RatisReplicationConfig.getInstance(HddsProtos.ReplicationFactor.THREE))
+        .setObjectID(2L)
+        .setParentObjectID(dir1.getObjectID())
+        .setUpdateID(100L)
+        .build();
+    
omMetadataManager.getOpenKeyTable(BucketLayout.FILE_SYSTEM_OPTIMIZED).put(dbOpenKey,
 openKeyInfo);
+
+    subFile.setKeyName("dir1/file1");
+    OMRequest omRequest = createPurgeKeysRequest(null, null, 
Collections.emptyList(),
+        Collections.singletonList(subFile), bucketInfo);
+    OMDirectoriesPurgeRequestWithFSO purgeRequest =
+        new OMDirectoriesPurgeRequestWithFSO(preExecute(omRequest));
+    OMDirectoriesPurgeResponseWithFSO omClientResponse =
+        (OMDirectoriesPurgeResponseWithFSO) 
purgeRequest.validateAndUpdateCache(ozoneManager, 100L);
+
+    // Light-parse block-length sum matches the full OmKeyInfo parse: bucket 
usedBytes drops exactly to zero.
+    OmBucketInfo updatedBucketInfo = 
omMetadataManager.getBucketTable().get(bucketKey);
+    assertEquals(0L, updatedBucketInfo.getUsedBytes());
+    assertEquals(expectedReplicatedBytes, 
updatedBucketInfo.getSnapshotUsedBytes());
+
+    performBatchOperationCommit(omClientResponse);
+
+    // hsync leg: the corresponding open key is marked deleted after the batch 
commit.
+    OmKeyInfo purgedOpenKey = 
omMetadataManager.getOpenKeyTable(BucketLayout.FILE_SYSTEM_OPTIMIZED).get(dbOpenKey);
+    assertEquals("true", 
purgedOpenKey.getMetadata().get(OzoneConsts.DELETED_HSYNC_KEY));
+  }
+
   private void performBatchOperationCommit(OMDirectoriesPurgeResponseWithFSO 
omClientResponse)
       throws ExecutionException, InterruptedException {
     CompletableFuture<Void> future = new CompletableFuture<>();
diff --git 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestDirectoryDeletingService.java
 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestDirectoryDeletingService.java
index fdb723dc319..4f3e2a2eeec 100644
--- 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestDirectoryDeletingService.java
+++ 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/service/TestDirectoryDeletingService.java
@@ -25,7 +25,6 @@
 
 import java.io.File;
 import java.io.IOException;
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.time.Duration;
 import java.util.ArrayList;
@@ -265,8 +264,7 @@ void testPurgeDirectoriesBatching() throws Exception {
     final int ratisLimitBytes = 2304;
 
     OzoneConfiguration conf = new OzoneConfiguration();
-    File testDir = Files.createTempDirectory("TestDDS-SubmitSpy").toFile();
-    ServerUtils.setOzoneMetaDirPath(conf, testDir.toString());
+    ServerUtils.setOzoneMetaDirPath(conf, folder.toFile().toString());
     conf.setTimeDuration(OMConfigKeys.OZONE_DIR_DELETING_SERVICE_INTERVAL, 
100, TimeUnit.MILLISECONDS);
     
conf.setStorageSize(OMConfigKeys.OZONE_OM_RATIS_LOG_APPENDER_QUEUE_BYTE_LIMIT, 
ratisLimitBytes, StorageUnit.BYTES);
     conf.setQuietMode(false);
@@ -327,8 +325,62 @@ void testPurgeDirectoriesBatching() throws Exception {
 
       assertThat(payloadBytes).as("Batch size should respect Ratis byte 
limit").isLessThanOrEqualTo(ratisLimitBytes);
     }
+  }
+
+  @Test
+  @DisplayName("DirectoryDeletingService submits one bucket per 
PurgeDirectories transaction")
+  void testPurgeDirectoriesGroupedByBucketPerTransaction() throws Exception {
+    OzoneConfiguration conf = new OzoneConfiguration();
+    ServerUtils.setOzoneMetaDirPath(conf, folder.toFile().toString());
+    conf.setTimeDuration(OMConfigKeys.OZONE_DIR_DELETING_SERVICE_INTERVAL, 
100, TimeUnit.MILLISECONDS);
+    conf.setQuietMode(false);
+
+    OmTestManagers managers = new OmTestManagers(conf);
+    om = managers.getOzoneManager();
+    KeyManager km = managers.getKeyManager();
+
+    DirectoryDeletingService real = (DirectoryDeletingService) 
km.getDirDeletingService();
+    DirectoryDeletingService dds = Mockito.spy(real);
+
+    List<OzoneManagerProtocolProtos.OMRequest> captured = new ArrayList<>();
+    Mockito.doAnswer(inv -> {
+      captured.add(inv.getArgument(0));
+      return OzoneManagerProtocolProtos.OMResponse.newBuilder()
+          
.setCmdType(OzoneManagerProtocolProtos.Type.PurgeDirectories).setStatus(OzoneManagerProtocolProtos.Status.OK)
+          .build();
+    
}).when(dds).submitRequest(Mockito.any(OzoneManagerProtocolProtos.OMRequest.class));
+
+    final long volumeId = 1L, bucketIdA = 2L, bucketIdB = 3L;
+    // Interleave paths from two different buckets to prove grouping is 
order-independent.
+    List<OzoneManagerProtocolProtos.PurgePathRequest> purgeList = new 
ArrayList<>();
+    Map<org.apache.hadoop.ozone.om.OMMetadataManager.VolumeBucketId, 
OzoneManagerProtocolProtos.BucketNameInfo>
+        bucketNameInfoMap = new HashMap<>();
+    for (long bucketId : new long[] {bucketIdA, bucketIdB}) {
+      bucketNameInfoMap.put(new 
org.apache.hadoop.ozone.om.OMMetadataManager.VolumeBucketId(volumeId, bucketId),
+          
OzoneManagerProtocolProtos.BucketNameInfo.newBuilder().setVolumeId(volumeId).setBucketId(bucketId)
+              .setVolumeName("v").setBucketName("b" + bucketId).build());
+    }
+    for (int i = 0; i < 6; i++) {
+      purgeList.add(OzoneManagerProtocolProtos.PurgePathRequest.newBuilder()
+          .setVolumeId(volumeId).setBucketId(bucketIdA).setDeletedDir("a-dir-" 
+ i).build());
+      purgeList.add(OzoneManagerProtocolProtos.PurgePathRequest.newBuilder()
+          .setVolumeId(volumeId).setBucketId(bucketIdB).setDeletedDir("b-dir-" 
+ i).build());
+    }
+
+    dds.optimizeDirDeletesAndSubmitRequest(0L, 0L, 0L, new ArrayList<>(), 
purgeList, null, Time.monotonicNow(), km,
+        kv -> true, kv -> true, bucketNameInfoMap, null, 1L, new 
AtomicInteger(Integer.MAX_VALUE));
 
-    org.apache.commons.io.FileUtils.deleteDirectory(testDir);
+    assertThat(captured).as("Expect one transaction per bucket when the byte 
limit is not hit").hasSize(2);
+    Set<Long> bucketsSeen = new HashSet<>();
+    for (OzoneManagerProtocolProtos.OMRequest omReq : captured) {
+      OzoneManagerProtocolProtos.PurgeDirectoriesRequest purge = 
omReq.getPurgeDirectoriesRequest();
+      Set<Long> bucketsInTxn = purge.getDeletedPathList().stream()
+          
.map(OzoneManagerProtocolProtos.PurgePathRequest::getBucketId).collect(java.util.stream.Collectors.toSet());
+      assertThat(bucketsInTxn).as("Each transaction must contain paths from a 
single bucket").hasSize(1);
+      bucketsSeen.addAll(bucketsInTxn);
+    }
+    assertThat(bucketsSeen).as("Both buckets should be purged across 
transactions").containsExactlyInAnyOrder(
+        bucketIdA, bucketIdB);
   }
 
 }


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

Reply via email to