This is an automated email from the ASF dual-hosted git repository.
smengcl 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 6a4513ddaec HDDS-15826. Recon: add cycle guard to NSSummary /du tree
walks (#10723)
6a4513ddaec is described below
commit 6a4513ddaec9ab08fd25c0c74b43da2cc7fa43c9
Author: Siyao Meng <[email protected]>
AuthorDate: Wed Aug 12 02:48:54 2026 -0700
HDDS-15826. Recon: add cycle guard to NSSummary /du tree walks (#10723)
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
.../org/apache/hadoop/ozone/recon/ReconServer.java | 10 ++
.../org/apache/hadoop/ozone/recon/ReconUtils.java | 55 +++---
.../ozone/recon/api/handlers/EntityHandler.java | 122 +++++++++++--
.../ozone/recon/metrics/NSSummaryMetrics.java | 91 ++++++++++
.../recon/spi/ReconNamespaceSummaryManager.java | 6 +
.../spi/impl/ReconNamespaceSummaryManagerImpl.java | 21 ++-
.../apache/hadoop/ozone/recon/TestReconUtils.java | 40 +++++
.../api/handlers/TestEntityHandlerCycleGuard.java | 196 +++++++++++++++++++++
.../ozone/recon/metrics/TestNSSummaryMetrics.java | 60 +++++++
9 files changed, 558 insertions(+), 43 deletions(-)
diff --git
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconServer.java
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconServer.java
index 76858731d80..07956814d4f 100644
---
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconServer.java
+++
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconServer.java
@@ -47,6 +47,7 @@
import org.apache.hadoop.hdds.utils.HddsServerUtil;
import org.apache.hadoop.ozone.OzoneSecurityUtil;
import org.apache.hadoop.ozone.recon.api.types.FeatureProvider;
+import org.apache.hadoop.ozone.recon.metrics.NSSummaryMetrics;
import org.apache.hadoop.ozone.recon.metrics.ReconTaskStatusMetrics;
import org.apache.hadoop.ozone.recon.scm.ReconSafeModeManager;
import org.apache.hadoop.ozone.recon.scm.ReconStorageConfig;
@@ -87,6 +88,7 @@ public class ReconServer extends GenericCli implements
Callable<Void> {
private OzoneConfiguration configuration;
private ReconStorageConfig reconStorage;
private CertificateClient certClient;
+ private NSSummaryMetrics nsSummaryMetrics;
private ReconTaskStatusMetrics reconTaskStatusMetrics;
private OzoneAdmins reconAdmins;
@@ -172,6 +174,7 @@ public Void call() throws Exception {
this.reconTaskStatusMetrics =
injector.getInstance(ReconTaskStatusMetrics.class);
+ this.nsSummaryMetrics = injector.getInstance(NSSummaryMetrics.class);
LOG.info("Initializing support of Recon Features...");
FeatureProvider.initFeatureSupport(configuration);
@@ -200,6 +203,10 @@ public Void call() throws Exception {
reconTaskStatusMetrics.register();
LOG.debug("ReconTaskStatusMetrics registered after schema upgrade");
}
+ if (nsSummaryMetrics != null) {
+ nsSummaryMetrics.register();
+ LOG.debug("NSSummaryMetrics registered after schema upgrade");
+ }
LOG.info("Recon server initialized successfully!");
} catch (Exception e) {
@@ -336,6 +343,9 @@ public void stop() {
if (reconTaskStatusMetrics != null) {
reconTaskStatusMetrics.unregister();
}
+ if (nsSummaryMetrics != null) {
+ nsSummaryMetrics.unregister();
+ }
isStarted = false;
if (reconDBProvider != null) {
try {
diff --git
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconUtils.java
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconUtils.java
index bddbb6da572..94028c0d55f 100644
---
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconUtils.java
+++
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconUtils.java
@@ -43,9 +43,12 @@
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.Instant;
+import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
+import java.util.Deque;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -781,27 +784,37 @@ public static void gatherSubPaths(long parentId,
List<String> subPaths,
long volumeID, long bucketID,
ReconNamespaceSummaryManager
reconNamespaceSummaryManager)
throws IOException {
- // Fetch the NSSummary object for parentId
- NSSummary parentSummary =
- reconNamespaceSummaryManager.getNSSummary(parentId);
- if (parentSummary == null) {
- return;
- }
-
- Set<Long> childDirIds = parentSummary.getChildDir();
- for (Long childId : childDirIds) {
- // Fetch the NSSummary for each child directory
- NSSummary childSummary =
- reconNamespaceSummaryManager.getNSSummary(childId);
- if (childSummary != null) {
- String subPath =
- ReconUtils.constructObjectPathWithPrefix(volumeID, bucketID,
- childId);
- // Add to subPaths
- subPaths.add(subPath);
- // Recurse into this child directory
- gatherSubPaths(childId, subPaths, volumeID, bucketID,
- reconNamespaceSummaryManager);
+ // Iterative traversal with a visited-set cycle guard. A corrupted
NSSummary
+ // tree (e.g. a directory listing itself or an ancestor as a child) would
+ // otherwise recurse forever and crash Recon with a StackOverflowError.
+ // Each node is fetched once, on pop, and its path recorded (except the
+ // traversal root, which is the caller's starting directory).
+ Set<Long> visited = new HashSet<>();
+ Deque<Long> stack = new ArrayDeque<>();
+ stack.push(parentId);
+ visited.add(parentId);
+ boolean cycleLogged = false;
+ while (!stack.isEmpty()) {
+ long currentId = stack.pop();
+ NSSummary summary = reconNamespaceSummaryManager.getNSSummary(currentId);
+ if (summary == null) {
+ continue;
+ }
+ if (currentId != parentId) {
+ subPaths.add(ReconUtils.constructObjectPathWithPrefix(volumeID,
+ bucketID, currentId));
+ }
+ for (Long childId : summary.getChildDir()) {
+ if (visited.add(childId)) {
+ stack.push(childId);
+ } else if (!cycleLogged) {
+ reconNamespaceSummaryManager.recordNSSummaryInvalidTreeDetection();
+ log.warn("Detected a repeated reference to object {} while walking "
+
+ "the NSSummary tree under object {} (volume {}, bucket {}); the
" +
+ "NSSummary data may be corrupted.", childId, parentId, volumeID,
+ bucketID);
+ cycleLogged = true;
+ }
}
}
}
diff --git
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/handlers/EntityHandler.java
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/handlers/EntityHandler.java
index dfb087a2234..ac9c78eed45 100644
---
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/handlers/EntityHandler.java
+++
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/handlers/EntityHandler.java
@@ -20,7 +20,12 @@
import static org.apache.hadoop.ozone.OzoneConsts.OM_KEY_PREFIX;
import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.Iterator;
import java.util.Set;
+import java.util.function.Consumer;
import org.apache.hadoop.hdds.scm.server.OzoneStorageContainerManager;
import org.apache.hadoop.ozone.OmUtils;
import org.apache.hadoop.ozone.om.helpers.BucketLayout;
@@ -33,12 +38,16 @@
import org.apache.hadoop.ozone.recon.api.types.QuotaUsageResponse;
import org.apache.hadoop.ozone.recon.recovery.ReconOMMetadataManager;
import org.apache.hadoop.ozone.recon.spi.ReconNamespaceSummaryManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* Class for handling all entity types.
*/
public abstract class EntityHandler {
+ private static final Logger LOG =
LoggerFactory.getLogger(EntityHandler.class);
+
private final ReconNamespaceSummaryManager reconNamespaceSummaryManager;
private final ReconOMMetadataManager omMetadataManager;
@@ -205,32 +214,111 @@ public static EntityHandler getEntityHandler(
* @throws IOException ioEx
*/
protected int[] getTotalFileSizeDist(long objectId) throws IOException {
- NSSummary nsSummary = reconNamespaceSummaryManager.getNSSummary(objectId);
- if (nsSummary == null) {
- return new int[ReconConstants.NUM_OF_FILE_SIZE_BINS];
- }
- int[] res = nsSummary.getFileSizeBucket();
- for (long childId: nsSummary.getChildDir()) {
- int[] subDirFileSizeDist = getTotalFileSizeDist(childId);
+ int[] res = new int[ReconConstants.NUM_OF_FILE_SIZE_BINS];
+ walkNSSummaryTree(objectId, nsSummary -> {
+ int[] fileSizeBucket = nsSummary.getFileSizeBucket();
for (int i = 0; i < ReconConstants.NUM_OF_FILE_SIZE_BINS; ++i) {
- res[i] += subDirFileSizeDist[i];
+ res[i] += fileSizeBucket[i];
}
- }
+ });
return res;
}
protected int getTotalDirCount(long objectId) throws IOException {
- NSSummary nsSummary =
- getReconNamespaceSummaryManager().getNSSummary(objectId);
- if (nsSummary == null) {
+ return walkNSSummaryTree(objectId, null);
+ }
+
+ /**
+ * Walk the NSSummary tree without retaining every object ID. Each stack
frame
+ * keeps one child iterator, so live memory is proportional to tree depth
+ * instead of the number of directories. The ancestor set prevents a corrupt
+ * child reference from walking back into the active path.
+ *
+ * @param objectId root object ID
+ * @param summaryConsumer optional consumer for each available NSSummary
+ * @return number of reachable subdirectory references
+ * @throws IOException if an NSSummary cannot be read
+ */
+ private int walkNSSummaryTree(long objectId,
+ Consumer<NSSummary> summaryConsumer) throws IOException {
+ NSSummary rootSummary =
reconNamespaceSummaryManager.getNSSummary(objectId);
+ if (rootSummary == null) {
return 0;
}
- Set<Long> subdirs = nsSummary.getChildDir();
- int totalCnt = subdirs.size();
- for (long subdir : subdirs) {
- totalCnt += getTotalDirCount(subdir);
+ if (summaryConsumer != null) {
+ summaryConsumer.accept(rootSummary);
+ }
+
+ Set<Long> ancestors = new HashSet<>();
+ Deque<NSSummaryTraversalFrame> stack = new ArrayDeque<>();
+ ancestors.add(objectId);
+ stack.push(new NSSummaryTraversalFrame(objectId,
+ rootSummary.getChildDir().iterator()));
+ int totalDirCount = 0;
+ boolean cycleLogged = false;
+ while (!stack.isEmpty()) {
+ NSSummaryTraversalFrame frame = stack.peek();
+ if (!frame.getChildIterator().hasNext()) {
+ stack.pop();
+ ancestors.remove(frame.getObjectId());
+ continue;
+ }
+
+ long childId = frame.getChildIterator().next();
+ if (ancestors.contains(childId)) {
+ if (!cycleLogged) {
+ logNSSummaryCycle(childId);
+ cycleLogged = true;
+ }
+ continue;
+ }
+
+ totalDirCount++;
+ NSSummary childSummary =
+ reconNamespaceSummaryManager.getNSSummary(childId);
+ if (childSummary == null) {
+ continue;
+ }
+ if (summaryConsumer != null) {
+ summaryConsumer.accept(childSummary);
+ }
+ ancestors.add(childId);
+ stack.push(new NSSummaryTraversalFrame(childId,
+ childSummary.getChildDir().iterator()));
+ }
+ return totalDirCount;
+ }
+
+ /**
+ * Warn that the NSSummary tree contains a self or ancestor loop. The walk
+ * skips the cyclic edge so the request still completes and operators can see
+ * that the persisted NSSummary data may be corrupted. Callers invoke this at
+ * most once per walk.
+ */
+ private void logNSSummaryCycle(long objectId) {
+ reconNamespaceSummaryManager.recordNSSummaryInvalidTreeDetection();
+ LOG.warn("Detected a cycle through object {} while walking the " +
+ "NSSummary tree under {}; skipping the cyclic reference. The " +
+ "NSSummary data may be corrupted.", objectId, getNormalizedPath());
+ }
+
+ private static final class NSSummaryTraversalFrame {
+ private final long objectId;
+ private final Iterator<Long> childIterator;
+
+ private NSSummaryTraversalFrame(long objectId,
+ Iterator<Long> childIterator) {
+ this.objectId = objectId;
+ this.childIterator = childIterator;
+ }
+
+ private long getObjectId() {
+ return objectId;
+ }
+
+ private Iterator<Long> getChildIterator() {
+ return childIterator;
}
- return totalCnt;
}
/**
diff --git
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/metrics/NSSummaryMetrics.java
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/metrics/NSSummaryMetrics.java
new file mode 100644
index 00000000000..4c35774402c
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/metrics/NSSummaryMetrics.java
@@ -0,0 +1,91 @@
+/*
+ * 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.recon.metrics;
+
+import com.google.inject.Singleton;
+import java.util.concurrent.atomic.AtomicLong;
+import org.apache.hadoop.hdds.annotation.InterfaceAudience;
+import org.apache.hadoop.metrics2.MetricsCollector;
+import org.apache.hadoop.metrics2.MetricsInfo;
+import org.apache.hadoop.metrics2.MetricsRecordBuilder;
+import org.apache.hadoop.metrics2.MetricsSource;
+import org.apache.hadoop.metrics2.annotation.Metrics;
+import org.apache.hadoop.metrics2.lib.DefaultMetricsSystem;
+import org.apache.hadoop.metrics2.lib.Interns;
+import org.apache.hadoop.ozone.OzoneConsts;
+import org.apache.hadoop.util.Time;
+
+/**
+ * Metrics for NSSummary tree traversals that detect invalid references.
+ */
[email protected]
+@Singleton
+@Metrics(about = "Recon NSSummary Metrics", context = OzoneConsts.OZONE)
+public final class NSSummaryMetrics implements MetricsSource {
+
+ private static final String SOURCE_NAME =
+ NSSummaryMetrics.class.getSimpleName();
+
+ private static final MetricsInfo INVALID_TREE_DETECTION_COUNT = Interns.info(
+ "invalidTreeDetectionCount",
+ "Number of NSSummary tree traversals that detected an invalid reference
since Recon started");
+
+ private static final MetricsInfo LAST_INVALID_TREE_DETECTION_MILLIS =
+ Interns.info("lastInvalidTreeDetectionMillis",
+ "Epoch time in milliseconds of the last NSSummary tree traversal
that detected an invalid reference");
+
+ private final AtomicLong invalidTreeDetectionCount = new AtomicLong();
+ private final AtomicLong lastInvalidTreeDetectionMillis =
+ new AtomicLong();
+
+ public void register() {
+ DefaultMetricsSystem.instance().register(
+ SOURCE_NAME, "Recon NSSummary Metrics", this);
+ }
+
+ public void unregister() {
+ DefaultMetricsSystem.instance().unregisterSource(SOURCE_NAME);
+ }
+
+ public void recordInvalidTreeDetection() {
+ recordInvalidTreeDetection(Time.now());
+ }
+
+ void recordInvalidTreeDetection(long detectedMillis) {
+ invalidTreeDetectionCount.incrementAndGet();
+ lastInvalidTreeDetectionMillis.accumulateAndGet(
+ detectedMillis, Math::max);
+ }
+
+ public long getInvalidTreeDetectionCount() {
+ return invalidTreeDetectionCount.get();
+ }
+
+ public long getLastInvalidTreeDetectionMillis() {
+ return lastInvalidTreeDetectionMillis.get();
+ }
+
+ @Override
+ public void getMetrics(MetricsCollector collector, boolean all) {
+ MetricsRecordBuilder builder = collector.addRecord(SOURCE_NAME);
+ builder.addCounter(INVALID_TREE_DETECTION_COUNT,
+ getInvalidTreeDetectionCount());
+ builder.addGauge(LAST_INVALID_TREE_DETECTION_MILLIS,
+ getLastInvalidTreeDetectionMillis());
+ }
+}
diff --git
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/ReconNamespaceSummaryManager.java
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/ReconNamespaceSummaryManager.java
index 0c59f0921b4..b40867c4ad1 100644
---
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/ReconNamespaceSummaryManager.java
+++
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/ReconNamespaceSummaryManager.java
@@ -49,6 +49,12 @@ void batchStoreNSSummaries(BatchOperation batch, long
objectId,
NSSummary getNSSummary(long objectId) throws IOException;
+ /**
+ * Record that a read-side traversal detected at least one invalid or
+ * repeated child reference in the NSSummary tree.
+ */
+ void recordNSSummaryInvalidTreeDetection();
+
void commitBatchOperation(RDBBatchOperation rdbBatchOperation)
throws IOException;
}
diff --git
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/impl/ReconNamespaceSummaryManagerImpl.java
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/impl/ReconNamespaceSummaryManagerImpl.java
index 0287859399d..8525ec3f714 100644
---
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/impl/ReconNamespaceSummaryManagerImpl.java
+++
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/spi/impl/ReconNamespaceSummaryManagerImpl.java
@@ -26,6 +26,7 @@
import org.apache.hadoop.hdds.utils.db.RDBBatchOperation;
import org.apache.hadoop.hdds.utils.db.Table;
import org.apache.hadoop.ozone.recon.api.types.NSSummary;
+import org.apache.hadoop.ozone.recon.metrics.NSSummaryMetrics;
import org.apache.hadoop.ozone.recon.spi.ReconNamespaceSummaryManager;
import org.apache.hadoop.ozone.recon.tasks.NSSummaryTask;
@@ -38,23 +39,28 @@ public class ReconNamespaceSummaryManagerImpl
private Table<Long, NSSummary> nsSummaryTable;
private DBStore namespaceDbStore;
private NSSummaryTask nsSummaryTask;
+ private final NSSummaryMetrics nsSummaryMetrics;
@Inject
- public ReconNamespaceSummaryManagerImpl(ReconDBProvider reconDBProvider,
NSSummaryTask nsSummaryTask)
- throws IOException {
- this(reconDBProvider.getDbStore(), nsSummaryTask);
+ public ReconNamespaceSummaryManagerImpl(ReconDBProvider reconDBProvider,
+ NSSummaryTask nsSummaryTask, NSSummaryMetrics nsSummaryMetrics)
+ throws IOException {
+ this(reconDBProvider.getDbStore(), nsSummaryTask, nsSummaryMetrics);
}
- private ReconNamespaceSummaryManagerImpl(DBStore dbStore, NSSummaryTask
nsSummaryTask)
+ private ReconNamespaceSummaryManagerImpl(DBStore dbStore,
+ NSSummaryTask nsSummaryTask, NSSummaryMetrics nsSummaryMetrics)
throws IOException {
namespaceDbStore = dbStore;
this.nsSummaryTable = NAMESPACE_SUMMARY.getTable(namespaceDbStore);
this.nsSummaryTask = nsSummaryTask;
+ this.nsSummaryMetrics = nsSummaryMetrics;
}
@Override
public ReconNamespaceSummaryManager getStagedNsSummaryManager(DBStore
dbStore) throws IOException {
- return new ReconNamespaceSummaryManagerImpl(dbStore, nsSummaryTask);
+ return new ReconNamespaceSummaryManagerImpl(
+ dbStore, nsSummaryTask, nsSummaryMetrics);
}
@Override
@@ -97,6 +103,11 @@ public NSSummary getNSSummary(long objectId) throws
IOException {
return nsSummaryTable.get(objectId);
}
+ @Override
+ public void recordNSSummaryInvalidTreeDetection() {
+ nsSummaryMetrics.recordInvalidTreeDetection();
+ }
+
@Override
public void commitBatchOperation(RDBBatchOperation rdbBatchOperation)
throws IOException {
diff --git
a/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/TestReconUtils.java
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/TestReconUtils.java
index 6f613894dd8..8b061ad7403 100644
---
a/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/TestReconUtils.java
+++
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/TestReconUtils.java
@@ -19,12 +19,14 @@
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.hadoop.ozone.recon.ReconUtils.createTarFile;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.anyBoolean;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.File;
@@ -36,6 +38,10 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.RandomUtils;
@@ -45,7 +51,10 @@
import org.apache.hadoop.hdds.scm.container.ContainerInfo;
import org.apache.hadoop.hdds.scm.pipeline.PipelineID;
import org.apache.hadoop.hdfs.web.URLConnectionFactory;
+import org.apache.hadoop.ozone.recon.api.types.NSSummary;
+import org.apache.hadoop.ozone.recon.spi.ReconNamespaceSummaryManager;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.io.TempDir;
/**
@@ -175,6 +184,37 @@ public void testNextClosestPowerIndexOfTwo() {
}
}
+ @Test
+ @Timeout(30)
+ public void testGatherSubPathsToleratesCyclicTree() throws IOException {
+ // Corrupted NSSummary tree: 1 -> {2, 3}, 2 -> 1 (back edge), 3 -> 3 (self
+ // loop). gatherSubPaths must terminate instead of overflowing the stack,
+ // and must list each reachable child directory once.
+ ReconNamespaceSummaryManager nsSummaryManager =
+ mock(ReconNamespaceSummaryManager.class);
+ when(nsSummaryManager.getNSSummary(1L))
+ .thenReturn(nsSummaryWithChildren(2L, 3L));
+ when(nsSummaryManager.getNSSummary(2L))
+ .thenReturn(nsSummaryWithChildren(1L));
+ when(nsSummaryManager.getNSSummary(3L))
+ .thenReturn(nsSummaryWithChildren(3L));
+
+ List<String> subPaths = new ArrayList<>();
+ ReconUtils.gatherSubPaths(1L, subPaths, 100L, 200L, nsSummaryManager);
+
+ // Child directories reachable from parent 1 are exactly {2, 3}, each
emitted
+ // once. The traversal root (1) must be excluded and no child dropped, so
+ // assert the exact "/volumeId/bucketId/objectId" subpaths (in any order).
+ assertThat(subPaths).containsExactlyInAnyOrder("/100/200/2", "/100/200/3");
+ verify(nsSummaryManager).recordNSSummaryInvalidTreeDetection();
+ }
+
+ private static NSSummary nsSummaryWithChildren(Long... childIds) {
+ NSSummary nsSummary = new NSSummary();
+ nsSummary.setChildDir(new HashSet<>(Arrays.asList(childIds)));
+ return nsSummary;
+ }
+
static void assertNextClosestPowerIndexOfTwo(long n) {
final int expected = oldNextClosestPowerIndexOfTwoFixed(n);
final int computed = ReconUtils.nextClosestPowerIndexOfTwo(n);
diff --git
a/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/api/handlers/TestEntityHandlerCycleGuard.java
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/api/handlers/TestEntityHandlerCycleGuard.java
new file mode 100644
index 00000000000..69dd9e60a0a
--- /dev/null
+++
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/api/handlers/TestEntityHandlerCycleGuard.java
@@ -0,0 +1,196 @@
+/*
+ * 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.recon.api.handlers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.AbstractSet;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.hadoop.hdds.scm.server.OzoneStorageContainerManager;
+import org.apache.hadoop.ozone.recon.ReconConstants;
+import org.apache.hadoop.ozone.recon.api.types.DUResponse;
+import org.apache.hadoop.ozone.recon.api.types.FileSizeDistributionResponse;
+import org.apache.hadoop.ozone.recon.api.types.NSSummary;
+import org.apache.hadoop.ozone.recon.api.types.NamespaceSummaryResponse;
+import org.apache.hadoop.ozone.recon.api.types.QuotaUsageResponse;
+import org.apache.hadoop.ozone.recon.recovery.ReconOMMetadataManager;
+import org.apache.hadoop.ozone.recon.spi.ReconNamespaceSummaryManager;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+/**
+ * Tests that the NSSummary tree walks in {@link EntityHandler} tolerate a
+ * corrupted (self-referencing / cyclic) tree instead of crashing Recon with a
+ * {@link StackOverflowError}.
+ */
+public class TestEntityHandlerCycleGuard {
+
+ private final ReconNamespaceSummaryManager nsSummaryManager =
+ mock(ReconNamespaceSummaryManager.class);
+
+ private EntityHandler newHandler() {
+ ReconOMMetadataManager omMetadataManager =
+ mock(ReconOMMetadataManager.class);
+ OzoneStorageContainerManager reconSCM =
+ mock(OzoneStorageContainerManager.class);
+ return new EntityHandler(nsSummaryManager, omMetadataManager, reconSCM,
+ null, "/") {
+ @Override
+ public NamespaceSummaryResponse getSummaryResponse() {
+ return null;
+ }
+
+ @Override
+ public DUResponse getDuResponse(boolean listFile, boolean withReplica,
+ boolean sort) {
+ return null;
+ }
+
+ @Override
+ public QuotaUsageResponse getQuotaResponse() {
+ return null;
+ }
+
+ @Override
+ public FileSizeDistributionResponse getDistResponse() {
+ return null;
+ }
+ };
+ }
+
+ private NSSummary nsSummary(int numFiles, long size, Set<Long> children) {
+ int[] bucket = new int[ReconConstants.NUM_OF_FILE_SIZE_BINS];
+ bucket[0] = numFiles;
+ return new NSSummary(numFiles, size, size, bucket, children, "dir", 0);
+ }
+
+ @Test
+ @Timeout(30)
+ public void testSelfReferencingDirDoesNotOverflow() throws IOException {
+ // Directory 1 lists itself as its own child: the corruption that would
+ // otherwise recurse forever. Both walks must terminate.
+ when(nsSummaryManager.getNSSummary(1L))
+ .thenReturn(nsSummary(2, 100L, newSet(1L)));
+
+ EntityHandler handler = newHandler();
+ assertEquals(0, handler.getTotalDirCount(1L));
+ int[] dist = handler.getTotalFileSizeDist(1L);
+ assertEquals(2, dist[0]);
+ verify(nsSummaryManager, times(2))
+ .recordNSSummaryInvalidTreeDetection();
+ }
+
+ @Test
+ @Timeout(30)
+ public void testCyclicTreeCountsEachDirOnce() throws IOException {
+ // 1 -> 2 -> 3 -> 1 (back edge to the root) and 2 -> 2 (self loop).
+ when(nsSummaryManager.getNSSummary(1L))
+ .thenReturn(nsSummary(1, 10L, newSet(2L)));
+ when(nsSummaryManager.getNSSummary(2L))
+ .thenReturn(nsSummary(1, 20L, newSet(3L, 2L)));
+ when(nsSummaryManager.getNSSummary(3L))
+ .thenReturn(nsSummary(1, 30L, newSet(1L)));
+
+ EntityHandler handler = newHandler();
+ // Reachable directories other than the root object 1 are {2, 3}.
+ assertEquals(2, handler.getTotalDirCount(1L));
+ // Each distinct directory contributes its file count exactly once.
+ assertEquals(3, handler.getTotalFileSizeDist(1L)[0]);
+ verify(nsSummaryManager, times(2))
+ .recordNSSummaryInvalidTreeDetection();
+ }
+
+ @Test
+ @Timeout(30)
+ public void testCleanTreeUnaffected() throws IOException {
+ // 1 -> {2, 3}, 2 -> {4}. No cycles: counts match the pre-fix behavior.
+ when(nsSummaryManager.getNSSummary(1L))
+ .thenReturn(nsSummary(1, 10L, newSet(2L, 3L)));
+ when(nsSummaryManager.getNSSummary(2L))
+ .thenReturn(nsSummary(1, 20L, newSet(4L)));
+ when(nsSummaryManager.getNSSummary(3L))
+ .thenReturn(nsSummary(1, 30L, newSet()));
+ when(nsSummaryManager.getNSSummary(4L))
+ .thenReturn(nsSummary(1, 40L, newSet()));
+
+ EntityHandler handler = newHandler();
+ assertEquals(3, handler.getTotalDirCount(1L));
+ assertThat(handler.getTotalFileSizeDist(1L)[0]).isEqualTo(4);
+ }
+
+ @Test
+ @Timeout(30)
+ public void testWideTreeIsTraversedIncrementally() throws IOException {
+ int childCount = 10_000;
+ AtomicInteger generatedChildren = new AtomicInteger();
+ AtomicInteger childLookups = new AtomicInteger();
+ Set<Long> children = new AbstractSet<Long>() {
+ @Override
+ public Iterator<Long> iterator() {
+ return new Iterator<Long>() {
+ private long nextId = 2;
+
+ @Override
+ public boolean hasNext() {
+ return nextId <= childCount + 1L;
+ }
+
+ @Override
+ public Long next() {
+ assertEquals(childLookups.get(), generatedChildren.get(),
+ "Tree walk buffered child IDs before reading their NSSummary");
+ generatedChildren.incrementAndGet();
+ return nextId++;
+ }
+ };
+ }
+
+ @Override
+ public int size() {
+ return childCount;
+ }
+ };
+
+ when(nsSummaryManager.getNSSummary(anyLong())).thenAnswer(invocation -> {
+ long objectId = invocation.getArgument(0);
+ if (objectId == 1L) {
+ return nsSummary(0, 0L, children);
+ }
+ childLookups.incrementAndGet();
+ return null;
+ });
+
+ assertEquals(childCount, newHandler().getTotalDirCount(1L));
+ assertEquals(childCount, childLookups.get());
+ }
+
+ private static Set<Long> newSet(Long... ids) {
+ return new HashSet<>(Arrays.asList(ids));
+ }
+}
diff --git
a/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/metrics/TestNSSummaryMetrics.java
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/metrics/TestNSSummaryMetrics.java
new file mode 100644
index 00000000000..b3cb7360031
--- /dev/null
+++
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/metrics/TestNSSummaryMetrics.java
@@ -0,0 +1,60 @@
+/*
+ * 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.recon.metrics;
+
+import static org.apache.ozone.test.MetricsAsserts.getLongCounter;
+import static org.apache.ozone.test.MetricsAsserts.getLongGauge;
+import static org.apache.ozone.test.MetricsAsserts.getMetrics;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.apache.hadoop.metrics2.MetricsRecordBuilder;
+import org.apache.hadoop.util.Time;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link NSSummaryMetrics}.
+ */
+class TestNSSummaryMetrics {
+
+ @Test
+ void testRecordInvalidTreeDetection() {
+ NSSummaryMetrics metrics = new NSSummaryMetrics();
+ long before = Time.now();
+
+ metrics.recordInvalidTreeDetection();
+ metrics.recordInvalidTreeDetection();
+
+ MetricsRecordBuilder builder = getMetrics(metrics);
+ assertThat(getLongCounter("invalidTreeDetectionCount", builder))
+ .isEqualTo(2L);
+ assertThat(getLongGauge("lastInvalidTreeDetectionMillis", builder))
+ .isBetween(before, Time.now());
+ }
+
+ @Test
+ void testLastDetectedMillisDoesNotRegress() {
+ NSSummaryMetrics metrics = new NSSummaryMetrics();
+
+ metrics.recordInvalidTreeDetection(200L);
+ metrics.recordInvalidTreeDetection(100L);
+
+ assertThat(metrics.getInvalidTreeDetectionCount()).isEqualTo(2L);
+ assertThat(metrics.getLastInvalidTreeDetectionMillis())
+ .isEqualTo(200L);
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]