Merge 'Nodetool additional commands 3/N' from Botond Dénes

This PR implements the following new nodetool commands:
* cleanup
* clearsnapshots
* listsnapshots

All commands come with tests and all tests pass with both the new and the current nodetool implementations.

Refs: https://github.com/scylladb/scylladb/issues/15588

Closes scylladb/scylladb#15843

* github.com:scylladb/scylladb:
  tools/scylla-nodetool: implement the listsnapshots command
  tools/scylla-nodetool: implement clearsnapshot command
  tools/scylla-nodetool: implement the cleanup command
  test/nodetool: rest_api_mock: add more options for multiple requests
  tools/scylla-nodetool: log responses with trace level
This commit is contained in:
Avi Kivity
2023-10-30 21:53:36 +02:00
5 changed files with 375 additions and 23 deletions

View File

@@ -20,7 +20,11 @@ logger = logging.getLogger(__name__)
class expected_request:
def __init__(self, method: str, path: str, params: dict = {}, multiple: bool = False,
ANY = -1 # allow for any number of requests (including no requests at all), similar to the `*` quantity in regexp
ONE = 0 # exactly one request is allowed
MULTIPLE = 1 # one or more request is allowed
def __init__(self, method: str, path: str, params: dict = {}, multiple: int = ONE,
response: Dict[str, Any] = None, response_status: int = 200):
self.method = method
self.path = path
@@ -52,7 +56,7 @@ def _make_expected_request(req_json):
req_json["method"],
req_json["path"],
params=req_json.get("params", dict()),
multiple=req_json.get("multiple", False),
multiple=req_json.get("multiple", expected_request.ONE),
response=req_json.get("response"),
response_status=req_json.get("response_status", 200))
@@ -130,19 +134,24 @@ class rest_server(aiohttp.abc.AbstractRouter):
return aiohttp.web.Response(status=500, text="Expected no requests, got {this_req}")
expected_req = self.expected_requests[0]
if this_req != expected_req:
if expected_req.multiple and expected_req.hit > 0 and \
len(self.expected_requests) > 1 and self.expected_requests[1] == this_req:
while this_req != expected_req:
if expected_req.multiple == expected_request.ANY or (
expected_req.multiple >= expected_request.MULTIPLE and expected_req.hit >= expected_req.multiple):
logger.info(f"popping multi request {expected_req}")
del self.expected_requests[0]
expected_req = self.expected_requests[0]
else:
logger.error(f"unexpected request, expected {expected_req}, got {this_req}")
return aiohttp.web.Response(status=500, text="Expected {expected_req}, got {this_req}")
if not expected_req.multiple:
if len(self.expected_requests) > 0:
expected_req = self.expected_requests[0]
continue
logger.error(f"unexpected request, expected {expected_req}, got {this_req}")
return aiohttp.web.Response(status=500, text="Expected {expected_req}, got {this_req}")
if expected_req.multiple == expected_request.ONE:
del self.expected_requests[0]
expected_req.hit += 1
else:
expected_req.hit += 1
if expected_req.response is None:
logger.info(f"expected_request: {expected_req}, no response")

View File

