This is an automated email from the ASF dual-hosted git repository.
PragmaTwice pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks.git
The following commit(s) were added to refs/heads/unstable by this push:
new e887721cb feat(info): support FORMAT (TXT | JSON) for the INFO command
(#3549)
e887721cb is described below
commit e887721cbcc11406769445ea7d66ad0c71a5502e
Author: nhancdt2602 <[email protected]>
AuthorDate: Wed Jul 8 20:41:46 2026 +0700
feat(info): support FORMAT (TXT | JSON) for the INFO command (#3549)
## Summary
Add an optional `FORMAT (TXT | JSON)` option to the `INFO` command:
```
INFO [<section> ...] [FORMAT (TXT | JSON)]
```
- `FORMAT TXT` (the default) keeps the existing Redis-compatible text
output.
- `FORMAT JSON` emits the same data as a JSON object keyed by section.
```
127.0.0.1:6666> INFO server FORMAT JSON
{"Server":{"version":"unstable","tcp_port":6666,"process_id":123,...}}
```
Closes #2302.
## Changes
- `CommandInfo`: parse the optional `FORMAT` keyword, separating it from
section names.
- `InfoEntry`: store each value as a typed `std::variant`, with
`InfoEntry::ToString()` for the text form; `GetInfo` serializes to text
or JSON.
- Add `TestInfoFormat` integration tests.
## Implementation Note
Each `INFO` value carries its original type in the `InfoEntry` variant
`std::variant<std::string, int64_t, double, bool>`, captured in the
constructors, so the collectors stay unchanged and each format renders
the value at serialization time:
- **Text** (`InfoEntry::ToString`): booleans as `0/1`, numbers via
`std::to_string`, strings verbatim — identical to the previous output.
- **JSON**: native types — numbers unquoted, booleans as `true/false`,
strings quoted.
The `FORMAT` keyword is case-insensitive; an unknown or missing value
returns `syntax error`.
---------
Signed-off-by: nhancdt <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Twice <[email protected]>
Co-authored-by: Twice <[email protected]>
---
src/commands/cmd_server.cc | 31 ++++++++--
src/server/server.cc | 29 ++++++---
src/server/server.h | 40 ++++++++++--
tests/gocase/unit/info/info_test.go | 118 ++++++++++++++++++++++++++++++++++++
4 files changed, 199 insertions(+), 19 deletions(-)
diff --git a/src/commands/cmd_server.cc b/src/commands/cmd_server.cc
index 94bb20386..be5ff20a2 100644
--- a/src/commands/cmd_server.cc
+++ b/src/commands/cmd_server.cc
@@ -271,15 +271,36 @@ class CommandConfig : public Commander {
class CommandInfo : public Commander {
public:
- Status Execute([[maybe_unused]] engine::Context &ctx, Server *srv,
Connection *conn, std::string *output) override {
- std::vector<std::string> sections;
- for (size_t i = 1; i < args_.size(); ++i) {
- sections.push_back(args_[i]);
+ Status Parse(const std::vector<std::string> &args) override {
+ for (size_t i = 1; i < args.size(); ++i) {
+ if (util::EqualICase(args[i], "format")) {
+ if (i + 1 >= args.size()) {
+ return {Status::RedisParseErr, errInvalidSyntax};
+ }
+ const auto &fmt = args[++i];
+ if (util::EqualICase(fmt, "json")) {
+ format_ = Server::InfoFormat::Json;
+ } else if (util::EqualICase(fmt, "txt")) {
+ format_ = Server::InfoFormat::Text;
+ } else {
+ return {Status::RedisParseErr, errInvalidSyntax};
+ }
+ } else {
+ sections_.push_back(args[i]);
+ }
}
- auto info = srv->GetInfo(conn->GetNamespace(), sections);
+ return Status::OK();
+ }
+
+ Status Execute([[maybe_unused]] engine::Context &ctx, Server *srv,
Connection *conn, std::string *output) override {
+ auto info = srv->GetInfo(conn->GetNamespace(), sections_, format_);
*output = conn->VerbatimString("txt", info);
return Status::OK();
}
+
+ private:
+ std::vector<std::string> sections_;
+ Server::InfoFormat format_ = Server::InfoFormat::Text;
};
class CommandDisk : public Commander {
diff --git a/src/server/server.cc b/src/server/server.cc
index 4a63fc7b7..6ebc2c3c4 100644
--- a/src/server/server.cc
+++ b/src/server/server.cc
@@ -1537,7 +1537,7 @@ Server::InfoEntries Server::GetKeyspaceInfo(const
std::string &ns) {
// DB is closed and the pointer is invalid. Server may crash if we access DB
during loading.
// If you add new fields which access DB into INFO command output, make sure
// this section can't be shown when loading(i.e. !is_loading_).
-std::string Server::GetInfo(const std::string &ns, const
std::vector<std::string> §ions) {
+std::string Server::GetInfo(const std::string &ns, const
std::vector<std::string> §ions, InfoFormat format) {
std::vector<std::pair<std::string, std::function<InfoEntries(Server *)>>>
info_funcs = {
{"Server", &Server::GetServerInfo}, {"Clients",
&Server::GetClientsInfo},
{"Memory", &Server::GetMemoryInfo}, {"Persistence",
&Server::GetPersistenceInfo},
@@ -1548,25 +1548,38 @@ std::string Server::GetInfo(const std::string &ns,
const std::vector<std::string
};
std::string info_str;
+ jsoncons::json json_obj;
bool all = sections.empty() || util::FindICase(sections.begin(),
sections.end(), "all") != sections.end();
bool first = true;
for (const auto &[sec, fn] : info_funcs) {
if (all || util::FindICase(sections.begin(), sections.end(), sec) !=
sections.end()) {
- if (first)
- first = false;
- else
- info_str.append("\r\n");
+ auto entries = fn(this);
+ if (format == InfoFormat::Json) {
+ jsoncons::json sec_obj;
+ for (const auto &entry : entries) {
+ std::visit([&](const auto &v) { sec_obj[entry.name] = v; },
entry.val);
+ }
+ json_obj[sec] = std::move(sec_obj);
+ } else {
+ if (first)
+ first = false;
+ else
+ info_str.append("\r\n");
- info_str.append("# " + sec + "\r\n");
+ info_str.append("# " + sec + "\r\n");
- for (const auto &entry : fn(this)) {
- info_str.append(fmt::format("{}:{}\r\n", entry.name, entry.val));
+ for (const auto &entry : entries) {
+ info_str.append(fmt::format("{}:{}\r\n", entry.name,
entry.ValueToString()));
+ }
}
}
}
+ if (format == InfoFormat::Json) {
+ return json_obj.to_string();
+ }
return info_str;
}
diff --git a/src/server/server.h b/src/server/server.h
index c0859dc91..4214cecc5 100644
--- a/src/server/server.h
+++ b/src/server/server.h
@@ -38,6 +38,7 @@
#include <type_traits>
#include <unordered_map>
#include <utility>
+#include <variant>
#include <vector>
#include "cluster/cluster.h"
@@ -263,15 +264,40 @@ class Server {
int64_t GetLastBgsaveTime();
std::string GetRoleInfo();
+ // An INFO entry holds its value with its original type in a variant, so
each output format can
+ // render it appropriately: the text format (ToString) emits the
Redis-compatible representation
+ // (e.g. a bool as 0/1, numbers via std::to_string) while FORMAT JSON emits
the native JSON type
+ // (a bool as true/false, numbers unquoted). The type is captured here at
construction.
struct InfoEntry {
+ using Value = std::variant<std::string, int64_t, double, bool>;
std::string name;
- std::string val;
+ Value val;
InfoEntry(std::string name, std::string val) : name(std::move(name)),
val(std::move(val)) {}
- InfoEntry(std::string name, std::string_view val) : name(std::move(name)),
val(val.begin(), val.end()) {}
- InfoEntry(std::string name, const char *val) : name(std::move(name)),
val(val) {}
- template <typename T, std::enable_if_t<std::is_integral_v<T> ||
std::is_floating_point_v<T>, int> = 0>
- InfoEntry(std::string name, T v) : name(std::move(name)),
val(std::to_string(v)) {}
+ InfoEntry(std::string name, std::string_view val) : name(std::move(name)),
val(std::string(val)) {}
+ InfoEntry(std::string name, const char *val) : name(std::move(name)),
val(std::string(val)) {}
+ InfoEntry(std::string name, bool v) : name(std::move(name)), val(v) {}
+ // Floating-point values (incl. float, which widens to double) are stored
as double.
+ InfoEntry(std::string name, double v) : name(std::move(name)), val(v) {}
+ // Integers (bool handled above) are stored as int64_t.
+ template <typename T, std::enable_if_t<std::is_integral_v<T> &&
!std::is_same_v<T, bool>, int> = 0>
+ InfoEntry(std::string name, T v) : name(std::move(name)),
val(static_cast<int64_t>(v)) {}
+
+ // Redis-compatible text form: strings verbatim, booleans as 0/1, numbers
via std::to_string.
+ std::string ValueToString() const {
+ return std::visit(
+ [](const auto &v) -> std::string {
+ using T = std::decay_t<decltype(v)>;
+ if constexpr (std::is_same_v<T, std::string>) {
+ return v;
+ } else if constexpr (std::is_same_v<T, bool>) {
+ return v ? "1" : "0";
+ } else {
+ return std::to_string(v);
+ }
+ },
+ val);
+ }
};
using InfoEntries = std::vector<InfoEntry>;
@@ -287,7 +313,9 @@ class Server {
InfoEntries GetCpuInfo();
InfoEntries GetKeyspaceInfo(const std::string &ns);
- std::string GetInfo(const std::string &ns, const std::vector<std::string>
§ions);
+ enum class InfoFormat { Text, Json };
+ std::string GetInfo(const std::string &ns, const std::vector<std::string>
§ions,
+ InfoFormat format = InfoFormat::Text);
std::string GetRocksDBStatsJson() const;
ReplState GetReplicationState();
diff --git a/tests/gocase/unit/info/info_test.go
b/tests/gocase/unit/info/info_test.go
index a853dac8d..09e593e2d 100644
--- a/tests/gocase/unit/info/info_test.go
+++ b/tests/gocase/unit/info/info_test.go
@@ -21,6 +21,7 @@ package info
import (
"context"
+ "encoding/json"
"fmt"
"strconv"
"strings"
@@ -187,3 +188,120 @@ func TestKeyspaceHitMiss(t *testing.T) {
require.Equal(t, "2", util.FindInfoEntry(rdb0, "keyspace_hits",
"stats"))
require.Equal(t, "3", util.FindInfoEntry(rdb0, "keyspace_misses",
"stats"))
}
+
+func TestInfoFormat(t *testing.T) {
+ srv := util.StartServer(t, map[string]string{})
+ defer srv.Close()
+
+ ctx := context.Background()
+ rdb := srv.NewClient()
+ defer func() { require.NoError(t, rdb.Close()) }()
+
+ // Values are emitted with their original JSON type: numbers are
unquoted, strings are quoted.
+ // encoding/json decodes JSON numbers into float64 and JSON strings
into string when the target
+ // is `any`, so we assert on the concrete Go types to lock in the
typed-output contract.
+ t.Run("single section as JSON with typed values", func(t *testing.T) {
+ out, err := rdb.Do(ctx, "INFO", "server", "FORMAT",
"JSON").Text()
+ require.NoError(t, err)
+
+ info := map[string]map[string]any{}
+ require.NoError(t, json.Unmarshal([]byte(out), &info))
+ require.Contains(t, info, "Server")
+ // numeric field -> JSON number (Go float64)
+ require.IsType(t, float64(0), info["Server"]["tcp_port"])
+ require.Greater(t, info["Server"]["tcp_port"].(float64),
float64(0))
+ // string field -> JSON string
+ require.Equal(t, "unstable", info["Server"]["kvrocks_version"])
+ // only the requested section should be present
+ require.Len(t, info, 1)
+ })
+
+ t.Run("boolean fields are JSON booleans", func(t *testing.T) {
+ out, err := rdb.Do(ctx, "INFO", "persistence", "FORMAT",
"JSON").Text()
+ require.NoError(t, err)
+
+ info := map[string]map[string]any{}
+ require.NoError(t, json.Unmarshal([]byte(out), &info))
+ // `loading` is a C++ bool: JSON preserves the native type
(true/false), while the text format
+ // renders it as 0/1 (asserted by the text-format test below
and the pre-existing cluster test).
+ require.IsType(t, false, info["Persistence"]["loading"])
+ require.Equal(t, false, info["Persistence"]["loading"])
+ })
+
+ t.Run("all sections as JSON", func(t *testing.T) {
+ out, err := rdb.Do(ctx, "INFO", "FORMAT", "JSON").Text()
+ require.NoError(t, err)
+
+ info := map[string]map[string]any{}
+ require.NoError(t, json.Unmarshal([]byte(out), &info))
+ for _, sec := range []string{"Server", "Clients", "Memory",
"Stats"} {
+ require.Contains(t, info, sec)
+ }
+ })
+
+ t.Run("multiple sections as JSON", func(t *testing.T) {
+ out, err := rdb.Do(ctx, "INFO", "server", "clients", "FORMAT",
"JSON").Text()
+ require.NoError(t, err)
+
+ info := map[string]map[string]any{}
+ require.NoError(t, json.Unmarshal([]byte(out), &info))
+ require.Contains(t, info, "Server")
+ require.Contains(t, info, "Clients")
+ require.Len(t, info, 2)
+ })
+
+ t.Run("text format matches pre-change expected output", func(t
*testing.T) {
+ // Expected output captured from the INFO command BEFORE the
typed-value (FORMAT) change, to
+ // guard that the text serialization stays byte-for-byte
identical to the original. The
+ // Persistence section is used because its fields are
deterministic right after startup;
+ // last_bgsave_time holds the (volatile) server start
timestamp, so it is matched as a number.
+ // \A and \z anchor the whole response, so this asserts an
exact match of the entire section.
+ expected := `\A# Persistence\r\n` +
+ `loading:0\r\n` +
+ `bgsave_in_progress:0\r\n` +
+ `last_bgsave_time:[0-9]+\r\n` +
+ `last_bgsave_status:ok\r\n` +
+ `last_bgsave_time_sec:-1\r\n\z`
+
+ // Both the default (no FORMAT) command and explicit FORMAT TXT
must reproduce the original
+ // text output, proving the typed-value refactor did not change
the text format.
+ def, err := rdb.Do(ctx, "INFO", "persistence").Text()
+ require.NoError(t, err)
+ require.Regexp(t, expected, def)
+
+ txt, err := rdb.Do(ctx, "INFO", "persistence", "FORMAT",
"TXT").Text()
+ require.NoError(t, err)
+ require.Regexp(t, expected, txt)
+ })
+
+ t.Run("cpu values are JSON numbers", func(t *testing.T) {
+ // used_cpu_* are floating-point fields; JSON must emit them as
numbers (not quoted strings),
+ // while the text format keeps the decimal representation.
+ js, err := rdb.Do(ctx, "INFO", "cpu", "FORMAT", "JSON").Text()
+ require.NoError(t, err)
+ info := map[string]map[string]any{}
+ require.NoError(t, json.Unmarshal([]byte(js), &info))
+ require.IsType(t, float64(0), info["CPU"]["used_cpu_sys"])
+ require.IsType(t, float64(0), info["CPU"]["used_cpu_user"])
+
+ txt, err := rdb.Do(ctx, "INFO", "cpu", "FORMAT", "TXT").Text()
+ require.NoError(t, err)
+ require.Regexp(t, `\nused_cpu_sys:[0-9]+\.[0-9]+\r`, txt)
+ })
+
+ t.Run("format keyword is case-insensitive", func(t *testing.T) {
+ out, err := rdb.Do(ctx, "info", "server", "format",
"json").Text()
+ require.NoError(t, err)
+ info := map[string]map[string]any{}
+ require.NoError(t, json.Unmarshal([]byte(out), &info))
+ require.Contains(t, info, "Server")
+ })
+
+ t.Run("invalid format value returns syntax error", func(t *testing.T) {
+ require.ErrorContains(t, rdb.Do(ctx, "INFO", "server",
"FORMAT", "YAML").Err(), "syntax error")
+ })
+
+ t.Run("FORMAT without a value returns syntax error", func(t *testing.T)
{
+ require.ErrorContains(t, rdb.Do(ctx, "INFO", "server",
"FORMAT").Err(), "syntax error")
+ })
+}