Implement nodetool history

patch by Stefan Miklosovic; reviewed by Maxim Muzafarov for CASSANDRA-20851
This commit is contained in:
Stefan Miklosovic 2025-08-19 17:12:27 +02:00
parent 63fb8741b8
commit 9c93d52ac9
No known key found for this signature in database
GPG Key ID: 32F35CB2F546D93E
7 changed files with 254 additions and 1 deletions

View File

@ -1,4 +1,5 @@
5.1
* Implement nodetool history (CASSANDRA-20851)
* Expose StorageService.dropPreparedStatements via JMX (CASSANDRA-20870)
* Expose Metric for Prepared Statement Cache Size (in bytes) (CASSANDRA-20864)
* Add support for BEGIN TRANSACTION to allow mutations that touch multiple partitions (CASSANDRA-20857)

View File

@ -133,7 +133,7 @@ public class NodeTool
String cmdLine = Joiner.on(" ").skipNulls().join(args);
cmdLine = cmdLine.replaceFirst("(?<=(-pw|--password))\\s+\\S+", " <hidden>");
try (FileWriter writer = new File(FBUtilities.getToolsOutputDirectory(), HISTORYFILE).newWriter(APPEND))
try (FileWriter writer = getHistoryFile().newWriter(APPEND))
{
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss,SSS");
writer.append(sdf.format(new Date())).append(": ").append(cmdLine).append(System.lineSeparator());
@ -144,6 +144,10 @@ public class NodeTool
}
}
public static File getHistoryFile()
{
return new File(FBUtilities.getToolsOutputDirectory(), HISTORYFILE);
}
public static List<String> getCommandsWithoutRoot(String separator)
{

View File

@ -0,0 +1,95 @@
/*
* 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.cassandra.tools.nodetool;
import java.util.List;
import org.apache.cassandra.io.util.File;
import org.apache.cassandra.io.util.FileUtils;
import org.apache.cassandra.tools.NodeProbe;
import org.apache.cassandra.tools.NodeTool;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
@Command(name = "history", description = "Print previously executed nodetool commands")
public class History extends AbstractCommand
{
@Option(paramLabel = "commands",
names = { "-n", "--num", "--number-of-commands" },
description = "Number of commands to print, defaults to 1000.")
public int commands = 1000;
@Override
protected void execute(NodeProbe probe)
{
if (commands < 1)
throw new IllegalArgumentException("Number of commands to display has to be at least 1.");
File historyFile = getHistoryFile();
validateHistoryFile(historyFile);
for (String line : commandsToPrint(historyFile))
output.out.println(line);
}
@Override
protected boolean shouldConnect() throws CommandLine.ExecutionException
{
return false;
}
File getHistoryFile()
{
return NodeTool.getHistoryFile();
}
List<String> commandsToPrint(File file)
{
List<String> historyCommands = FileUtils.readLines(file);
int size = historyCommands.size();
List<String> commandLines;
if (commands > size)
commandLines = historyCommands;
else
commandLines = historyCommands.subList(size - commands, size);
return commandLines;
}
/**
* Nodetool is appending command to history file before it is executed so us checking on its
* existence and validating it is not technically necessary however nodetool is also swallowing
* all errors when it was not succesful in appending to the history file so better to check here in that case.
*
* @param historyFile file to check that it is actually a file which exists and it is readable
*/
void validateHistoryFile(File historyFile)
{
if (!historyFile.exists())
throw new IllegalStateException(String.format("History file %s does not exist.%n", historyFile.absolutePath()));
if (!historyFile.isFile())
throw new IllegalStateException(String.format("History file %s is not a file.%n", historyFile.absolutePath()));
if (!historyFile.isReadable())
throw new IllegalStateException(String.format("History file %s is not readable.%n", historyFile.absolutePath()));
}
}

View File