@@ -0,0 +1,73 @@
#
# Copyright 2023-present ScyllaDB
#
# SPDX-License-Identifier: AGPL-3.0-or-later
#
from rest_api_mock import expected_request
import utils
def test_cleanup(nodetool):
nodetool("cleanup", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", params={"type": "non_local_strategy"},
response=["ks1", "ks2"]),
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.ANY,
response=["ks1", "ks2", "system"]),
expected_request("POST", "/storage_service/keyspace_cleanup/ks1", response=0),
expected_request("POST", "/storage_service/keyspace_cleanup/ks2", response=0),
])
def test_cleanup_keyspace(nodetool):
nodetool("cleanup", "ks1", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["ks1", "ks2", "system"]),
expected_request("POST", "/storage_service/keyspace_cleanup/ks1", response=0),
])
def test_cleanup_table(nodetool):
nodetool("cleanup", "ks1", "tbl1", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["ks1", "ks2", "system"]),
expected_request("POST", "/storage_service/keyspace_cleanup/ks1", params={"cf": "tbl1"}, response=0),
])
def test_cleanup_tables(nodetool):
nodetool("cleanup", "ks1", "tbl1", "tbl2", "tbl3", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["ks1", "ks2", "system"]),
expected_request("POST", "/storage_service/keyspace_cleanup/ks1", params={"cf": "tbl1,tbl2,tbl3"}, response=0),
])
def test_cleanup_nonexistent_keyspace(nodetool):
utils.check_nodetool_fails_with(
nodetool,
("cleanup", "non_existent_ks"),
{"expected_requests": [
expected_request("GET", "/storage_service/keyspaces", response=["ks1", "ks2", "system"])]},
["nodetool: Keyspace [non_existent_ks] does not exist.",
"error processing arguments: keyspace non_existent_ks does not exist"])
def test_cleanup_jobs_arg(nodetool):
nodetool("cleanup", "ks1", "-j", "0", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["ks1", "ks2", "system"]),
expected_request("POST", "/storage_service/keyspace_cleanup/ks1", response=0),
])
nodetool("cleanup", "ks1", "--jobs", "2", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["ks1", "ks2", "system"]),
expected_request("POST", "/storage_service/keyspace_cleanup/ks1", response=0),
])
nodetool("cleanup", "ks1", "--jobs=1", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["ks1", "ks2", "system"]),
expected_request("POST", "/storage_service/keyspace_cleanup/ks1", response=0),
])

View File

@@ -10,14 +10,16 @@ import utils
def test_all_keyspaces(nodetool):
nodetool("compact", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=True, response=["system", "system_schema"]),
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["system", "system_schema"]),
expected_request("POST", "/storage_service/keyspace_compaction/system"),
expected_request("POST", "/storage_service/keyspace_compaction/system_schema")])
def test_keyspace(nodetool):
nodetool("compact", "system_schema", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=True, response=["system", "system_schema"]),
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["system", "system_schema"]),
expected_request("POST", "/storage_service/keyspace_compaction/system_schema")])
@@ -26,7 +28,8 @@ def test_nonexistent_keyspace(nodetool):
nodetool,
("compact", "non_existent_ks"),
{"expected_requests": [
expected_request("GET", "/storage_service/keyspaces", multiple=True, response=["system"]),
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["system"]),
expected_request("POST", "/storage_service/keyspace_compaction/non_existent_ks")]},
["nodetool: Keyspace [non_existent_ks] does not exist.",
"error processing arguments: keyspace non_existent_ks does not exist"])
@@ -34,11 +37,13 @@ def test_nonexistent_keyspace(nodetool):
def test_table(nodetool):
nodetool("compact", "system_schema", "columns", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=True, response=["system", "system_schema"]),
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["system", "system_schema"]),
expected_request("POST", "/storage_service/keyspace_compaction/system_schema", params={"cf": "columns"})])
nodetool("compact", "system_schema", "columns", "computed_columns", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=True, response=["system", "system_schema"]),
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["system", "system_schema"]),
expected_request("POST",
"/storage_service/keyspace_compaction/system_schema",
params={"cf": "columns,computed_columns"})])
@@ -46,7 +51,8 @@ def test_table(nodetool):
def test_split_output_compatibility_argument(nodetool):
dummy_request = [
expected_request("GET", "/storage_service/keyspaces", multiple=True, response=["system", "system_schema"]),
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["system", "system_schema"]),
expected_request("POST", "/storage_service/keyspace_compaction/system_schema")]
nodetool("compact", "system_schema", "-s", expected_requests=dummy_request)
@@ -55,7 +61,8 @@ def test_split_output_compatibility_argument(nodetool):
def test_token_range_compatibility_argument(nodetool):
dummy_request = [
expected_request("GET", "/storage_service/keyspaces", multiple=True, response=["system", "system_schema"]),
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.MULTIPLE,
response=["system", "system_schema"]),
expected_request("POST", "/storage_service/keyspace_compaction/system_schema")]
nodetool("compact", "system_schema", "-st", "0", "-et", "1000", expected_requests=dummy_request)

View File

