`.
+The starting node must be an existing absolute znode path. The maximum depth is
+a non-negative integer: 0 prints every non-leaf node, 1 prints the starting
+node and its non-leaf children, 2 adds another level, and so on.
+**Depth limits output only, not traversal or totals.**
+
+`children` is the number of all descendants, not just immediate children.
+`data` is the sum of payload bytes for the node itself and all descendants.
+Null data contributes zero bytes. Leaves contribute to their ancestors' totals
+but are not printed; selecting a leaf produces no summary entries.
+
+For example, if `/app` has 2 payload bytes, `/app/branch` has 3, and
+`/app/branch/leaf` has 5, the summary is:
+
+```text
+ /app
+ children: 2
+ data: 10
+-- /app/branch
+-- children: 1
+-- data: 8
+```
+
#### Snapshot analysis limitations
-The comparer runs offline, reads files without modifying them, and supports
-uncompressed, `.gz`, and `.snappy` snapshots, including mixed formats. It
-validates snapshot checksums and reports unreadable or corrupt input rather
-than silently producing a successful analysis. Invalid arguments or file paths
-exit with code 2; snapshot read failures exit with code 1. A Windows launcher
-with a `.cmd` extension is included. Both launchers use the existing `zkEnv`
-configuration.
+Both tools run offline, read files without modifying them, and support
+uncompressed, `.gz`, and `.snappy` snapshots (including mixed formats in the
+comparer). They validate snapshot checksums and report unreadable or corrupt
+input rather than silently producing a successful analysis. Invalid arguments,
+file paths or summary starting paths exit with code 2; snapshot read failures
+exit with code 1. Windows launchers with the same names and a `.cmd` extension
+are also included. The launchers use the existing `zkEnv` configuration.
-The comparer **includes ephemeral znodes** present in the snapshot; it does not
+Both tools **include ephemeral znodes** present in the snapshot; they do not
report session records. This reflects the upstream traversal behavior, despite
-the original description claiming that ephemerals were ignored. It does not
-compare payload contents, ACLs, versions or other znode metadata.
+the original comparer's description claiming that ephemerals were ignored.
+Neither tool compares payload contents, ACLs, versions or other znode metadata.
**Equal sizes/counts do not prove identical contents.** Snapshots may be fuzzy:
-the tool does not replay transaction logs, reconstruct point-in-time state,
-or establish transaction-consistent equality. It loads snapshots into memory,
-and recursive traversal visits the full snapshot.
+these tools do not replay transaction logs, reconstruct point-in-time state,
+or establish transaction-consistent equality. The tools load snapshots into
+memory, and recursive traversal still visits the full selected subtree.
diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotRecursiveSummary.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotRecursiveSummary.java
new file mode 100644
index 00000000000..d34bc38c4b6
--- /dev/null
+++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotRecursiveSummary.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.zookeeper.server;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Set;
+import java.util.TreeSet;
+import org.apache.yetus.audience.InterfaceAudience;
+import org.apache.zookeeper.ZKUtil;
+import org.apache.zookeeper.common.PathUtils;
+import org.apache.zookeeper.util.ServiceUtils;
+
+/**
+ * Recursively summarizes snapshot subtree data sizes and descendant counts.
+ * Only non-leaf nodes are printed, but totals include all descendants and ephemeral nodes.
+ * The maximum depth limits output, not traversal; zero means unlimited output depth.
+ *
+ * Backported from Apache ZooKeeper commit 05b215994f5e145c2758c4089828b57ba471b329
+ * (ZOOKEEPER-4566).
+ */
+@InterfaceAudience.Public
+public class SnapshotRecursiveSummary {
+
+ public static void main(String[] args) {
+ if (args.length != 3) {
+ System.err.println(getUsage());
+ ServiceUtils.requestSystemExit(ExitCode.INVALID_INVOCATION.getValue());
+ return;
+ }
+ try {
+ new SnapshotRecursiveSummary().run(args[0], args[1], Integer.parseInt(args[2]));
+ } catch (IllegalArgumentException e) {
+ System.err.println(e.getMessage());
+ System.err.println(getUsage());
+ ServiceUtils.requestSystemExit(ExitCode.INVALID_INVOCATION.getValue());
+ } catch (IOException e) {
+ System.err.println("Unable to read snapshot: " + e.getMessage());
+ ServiceUtils.requestSystemExit(ExitCode.UNEXPECTED_ERROR.getValue());
+ }
+ }
+
+ public void run(String snapshotFileName, String startingNode, int maxDepth) throws IOException {
+ PathUtils.validatePath(startingNode);
+ if (maxDepth < 0) {
+ throw new IllegalArgumentException("max_depth must be a non-negative integer.");
+ }
+ String error = ZKUtil.validateFileInput(snapshotFileName);
+ if (error != null) {
+ throw new IllegalArgumentException(error);
+ }
+ DataTree dataTree = SnapshotComparer.getSnapshot(new File(snapshotFileName));
+ if (dataTree.getNode(startingNode) == null) {
+ throw new IllegalArgumentException("Starting node does not exist: " + startingNode);
+ }
+ StringBuilder builder = new StringBuilder();
+ printZnode(dataTree, startingNode, builder, 0, maxDepth);
+ System.out.println(builder);
+ }
+
+ private long[] printZnode(DataTree dataTree, String name, StringBuilder builder, int level, int maxDepth) {
+ DataNode node = dataTree.getNode(name);
+ Set children;
+ long dataSize;
+ synchronized (node) {
+ dataSize = node.data == null ? 0 : node.data.length;
+ children = new TreeSet<>(node.getChildren());
+ }
+ long[] result = {1L, dataSize};
+ if (children.isEmpty()) {
+ return result;
+ }
+ StringBuilder childBuilder = new StringBuilder();
+ for (String child : children) {
+ long[] childResult = printZnode(dataTree, name + (name.equals("/") ? "" : "/") + child,
+ childBuilder, level + 1, maxDepth);
+ result[0] += childResult[0];
+ result[1] += childResult[1];
+ }
+ if (maxDepth == 0 || level <= maxDepth) {
+ String indent = String.join("", Collections.nCopies(level, "--"));
+ builder.append(indent).append(" ").append(name).append("\n");
+ builder.append(indent).append(" children: ").append(result[0] - 1).append("\n");
+ builder.append(indent).append(" data: ").append(result[1]).append("\n");
+ builder.append(childBuilder);
+ }
+ return result;
+ }
+
+ public static String getUsage() {
+ String newLine = System.lineSeparator();
+ return String.join(newLine,
+ "USAGE:",
+ "",
+ "SnapshotRecursiveSummary ",
+ "",
+ "snapshot_file: path to the zookeeper snapshot",
+ "starting_node: the absolute path in the zookeeper tree where traversal should begin",
+ "max_depth: non-negative output depth. 0 displays every non-leaf node; "
+ + "1 displays the starting node and its non-leaf children; 2 adds another level, and so on. "
+ + "This ONLY affects the level of details displayed, NOT the calculation.");
+ }
+
+}
diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotRecursiveSummaryTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotRecursiveSummaryTest.java
new file mode 100644
index 00000000000..2e326ee1fc6
--- /dev/null
+++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotRecursiveSummaryTest.java
@@ -0,0 +1,198 @@
+/*
+ * 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.zookeeper.server;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.not;
+import static org.junit.Assert.assertThat;
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import org.apache.zookeeper.ZKTestCase;
+import org.apache.zookeeper.ZooDefs;
+import org.apache.zookeeper.server.SnapshotComparerTest.SnapshotToolTestSupport;
+import org.apache.zookeeper.server.persistence.SnapStream.StreamMode;
+import org.junit.After;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SnapshotRecursiveSummaryTest extends ZKTestCase {
+
+ private SnapshotToolTestSupport support;
+
+ @Before
+ public void setUp() throws IOException {
+ support = new SnapshotToolTestSupport();
+ }
+
+ @After
+ public void tearDown() throws IOException {
+ support.close();
+ }
+
+ @Test
+ public void testRecursiveSubtreeTotals() throws Exception {
+ String output = summarize(tree(), "/app", "0");
+
+ assertThat(output, containsString(" /app\n children: 5\n data: 17"));
+ assertThat(output, containsString("-- /app/branch\n-- children: 2\n-- data: 8"));
+ assertThat(output, containsString("---- /app/branch/deep\n---- children: 1\n---- data: 5"));
+ assertThat(output, not(containsString(" /app/leaf\n")));
+ assertThat(output, not(containsString(" /app/empty\n")));
+ assertThat(output, not(containsString(" /zookeeper\n")));
+ }
+
+ @Test
+ public void testDepthOnlyLimitsOutput() throws Exception {
+ String output = summarize(tree(), "/app", "1");
+
+ assertThat(output, containsString(" /app\n children: 5\n data: 17"));
+ assertThat(output, containsString("-- /app/branch\n-- children: 2\n-- data: 8"));
+ assertThat(output, not(containsString(" /app/branch/deep\n")));
+ }
+
+ @Test
+ public void testRootIncludesItsOwnData() throws Exception {
+ DataTree tree = tree();
+ tree.setData("/", new byte[11], 1, 1, 1);
+
+ String output = summarize(tree, "/", "1");
+
+ assertThat(output, containsString(" /\n children: 9\n data: 28"));
+ assertThat(output, containsString("-- /app\n-- children: 5\n-- data: 17"));
+ assertThat(output, containsString("-- /zookeeper\n-- children: 2\n-- data: 0"));
+ }
+
+ @Test
+ public void testSelectedLeafIsNotPrinted() throws Exception {
+ String output = summarize(tree(), "/app/leaf", "0");
+
+ assertThat(output, not(containsString("children:")));
+ assertThat(output, not(containsString("data:")));
+ }
+
+ @Test
+ public void testNullDataContributesZeroBytes() throws Exception {
+ DataTree tree = new DataTree();
+ support.addNode(tree, "/app", null);
+ support.addNode(tree, "/app/leaf", null);
+
+ String output = summarize(tree, "/app", "0");
+
+ assertThat(output, containsString(" /app\n children: 1\n data: 0"));
+ }
+
+ @Test
+ public void testEphemeralDescendantsAreIncluded() throws Exception {
+ DataTree tree = new DataTree();
+ support.addNode(tree, "/app", new byte[2]);
+ tree.createNode("/app/ephemeral", new byte[7], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0x123L, -1, 1, 1);
+
+ String output = summarize(tree, "/app", "0");
+
+ assertThat(output, containsString(" /app\n children: 1\n data: 9"));
+ }
+
+ @Test
+ public void testCompressedSnapshots() throws Exception {
+ for (StreamMode mode : StreamMode.values()) {
+ File snapshot = support.snapshot(tree(), mode, true);
+ String output = run(snapshot.toString(), "/app", "0").success();
+ assertThat(output, containsString(" /app\n children: 5\n data: 17"));
+ }
+ }
+
+ @Test
+ public void testSnapshotWithoutDigest() throws Exception {
+ File snapshot = support.snapshot(tree(), StreamMode.CHECKED, false);
+
+ String output = run(snapshot.toString(), "/app", "0").success();
+
+ assertThat(output, containsString(" /app\n children: 5\n data: 17"));
+ }
+
+ @Test
+ public void testInvalidArguments() throws Exception {
+ File snapshot = support.snapshot(tree(), StreamMode.CHECKED, true);
+ run().invalidInvocation();
+ run(snapshot.toString(), "/app").invalidInvocation();
+ run(snapshot.toString(), "/app", "0", "extra").invalidInvocation();
+ for (String invalid : Arrays.asList("-1", "NaN", "2147483648")) {
+ run(snapshot.toString(), "/app", invalid).invalidInvocation();
+ }
+ }
+
+ @Test
+ public void testInvalidZnodePaths() throws Exception {
+ File snapshot = support.snapshot(tree(), StreamMode.CHECKED, true);
+ for (String invalid : Arrays.asList("", "app", "/app/", "/app//leaf", "/app/..", "/missing")) {
+ SnapshotToolTestSupport.Result result = run(snapshot.toString(), invalid, "0");
+ result.invalidInvocation();
+ assertThat(result.output, not(containsString("NullPointerException")));
+ }
+ }
+
+ @Test
+ public void testInvalidFiles() throws Exception {
+ run(new File(support.directory, "missing").toString(), "/", "0").invalidInvocation();
+ run(support.directory.toString(), "/", "0").invalidInvocation();
+ }
+
+ @Test
+ public void testCorruptSnapshots() throws Exception {
+ for (File corrupt : support.corruptSnapshots()) {
+ SnapshotToolTestSupport.Result result = run(corrupt.toString(), "/", "0");
+ result.readFailure();
+ assertThat(result.output, not(containsString("children:")));
+ }
+ }
+
+ @Test
+ public void testShellLauncher() throws Exception {
+ Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows"));
+ File snapshot = support.snapshot(tree(), StreamMode.GZIP, true);
+ for (String flags : Arrays.asList("", "-Xms32m -Xmx128m")) {
+ String output = support.runLauncher("zkSnapshotRecursiveSummaryToolkit.sh", flags,
+ snapshot.toString(), "/app", "0").success();
+ assertThat(output, containsString(" /app\n children: 5\n data: 17"));
+ }
+ }
+
+ private DataTree tree() throws Exception {
+ DataTree tree = new DataTree();
+ support.addNode(tree, "/app", new byte[2]);
+ support.addNode(tree, "/app/branch", new byte[3]);
+ support.addNode(tree, "/app/branch/deep", null);
+ support.addNode(tree, "/app/branch/deep/leaf", new byte[5]);
+ support.addNode(tree, "/app/empty", null);
+ support.addNode(tree, "/app/leaf", new byte[7]);
+ return tree;
+ }
+
+ private String summarize(DataTree tree, String path, String depth) throws Exception {
+ File snapshot = support.snapshot(tree, StreamMode.CHECKED, true);
+ return run(snapshot.toString(), path, depth).success();
+ }
+
+ private SnapshotToolTestSupport.Result run(String... args) throws Exception {
+ return support.run("org.apache.zookeeper.server.SnapshotRecursiveSummary", "", args);
+ }
+
+}