Provide SSH command to analyze H2 caches Understanding how to configure cache-chroniclemap might be quite difficult without having more information about the size of the caches and average values of their keys and values. Provide an SSH command so that cache-chroniclemap can be installed as a plugin before being actually installed as lib module, in order to gather information about the existing persisted H2 caches. Bug: Issue 13475 Change-Id: I6f6ec28d68d9e5f9ff971a494bd45fd013e16d82
diff --git a/BUILD b/BUILD index 2336593..1ffe0a7 100644 --- a/BUILD +++ b/BUILD
@@ -12,6 +12,9 @@ name = "cache-chroniclemap", srcs = glob(["src/main/java/**/*.java"]), resources = glob(["src/main/resources/**/*"]), + manifest_entries = [ + "Gerrit-SshModule: com.googlesource.gerrit.modules.cache.chroniclemap.command.SSHCommandModule", + ], deps = [ "@chronicle-map//jar", "@chronicle-core//jar", @@ -23,6 +26,8 @@ "@javapoet//jar", "@jna-platform//jar", "@dev-jna//jar", + "//lib:h2", + "//lib/commons:io", ], )
diff --git a/config.md b/config.md index bffc481..ab189b5 100644 --- a/config.md +++ b/config.md
@@ -226,4 +226,10 @@ brand new persistent cache (i.e. delete the old one). More information on recovery can be found in the -[Official documentation](https://github.com/OpenHFT/Chronicle-Map/blob/master/docs/CM_Tutorial.adoc#recovery) \ No newline at end of file +[Official documentation](https://github.com/OpenHFT/Chronicle-Map/blob/master/docs/CM_Tutorial.adoc#recovery) + +### Tuning + +This module provides tooling to help understand how configuration should be +optimized for chronicle-map. +More information in the [tuning](tuning.md) documentation.
diff --git a/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/command/AnalyzeH2Caches.java b/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/command/AnalyzeH2Caches.java new file mode 100644 index 0000000..3dec1cd --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/command/AnalyzeH2Caches.java
@@ -0,0 +1,141 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed 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 com.googlesource.gerrit.modules.cache.chroniclemap.command; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.sshd.SshCommand; +import com.google.inject.Inject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.commons.io.FilenameUtils; +import org.eclipse.jgit.lib.Config; +import org.h2.Driver; + +public class AnalyzeH2Caches extends SshCommand { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private String cacheDirectory; + private SitePaths site; + + @Inject + AnalyzeH2Caches(@GerritServerConfig Config cfg, SitePaths site) { + this.cacheDirectory = cfg.getString("cache", null, "directory"); + this.site = site; + } + + @Override + protected void run() throws UnloggedFailure, Failure, Exception { + Set<Path> h2Files = getH2CacheFiles(); + stdout.println("Extracting information from H2 caches..."); + + Config config = new Config(); + for (Path h2 : h2Files) { + final String url = jdbcUrl(h2); + final String baseName = + FilenameUtils.removeExtension(FilenameUtils.getBaseName(h2.toString())); + try { + + try (Connection conn = Driver.load().connect(url, null); + Statement s = conn.createStatement(); + ResultSet r = + s.executeQuery( + "SELECT COUNT(*), AVG(OCTET_LENGTH(k)), AVG(OCTET_LENGTH(v)) FROM data")) { + if (r.next()) { + long size = r.getLong(1); + long avgKeySize = r.getLong(2); + long avgValueSize = r.getLong(3); + + if (size == 0) { + stdout.println(String.format("WARN: Cache %s is empty, skipping.", baseName)); + continue; + } + + config.setLong("cache", baseName, "entries", size); + config.setLong("cache", baseName, "avgKeySize", avgKeySize); + + // Account for extra serialization bytes of TimedValue entries. + short TIMED_VALUE_WRAPPER_OVERHEAD = Long.BYTES + Integer.BYTES; + config.setLong( + "cache", baseName, "avgValueSize", avgValueSize + TIMED_VALUE_WRAPPER_OVERHEAD); + } + } + } catch (SQLException e) { + stderr.println(String.format("Could not get information from %s", baseName)); + throw die(e); + } + } + stdout.println(); + stdout.println("****************************"); + stdout.println("** Chronicle-map template **"); + stdout.println("****************************"); + stdout.println(); + stdout.println(config.toText()); + } + + private Set<Path> getH2CacheFiles() throws UnloggedFailure { + + try { + final Optional<Path> maybeCacheDir = getCacheDir(site, cacheDirectory); + + return maybeCacheDir + .map( + cacheDir -> { + try { + return Files.walk(cacheDir) + .filter(path -> path.toString().endsWith("h2.db")) + .collect(Collectors.toSet()); + } catch (IOException e) { + logger.atSevere().withCause(e).log("Could not read H2 files"); + return Collections.<Path>emptySet(); + } + }) + .orElse(Collections.emptySet()); + } catch (IOException e) { + throw die(e); + } + } + + private String jdbcUrl(Path h2FilePath) { + final String normalized = + FilenameUtils.removeExtension(FilenameUtils.removeExtension(h2FilePath.toString())); + return "jdbc:h2:" + normalized + ";AUTO_SERVER=TRUE"; + } + + private static Optional<Path> getCacheDir(SitePaths site, String name) throws IOException { + if (name == null) { + return Optional.empty(); + } + Path loc = site.resolve(name); + if (!Files.exists(loc)) { + throw new IOException( + String.format("disk cache is configured but doesn't exist: %s", loc.toAbsolutePath())); + } + if (!Files.isReadable(loc)) { + throw new IOException(String.format("Can't read from disk cache: %s", loc.toAbsolutePath())); + } + logger.atFine().log("Enabling disk cache %s", loc.toAbsolutePath()); + return Optional.of(loc); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/command/SSHCommandModule.java b/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/command/SSHCommandModule.java new file mode 100644 index 0000000..038c177 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/modules/cache/chroniclemap/command/SSHCommandModule.java
@@ -0,0 +1,23 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed 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 com.googlesource.gerrit.modules.cache.chroniclemap.command; + +import com.google.gerrit.sshd.PluginCommandModule; + +public class SSHCommandModule extends PluginCommandModule { + @Override + protected void configureCommands() { + command("analyze-h2-caches").to(AnalyzeH2Caches.class); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/modules/cache/chroniclemap/command/AnalyzeH2CachesIT.java b/src/test/java/com/googlesource/gerrit/modules/cache/chroniclemap/command/AnalyzeH2CachesIT.java new file mode 100644 index 0000000..25327e0 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/modules/cache/chroniclemap/command/AnalyzeH2CachesIT.java
@@ -0,0 +1,106 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed 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 com.googlesource.gerrit.modules.cache.chroniclemap.command; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.gerrit.acceptance.LightweightPluginDaemonTest; +import com.google.gerrit.acceptance.TestPlugin; +import com.google.gerrit.acceptance.UseLocalDisk; +import com.google.gerrit.acceptance.UseSsh; +import com.google.gerrit.server.config.SitePaths; +import com.google.inject.Inject; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.Test; + +@UseSsh +@TestPlugin( + name = "cache-chroniclemap", + sshModule = "com.googlesource.gerrit.modules.cache.chroniclemap.command.SSHCommandModule") +public class AnalyzeH2CachesIT extends LightweightPluginDaemonTest { + + @Inject private SitePaths sitePaths; + + private String cmd = Joiner.on(" ").join("cache-chroniclemap", "analyze-h2-caches"); + + @Test + @UseLocalDisk + public void shouldAnalyzeH2Cache() throws Exception { + createChange(); + + String result = adminSshSession.exec(cmd); + + adminSshSession.assertSuccess(); + assertThat(result).contains("[cache \"mergeability\"]\n" + "\tentries = 1\n"); + assertThat(result).contains("[cache \"diff\"]\n" + "\tentries = 1\n"); + assertThat(result).contains("[cache \"accounts\"]\n" + "\tentries = 4\n"); + assertThat(result).contains("[cache \"diff_summary\"]\n" + "\tentries = 1\n"); + assertThat(result).contains("[cache \"persisted_projects\"]\n" + "\tentries = 3\n"); + } + + @Test + @UseLocalDisk + public void shouldProduceWarningWhenCacheFileIsEmpty() throws Exception { + List<String> expected = + ImmutableList.of( + "WARN: Cache diff_intraline is empty, skipping.", + "WARN: Cache change_kind is empty, skipping.", + "WARN: Cache diff_summary is empty, skipping.", + "WARN: Cache diff is empty, skipping.", + "WARN: Cache mergeability is empty, skipping.", + "WARN: Cache pure_revert is empty, skipping.", + "WARN: Cache git_tags is empty, skipping."); + String result = adminSshSession.exec(cmd); + + adminSshSession.assertSuccess(); + assertThat(ImmutableList.copyOf(result.split("\n"))).containsAtLeastElementsIn(expected); + } + + @Test + @UseLocalDisk + public void shouldIgnoreNonH2Files() throws Exception { + + Path cacheDirectory = sitePaths.resolve(cfg.getString("cache", null, "directory")); + Files.write(cacheDirectory.resolve("some.dat"), "some_content".getBytes()); + + List<String> expected = + ImmutableList.of( + "WARN: Cache diff_intraline is empty, skipping.", + "WARN: Cache change_kind is empty, skipping.", + "WARN: Cache diff_summary is empty, skipping.", + "WARN: Cache diff is empty, skipping.", + "WARN: Cache mergeability is empty, skipping.", + "WARN: Cache pure_revert is empty, skipping.", + "WARN: Cache git_tags is empty, skipping."); + String result = adminSshSession.exec(cmd); + + adminSshSession.assertSuccess(); + assertThat(ImmutableList.copyOf(result.split("\n"))).containsAtLeastElementsIn(expected); + } + + @Test + @UseLocalDisk + public void shouldFailWhenCacheDirectoryDoesNotExists() throws Exception { + cfg.setString("cache", null, "directory", "/tmp/non_existing_directory"); + + adminSshSession.exec(cmd); + adminSshSession.assertFailure( + "fatal: disk cache is configured but doesn't exist: /tmp/non_existing_directory"); + } +}
diff --git a/tuning.md b/tuning.md new file mode 100644 index 0000000..2f72668 --- /dev/null +++ b/tuning.md
@@ -0,0 +1,104 @@ +# Tuning + +Tuning chronicle-map correctly might be a daunting task: +How many entries does a particular cache instance need? +what is the average key and value for it? + +Rather than leaving you only with the trial and error (or the guesswork) +approach, this module provides a utility to help you get started in the right +direction. + +Since chronicle-map is one of the first open-source alternatives to the H2 +implementation, it is very likely that your Gerrit instance has been running +with the default H2 cache backend. + +The idea is to read from the _actual_ H2 persisted files and output the +information that will be required to configure chronicle-map as an alternative. + +You can do this _before_ installing cache-chroniclemap as a lib module so that +your Gerrit server will not need downtime. As follows: + +* Drop `cache-chroniclemap.jar` file in the `plugins/` directory. +* Wait for the pluginLoader to acknowledge and load the new plugin. You will +see an entry in the `error_log`: + +``` +INFO com.google.gerrit.server.plugins.PluginLoader : Loaded plugin cache-chroniclemap +``` + +* You can now run an analysis on the current status of your H2 caches + +```bash +ssh -p 29418 admin@<gerrit-server> cache-chroniclemap analyze-h2-caches +``` + +The result will be outputted on standard output in a git config format. +This is an example (the values are made up): + +``` +**************************** +** Chronicle-map template ** +**************************** + +[cache "diff_summary"] + entries = 101 + avgKeySize = 192 + avgValueSize = 1350 +[cache "web_sessions"] + entries = 1 + avgKeySize = 68 + avgValueSize = 332 +[cache "pure_revert"] + entries = 1 + avgKeySize = 112 + avgValueSize = 8 +[cache "mergeability"] + entries = 101 + avgKeySize = 150 + avgValueSize = 8 +[cache "diff"] + entries = 101 + avgKeySize = 188 + avgValueSize = 5035 +[cache "persisted_projects"] + entries = 2 + avgKeySize = 88 + avgValueSize = 4489 +[cache "accounts"] + entries = 5 + avgKeySize = 52 + avgValueSize = 505 +``` + +Empty caches (if any) will not generate empty config stanzas, rather a warning +will be displayed on standard output. + +For example: +``` +WARN: Cache diff_intraline is empty, skipping +``` + +Please note that the generated configuration is not necessarily final and it +might still need adjustments: +* Since chronicle-map file size is pre-allocated, you might want to allow for +more entries. +* You might want account for uncertainty by specifying a `maxBloatFactor` greater +than 1. +* any other reason. + +Once you gathered the information you wanted you might consider to remove the +plugin: + +* Remove the jar from the `plugins` directory + +```bash +rm plugins/cache-chroniclemap.jar +``` + +* Wait for the pluginLoader to acknowledge and unload the plugin. You will +see an entry in the `error_log`: + +``` +INFO com.google.gerrit.server.plugins.PluginLoader : Unloading plugin cache-chroniclemap +``` +