@@ -0,0 +1,101 @@
#
# Copyright 2023-present ScyllaDB
#
# SPDX-License-Identifier: AGPL-3.0-or-later
#
from rest_api_mock import expected_request
import utils
def test_clearnapshot(nodetool):
nodetool("clearsnapshot", expected_requests=[
expected_request("DELETE", "/storage_service/snapshots")
])
def test_clearnapshot_keyspace(nodetool):
nodetool("clearsnapshot", "ks1", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.ANY,
response=["ks1", "ks2"]),
expected_request("DELETE", "/storage_service/snapshots", params={"kn": "ks1"})
])
def test_clearnapshot_keyspaces(nodetool):
nodetool("clearsnapshot", "ks1", "ks2", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.ANY,
response=["ks1", "ks2"]),
expected_request("DELETE", "/storage_service/snapshots", params={"kn": "ks1,ks2"})
])
def test_clearnapshot_nonexistent_keyspaces(nodetool, scylla_only):
utils.check_nodetool_fails_with(
nodetool,
("clearsnapshot", "non_existent_ks"),
{"expected_requests": [
expected_request("GET", "/storage_service/keyspaces", response=["ks1", "ks2"])]},
["error processing arguments: keyspace non_existent_ks does not exist"])
def test_clearnapshot_tag(nodetool):
nodetool("clearsnapshot", "-t", "snapshot_name", expected_requests=[
expected_request("DELETE", "/storage_service/snapshots", params={"tag": "snapshot_name"})
])
def test_clearnapshot_tag_and_keyspace(nodetool):
nodetool("clearsnapshot", "-t", "snapshot_name", "ks1", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.ANY,
response=["ks1", "ks2"]),
expected_request("DELETE", "/storage_service/snapshots", params={"kn": "ks1", "tag": "snapshot_name"})
])
def test_clearnapshot_tag_and_keyspaces(nodetool):
nodetool("clearsnapshot", "-t", "snapshot_name", "ks1", "ks2", expected_requests=[
expected_request("GET", "/storage_service/keyspaces", multiple=expected_request.ANY,
response=["ks1", "ks2"]),
expected_request("DELETE", "/storage_service/snapshots", params={"kn": "ks1,ks2", "tag": "snapshot_name"})
])
def test_listsnapshots(nodetool, request):
res = nodetool("listsnapshots", expected_requests=[
expected_request("GET", "/storage_service/snapshots", response=[
{"key": "1698236289867", "value": [{"ks": "ks1", "cf": "tbl1", "total": 45056, "live": 0},
{"ks": "ks1", "cf": "tbl2", "total": 40956, "live": 0}]},
{"key": "1698236070745", "value": [{"ks": "ks1", "cf": "tbl1", "total": 35056, "live": 0},
{"ks": "ks1", "cf": "tbl2", "total": 20956, "live": 0}]},
]),
expected_request("GET", "/storage_service/snapshots/size/true", response=945235),
])
cassandra_expected_output =\
"""Snapshot Details:
Snapshot name Keyspace name Column family name True size Size on disk
1698236289867 ks1 tbl1 0 bytes 44 KB
1698236289867 ks1 tbl2 0 bytes 40 KB
1698236070745 ks1 tbl1 0 bytes 34.23 KB
1698236070745 ks1 tbl2 0 bytes 20.46 KB
Total TrueDiskSpaceUsed: 923.08 KiB
"""
scylla_expected_output =\
"""Snapshot Details:
Snapshot name Keyspace name Column family name True size Size on disk
1698236289867 ks1 tbl1 0 B 44 KiB
1698236289867 ks1 tbl2 0 B 40 KiB
1698236070745 ks1 tbl1 0 B 34 KiB
1698236070745 ks1 tbl2 0 B 20 KiB
Total TrueDiskSpaceUsed: 923 KiB
"""
if request.config.getoption("nodetool") == "scylla":
assert res == scylla_expected_output
else:
assert res == cassandra_expected_output

View File

