diff --git a/bin/zkSnapshotRecursiveSummaryToolkit.cmd b/bin/zkSnapshotRecursiveSummaryToolkit.cmd new file mode 100644 index 00000000000..850704a3719 --- /dev/null +++ b/bin/zkSnapshotRecursiveSummaryToolkit.cmd @@ -0,0 +1,23 @@ +@echo off +REM Licensed to the Apache Software Foundation (ASF) under one or more +REM contributor license agreements. See the NOTICE file distributed with +REM this work for additional information regarding copyright ownership. +REM The ASF licenses this file to You under the Apache License, Version 2.0 +REM (the "License"); you may not use this file except in compliance with +REM the License. You may obtain a copy of the License at +REM +REM http://www.apache.org/licenses/LICENSE-2.0 +REM +REM Unless required by applicable law or agreed to in writing, software +REM distributed under the License is distributed on an "AS IS" BASIS, +REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +REM See the License for the specific language governing permissions and +REM limitations under the License. + +setlocal +call "%~dp0zkEnv.cmd" + +set ZOOMAIN=org.apache.zookeeper.server.SnapshotRecursiveSummary +call %JAVA% -cp "%CLASSPATH%" %ZOOMAIN% %* + +endlocal & exit /b %ERRORLEVEL% diff --git a/bin/zkSnapshotRecursiveSummaryToolkit.sh b/bin/zkSnapshotRecursiveSummaryToolkit.sh new file mode 100755 index 00000000000..d43be95a84c --- /dev/null +++ b/bin/zkSnapshotRecursiveSummaryToolkit.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# 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. + +ZOOBIN="${BASH_SOURCE-$0}" +ZOOBIN="$(dirname "${ZOOBIN}")" +ZOOBINDIR="$(cd "${ZOOBIN}"; pwd)" + +if [ -e "$ZOOBIN/../libexec/zkEnv.sh" ]; then + . "$ZOOBINDIR"/../libexec/zkEnv.sh +else + . "$ZOOBINDIR"/zkEnv.sh +fi + +"$JAVA" -cp "$CLASSPATH" $JVMFLAGS \ + org.apache.zookeeper.server.SnapshotRecursiveSummary "$@" diff --git a/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md b/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md index d5fd1887528..7fc685d9e8f 100644 --- a/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md +++ b/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md @@ -24,6 +24,7 @@ limitations under the License. * [zkTxnLogToolkit.sh](#zkTxnLogToolkit) * [zkSnapShotToolkit.sh](#zkSnapShotToolkit) * [zkSnapshotComparer.sh](#zkSnapshotComparer) + * [zkSnapshotRecursiveSummaryToolkit.sh](#zkSnapshotRecursiveSummaryToolkit) * [Testing](#Testing) * [Jepsen Test](#jepsen-test) @@ -252,24 +253,58 @@ Use `-d`, `--debug` to display filtered paths and comparison details. Use The batch report visits each depth and sorts paths alphabetically within it. It retains the upstream empty label for the root in comparison lines. + + +### zkSnapshotRecursiveSummaryToolkit.sh + +Recursively summarize one snapshot subtree. This is the native backport of +[ZOOKEEPER-4566](https://github.com/apache/zookeeper/commit/05b215994f5e145c2758c4089828b57ba471b329). + +```bash +bin/zkSnapshotRecursiveSummaryToolkit.sh snapshot.1 /app 1 +``` + +Usage: `SnapshotRecursiveSummary `. +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); + } + +}