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> &sections) {
+std::string Server::GetInfo(const std::string &ns, const 
std::vector<std::string> &sections, 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> 
&sections);
+  enum class InfoFormat { Text, Json };
+  std::string GetInfo(const std::string &ns, const std::vector<std::string> 
&sections,
+                      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")
+       })
+}

Reply via email to