@@ -20,6 +20,7 @@
#include "log.hh"
#include "tools/utils.hh"
#include "utils/http.hh"
#include "utils/human_readable.hh"
#include "utils/rjson.hh"
#include "utils/UUID.hh"
@@ -58,8 +59,10 @@ class scylla_rest_client {
}
if (res.empty()) {
nlog.trace("Got empty response");
return rjson::null_value();
} else {
nlog.trace("Got response:\n{}", res);
return rjson::parse(res);
}
}
@@ -90,20 +93,76 @@ public:
}
};
std::vector<sstring> get_keyspaces(scylla_rest_client& client, std::optional<sstring> type = {}) {
std::unordered_map<sstring, sstring> params;
if (type) {
params["type"] = *type;
}
auto keyspaces_json = client.get("/storage_service/keyspaces", std::move(params));
std::vector<sstring> keyspaces;
for (const auto& keyspace_json : keyspaces_json.GetArray()) {
keyspaces.emplace_back(rjson::to_string_view(keyspace_json));
}
return keyspaces;
}
using operation_func = void(*)(scylla_rest_client&, const bpo::variables_map&);
std::map<operation, operation_func> get_operations_with_func();
void cleanup_operation(scylla_rest_client& client, const bpo::variables_map& vm) {
if (vm.count("cleanup_arg")) {
const auto all_keyspaces = get_keyspaces(client);
auto args = vm["cleanup_arg"].as<std::vector<sstring>>();
std::unordered_map<sstring, sstring> params;
const auto keyspace = args[0];
if (std::ranges::find(all_keyspaces, keyspace) == all_keyspaces.end()) {
throw std::invalid_argument(fmt::format("keyspace {} does not exist", keyspace));
}
if (args.size() > 1) {
params["cf"] = fmt::to_string(fmt::join(args.begin() + 1, args.end(), ","));
}
client.post(format("/storage_service/keyspace_cleanup/{}", keyspace), std::move(params));
} else {
for (const auto& keyspace : get_keyspaces(client, "non_local_strategy")) {
client.post(format("/storage_service/keyspace_cleanup/{}", keyspace));
}
}
}
void clearsnapshot_operation(scylla_rest_client& client, const bpo::variables_map& vm) {
std::unordered_map<sstring, sstring> params;
if (vm.count("keyspaces")) {
std::vector<sstring> keyspaces;
const auto all_keyspaces = get_keyspaces(client);
for (const auto& keyspace : vm["keyspaces"].as<std::vector<sstring>>()) {
if (std::ranges::find(all_keyspaces, keyspace) == all_keyspaces.end()) {
throw std::invalid_argument(fmt::format("keyspace {} does not exist", keyspace));
}
keyspaces.push_back(keyspace);
}
if (!keyspaces.empty()) {
params["kn"] = fmt::to_string(fmt::join(keyspaces.begin(), keyspaces.end(), ","));
}
}
if (vm.count("tag")) {
params["tag"] = vm["tag"].as<sstring>();
}
client.del("/storage_service/snapshots", std::move(params));
}
void compact_operation(scylla_rest_client& client, const bpo::variables_map& vm) {
if (vm.count("user-defined")) {
throw std::invalid_argument("--user-defined flag is unsupported");
}
auto keyspaces_json = client.get("/storage_service/keyspaces", {});
std::vector<sstring> all_keyspaces;
for (const auto& keyspace_json : keyspaces_json.GetArray()) {
all_keyspaces.emplace_back(rjson::to_string_view(keyspace_json));
}
const auto all_keyspaces = get_keyspaces(client);
if (vm.count("compaction_arg")) {
auto args = vm["compaction_arg"].as<std::vector<sstring>>();
@@ -280,6 +339,54 @@ void gettraceprobability_operation(scylla_rest_client& client, const bpo::variab
fmt::print(std::cout, "Current trace probability: {}\n", res.GetDouble());
}
void listsnapshots_operation(scylla_rest_client& client, const bpo::variables_map& vm) {
const auto snapshots = client.get("/storage_service/snapshots");
const auto true_size = client.get("/storage_service/snapshots/size/true").GetInt64();
std::array<std::string, 5> header_row{"Snapshot name", "Keyspace name", "Column family name", "True size", "Size on disk"};
std::array<size_t, 5> max_column_length{};
for (size_t c = 0; c < header_row.size(); ++c) {
max_column_length[c] = header_row[c].size();
}
auto format_hr_size = [] (const utils::human_readable_value hrv) {
if (!hrv.suffix || hrv.suffix == 'B') {
return fmt::format("{} B ", hrv.value);
}
return fmt::format("{} {}iB", hrv.value, hrv.suffix);
};
std::vector<std::array<std::string, 5>> rows;
for (const auto& snapshot_by_name : snapshots.GetArray()) {
const auto snapshot_name = std::string(rjson::to_string_view(snapshot_by_name.GetObject()["key"]));
for (const auto& snapshot : snapshot_by_name.GetObject()["value"].GetArray()) {
rows.push_back({
snapshot_name,
std::string(rjson::to_string_view(snapshot["ks"])),
std::string(rjson::to_string_view(snapshot["cf"])),
format_hr_size(utils::to_hr_size(snapshot["live"].GetInt64())),
format_hr_size(utils::to_hr_size(snapshot["total"].GetInt64()))});
for (size_t c = 0; c < rows.back().size(); ++c) {
max_column_length[c] = std::max(max_column_length[c], rows.back()[c].size());
}
}
}
const auto header_row_format = fmt::format("{{:<{}}} {{:<{}}} {{:<{}}} {{:<{}}} {{:<{}}}\n", max_column_length[0],
max_column_length[1], max_column_length[2], max_column_length[3], max_column_length[4]);
const auto regular_row_format = fmt::format("{{:<{}}} {{:<{}}} {{:<{}}} {{:>{}}} {{:>{}}}\n", max_column_length[0],
max_column_length[1], max_column_length[2], max_column_length[3], max_column_length[4]);
fmt::print(std::cout, "Snapshot Details:\n");
fmt::print(std::cout, fmt::runtime(header_row_format.c_str()), header_row[0], header_row[1], header_row[2], header_row[3], header_row[4]);
for (const auto& r : rows) {
fmt::print(std::cout, fmt::runtime(regular_row_format.c_str()), r[0], r[1], r[2], r[3], r[4]);
}
fmt::print(std::cout, "\nTotal TrueDiskSpaceUsed: {}\n\n", format_hr_size(utils::to_hr_size(true_size)));
}
void help_operation(const tool_app_template::config& cfg, const bpo::variables_map& vm) {
if (vm.count("command")) {
const auto command = vm["command"].as<sstring>();
@@ -416,6 +523,47 @@ const std::map<std::string_view, std::string_view> option_substitutions{
std::map<operation, operation_func> get_operations_with_func() {
const static std::map<operation, operation_func> operations_with_func {
{
{
"cleanup",
"Triggers removal of data that the node no longer owns",
R"(
You should run nodetool cleanup whenever you scale-out (expand) your cluster, and
new nodes are added to the same DC. The scale out process causes the token ring
to get re-distributed. As a result, some of the nodes will have replicas for
tokens that they are no longer responsible for (taking up disk space). This data
continues to consume diskspace until you run nodetool cleanup. The cleanup
operation deletes these replicas and frees up disk space.
Fore more information, see: https://opensource.docs.scylladb.com/stable/operating-scylla/nodetool-commands/cleanup.html
)",
{
typed_option<int64_t>("jobs,j", "The number of compaction jobs to be used for the cleanup (unused)"),
},
{
typed_option<std::vector<sstring>>("cleanup_arg", "[<keyspace> <tables>...]", -1),
}
},
cleanup_operation
},
{
{
"clearsnapshot",
"Remove snapshots",
R"(
By default all snapshots are removed for all keyspaces.
Fore more information, see: https://opensource.docs.scylladb.com/stable/operating-scylla/nodetool-commands/clearsnapshot.html
)",
{
typed_option<sstring>("tag,t", "The snapshot to remove"),
},
{
typed_option<std::vector<sstring>>("keyspaces", "[<keyspaces>...]", -1),
}
},
clearsnapshot_operation
},
{
{
"compact",
@@ -545,6 +693,20 @@ Fore more information, see: https://opensource.docs.scylladb.com/stable/operatin
},
[] (scylla_rest_client&, const bpo::variables_map&) {}
},
{
{
"listsnapshots",
"Lists all the snapshots along with the size on disk and true size",
R"(
Dropped tables (column family) will not be part of the listsnapshots.
Fore more information, see: https://opensource.docs.scylladb.com/stable/operating-scylla/nodetool-commands/listsnapshots.html
)",
{ },
{ },
},
listsnapshots_operation
},
{
{
"settraceprobability",