@ -128,6 +128,7 @@ import static org.apache.cassandra.tools.nodetool.Help.printTopCommandUsage;
GossipInfo.class,
GuardrailsConfigCommand.GetGuardrailsConfig.class,
GuardrailsConfigCommand.SetGuardrailsConfig.class,
History.class,
Import.class,
Info.class,
InvalidateCIDRPermissionsCache.class,

View File

@ -0,0 +1,31 @@
NAME
nodetool history - Print previously executed nodetool commands
SYNOPSIS
nodetool [(-h <host> | --host <host>)] [(-p <port> | --port <port>)]
[(-pp | --print-port)] [(-pw <password> | --password <password>)]
[(-pwf <passwordFilePath> | --password-file <passwordFilePath>)]
[(-u <username> | --username <username>)] history
[(-n <commands> | --number-of-commands <commands>)]
OPTIONS
-h <host>, --host <host>
Node hostname or ip address
-n <commands>, --num <commands>, --number-of-commands <commands>
Number of commands to print, defaults to 1000.
-p <port>, --port <port>
Remote jmx agent port number
-pp, --print-port
Operate in 4.0 mode with hosts disambiguated by port number
-pw <password>, --password <password>
Remote jmx agent password
-pwf <passwordFilePath>, --password-file <passwordFilePath>
Path to the JMX password file
-u <username>, --username <username>
Remote jmx agent username

View File

@ -77,6 +77,7 @@ The most commonly used nodetool commands are:
gettraceprobability Print the current trace probability value
gossipinfo Shows the gossip information for the cluster
help Display help information
history Print previously executed nodetool commands
import Import new SSTables to the system
info Print node information (uptime, load, ...)
invalidatecidrpermissionscache Invalidate the cidr permissions cache

View File

@ -0,0 +1,120 @@
/*
* 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.cassandra.tools.nodetool;
import java.util.List;
import org.junit.BeforeClass;
import org.junit.Test;
import org.apache.cassandra.io.util.File;
import org.apache.cassandra.io.util.FileUtils;
import org.apache.cassandra.tools.Output;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class HistoryTest
{
public static File tempHistoryFile = FileUtils.createTempFile("test_nodetool", "history");
public static File emptyHistoryFile = FileUtils.createTempFile("test_nodetool_empty", "history");
public static List<String> listOfCommands = List.of("oldest command",
"older command",
"newer command",
"newest command");
@BeforeClass
public static void populateHistoryFile()
{
FileUtils.write(tempHistoryFile, listOfCommands);
}
@Test
public void testHistory()
{
History history = new History()
{
@Override
File getHistoryFile()
{
return HistoryTest.tempHistoryFile;
}
};
history.logger(Output.CONSOLE);
File historyFile = history.getHistoryFile();
assertThat(history.commandsToPrint(historyFile)).containsExactly(listOfCommands.toArray(new String[0]));
history.commands = 2;
assertThat(history.commandsToPrint(historyFile)).containsExactly(listOfCommands.subList(2, listOfCommands.size()).toArray(new String[0]));
history.commands = 4;
assertThat(history.commandsToPrint(historyFile)).containsExactly(listOfCommands.toArray(new String[0]));
}
@Test
public void testEmptyHistory()
{
History history = new History()
{
@Override
File getHistoryFile()
{
return HistoryTest.emptyHistoryFile;
}
};
history.logger(Output.CONSOLE);
File historyFile = history.getHistoryFile();
assertThat(history.commandsToPrint(historyFile)).isEmpty();
}
@Test
public void testHistoryOutputOnNonExistingHistoryFile()
{
History history = new History()
{
@Override
File getHistoryFile()
{
return new File("/does/not/exist");
}
};
history.logger(Output.CONSOLE);
assertThatThrownBy(() -> history.execute(null))
.isInstanceOf(IllegalStateException.class)
.hasMessage("History file /does/not/exist does not exist.\n");
}
@Test
public void testInvalidNumberOfCommandsToPrint()
{
History history = new History();
history.commands = 0;
assertThatThrownBy(() -> history.execute(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Number of commands to display has to be at least 1.");
}
}