This is an automated email from the ASF dual-hosted git repository.
jihuayu 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 27eae0d19 feat(commands): implement CLIENT SETINFO subcommand (#3465)
27eae0d19 is described below
commit 27eae0d199b6a40b296d895073658354850087d6
Author: gongna-au <[email protected]>
AuthorDate: Tue Apr 28 17:47:13 2026 +0800
feat(commands): implement CLIENT SETINFO subcommand (#3465)
## Summary
Implement the `CLIENT SETINFO` subcommand (introduced in Redis 7.2) to
allow clients to set `lib-name` and `lib-ver` metadata on the
connection. Many modern Redis SDKs (go-redis v9, jedis 5, redis-py 5)
automatically send `CLIENT SETINFO` on connect — without this support,
those clients log errors on every new connection.
## Changes
- **`src/server/redis_connection.h`**: Add `lib_name_` / `lib_ver_`
fields and getters/setters to the `Connection` class.
- **`src/server/redis_connection.cc`**: Include `lib-name=` and
`lib-ver=` in `CLIENT LIST` / `CLIENT INFO` output, matching the Redis
output format.
- **`src/commands/cmd_server.cc`**: Add `SETINFO` subcommand parsing and
execution in `CommandClient`. Supports `LIB-NAME` and `LIB-VER`
attributes, validates charset (same rules as `SETNAME`), and rejects
unrecognized attributes.
- **`tests/gocase/unit/client/client_setinfo_test.go`**: Integration
tests covering set/get, `CLIENT INFO`/`LIST` output, case-insensitivity,
empty value (clear), unknown attribute rejection, and invalid
characters.
## Behavior
```
> CLIENT SETINFO LIB-NAME go-redis
OK
> CLIENT SETINFO LIB-VER 9.7.0
OK
> CLIENT INFO
id=3 addr=127.0.0.1:6379 fd=8 name= age=5 idle=0 flags=N namespace= qbuf=0
obuf=0 cmd=client lib-name=go-redis lib-ver=9.7.0
```
- `CLIENT SETINFO LIB-NAME <value>` — sets the client library name
- `CLIENT SETINFO LIB-VER <value>` — sets the client library version
- Empty string clears the field
- Attribute names are case-insensitive
- Values follow the same charset restriction as `CLIENT SETNAME` (no
spaces/control chars)
- Unrecognized attributes return: `ERR Unrecognized option '<attr>'`
---------
Co-authored-by: gongna-au <[email protected]>
Co-authored-by: 纪华裕 <[email protected]>
---
src/commands/cmd_server.cc | 40 ++++++++-
src/server/redis_connection.cc | 7 +-
src/server/redis_connection.h | 9 ++
tests/gocase/unit/client/client_setinfo_test.go | 105 ++++++++++++++++++++++++
tests/gocase/unit/monitor/monitor_test.go | 5 +-
5 files changed, 159 insertions(+), 7 deletions(-)
diff --git a/src/commands/cmd_server.cc b/src/commands/cmd_server.cc
index 35a85c442..a624756db 100644
--- a/src/commands/cmd_server.cc
+++ b/src/commands/cmd_server.cc
@@ -504,6 +504,31 @@ class CommandClient : public Commander {
return Status::OK();
}
+ if (subcommand_ == "setinfo") {
+ if (args.size() != 4) {
+ return {Status::RedisParseErr, errInvalidSyntax};
+ }
+
+ auto attr = util::ToLower(args[2]);
+
+ for (auto ch : args[3]) {
+ if (ch < '!' || ch > '~') {
+ return {Status::RedisInvalidCmd,
+ "lib-name and lib-ver cannot contain spaces, newlines or
special characters"};
+ }
+ }
+
+ if (attr == "lib-name") {
+ setinfo_lib_name_ = args[3];
+ } else if (attr == "lib-ver") {
+ setinfo_lib_ver_ = args[3];
+ } else {
+ return {Status::RedisInvalidCmd, "Unrecognized option '" + args[2] +
"'"};
+ }
+
+ return Status::OK();
+ }
+
if (subcommand_ == "reply") {
if (args.size() != 3) {
return {Status::RedisParseErr, errInvalidSyntax};
@@ -602,7 +627,7 @@ class CommandClient : public Commander {
return Status::OK();
}
return {Status::RedisInvalidCmd,
- "Syntax error, try CLIENT LIST|INFO|KILL
ip:port|GETNAME|SETNAME|REPLY|"
+ "Syntax error, try CLIENT LIST|INFO|KILL
ip:port|GETNAME|SETNAME|SETINFO|REPLY|"
"PAUSE|UNPAUSE"};
}
@@ -659,10 +684,19 @@ class CommandClient : public Commander {
srv->UnpauseConns();
*output = redis::RESP_OK;
return Status::OK();
+ } else if (subcommand_ == "setinfo") {
+ if (setinfo_lib_name_) {
+ conn->SetLibName(*setinfo_lib_name_);
+ }
+ if (setinfo_lib_ver_) {
+ conn->SetLibVer(*setinfo_lib_ver_);
+ }
+ *output = redis::RESP_OK;
+ return Status::OK();
}
return {Status::RedisInvalidCmd,
- "Syntax error, try CLIENT LIST|INFO|KILL
ip:port|GETNAME|SETNAME|REPLY|"
+ "Syntax error, try CLIENT LIST|INFO|KILL
ip:port|GETNAME|SETNAME|SETINFO|REPLY|"
"PAUSE|UNPAUSE"};
}
@@ -670,6 +704,8 @@ class CommandClient : public Commander {
std::string addr_;
std::string conn_name_;
std::string subcommand_;
+ std::optional<std::string> setinfo_lib_name_;
+ std::optional<std::string> setinfo_lib_ver_;
redis::Connection::ReplyMode reply_mode_ = redis::Connection::ReplyMode::ON;
bool skipme_ = false;
int64_t kill_type_ = 0;
diff --git a/src/server/redis_connection.cc b/src/server/redis_connection.cc
index f9db92b0c..aa301b32f 100644
--- a/src/server/redis_connection.cc
+++ b/src/server/redis_connection.cc
@@ -87,9 +87,10 @@ std::string Connection::ToString() {
db_or_ns_value = ns_;
}
- return fmt::format("id={} addr={} fd={} name={} age={} idle={} flags={}
{}={} qbuf={} obuf={} cmd={}\n", id_, addr_,
- bufferevent_getfd(bev_), name_, GetAge(), GetIdleTime(),
GetFlags(), db_or_ns_field,
- db_or_ns_value, evbuffer_get_length(Input()),
evbuffer_get_length(Output()), last_cmd_);
+ return fmt::format(
+ "id={} addr={} fd={} name={} age={} idle={} flags={} {}={} qbuf={}
obuf={} cmd={} lib-name={} lib-ver={}\n", id_,
+ addr_, bufferevent_getfd(bev_), name_, GetAge(), GetIdleTime(),
GetFlags(), db_or_ns_field, db_or_ns_value,
+ evbuffer_get_length(Input()), evbuffer_get_length(Output()), last_cmd_,
set_info_.lib_name, set_info_.lib_ver);
}
void Connection::Close() {
diff --git a/src/server/redis_connection.h b/src/server/redis_connection.h
index 4eba076e6..909ce8759 100644
--- a/src/server/redis_connection.h
+++ b/src/server/redis_connection.h
@@ -137,6 +137,14 @@ class Connection : public EvbufCallbackBase<Connection> {
void SetID(uint64_t id) { id_ = id; }
std::string GetName() const { return name_; }
void SetName(std::string name) { name_ = std::move(name); }
+ struct SetInfo {
+ std::string lib_name;
+ std::string lib_ver;
+ };
+
+ const SetInfo &GetSetInfo() const { return set_info_; }
+ void SetLibName(std::string lib_name) { set_info_.lib_name =
std::move(lib_name); }
+ void SetLibVer(std::string lib_ver) { set_info_.lib_ver =
std::move(lib_ver); }
std::string GetAddr() const { return addr_; }
void SetAddr(std::string ip, uint32_t port);
void SetLastCmd(std::string cmd) { last_cmd_ = std::move(cmd); }
@@ -203,6 +211,7 @@ class Connection : public EvbufCallbackBase<Connection> {
std::atomic<int> flags_ = 0;
std::string ns_;
std::string name_;
+ SetInfo set_info_;
std::string ip_;
std::string announce_ip_;
uint32_t port_ = 0;
diff --git a/tests/gocase/unit/client/client_setinfo_test.go
b/tests/gocase/unit/client/client_setinfo_test.go
new file mode 100644
index 000000000..356626731
--- /dev/null
+++ b/tests/gocase/unit/client/client_setinfo_test.go
@@ -0,0 +1,105 @@
+/*
+ * 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 client
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/apache/kvrocks/tests/gocase/util"
+ "github.com/stretchr/testify/require"
+)
+
+func TestClientSetInfo(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()) }()
+
+ t.Run("CLIENT SETINFO LIB-NAME sets library name", func(t *testing.T) {
+ res, err := rdb.Do(ctx, "CLIENT", "SETINFO", "LIB-NAME",
"my-lib").Result()
+ require.NoError(t, err)
+ require.Equal(t, "OK", res)
+ })
+
+ t.Run("CLIENT SETINFO LIB-VER sets library version", func(t *testing.T)
{
+ res, err := rdb.Do(ctx, "CLIENT", "SETINFO", "LIB-VER",
"1.2.3").Result()
+ require.NoError(t, err)
+ require.Equal(t, "OK", res)
+ })
+
+ t.Run("CLIENT INFO shows lib-name and lib-ver", func(t *testing.T) {
+ info, err := rdb.Do(ctx, "CLIENT", "INFO").Result()
+ require.NoError(t, err)
+ infoStr, ok := info.(string)
+ require.True(t, ok)
+ require.Contains(t, infoStr, "lib-name=my-lib")
+ require.Contains(t, infoStr, "lib-ver=1.2.3")
+ })
+
+ t.Run("CLIENT LIST shows lib-name and lib-ver", func(t *testing.T) {
+ list, err := rdb.Do(ctx, "CLIENT", "LIST").Result()
+ require.NoError(t, err)
+ listStr, ok := list.(string)
+ require.True(t, ok)
+ require.Contains(t, listStr, "lib-name=my-lib")
+ require.Contains(t, listStr, "lib-ver=1.2.3")
+ })
+
+ t.Run("CLIENT SETINFO is case-insensitive for attribute name", func(t
*testing.T) {
+ res, err := rdb.Do(ctx, "CLIENT", "SETINFO", "lib-name",
"lower-lib").Result()
+ require.NoError(t, err)
+ require.Equal(t, "OK", res)
+
+ info, err := rdb.Do(ctx, "CLIENT", "INFO").Result()
+ require.NoError(t, err)
+ require.Contains(t, info, "lib-name=lower-lib")
+ })
+
+ t.Run("CLIENT SETINFO with empty value clears the field", func(t
*testing.T) {
+ res, err := rdb.Do(ctx, "CLIENT", "SETINFO", "LIB-NAME",
"").Result()
+ require.NoError(t, err)
+ require.Equal(t, "OK", res)
+
+ info, err := rdb.Do(ctx, "CLIENT", "INFO").Result()
+ require.NoError(t, err)
+ require.Contains(t, info.(string), "lib-name=")
+ require.False(t, strings.Contains(info.(string),
"lib-name=lower-lib"))
+ })
+
+ t.Run("CLIENT SETINFO rejects unknown attribute", func(t *testing.T) {
+ err := rdb.Do(ctx, "CLIENT", "SETINFO", "UNKNOWN",
"value").Err()
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "Unrecognized option")
+ })
+
+ t.Run("CLIENT SETINFO rejects value with spaces", func(t *testing.T) {
+ err := rdb.Do(ctx, "CLIENT", "SETINFO", "LIB-NAME", "my
lib").Err()
+ require.Error(t, err)
+ })
+
+ t.Run("CLIENT SETINFO wrong number of arguments", func(t *testing.T) {
+ err := rdb.Do(ctx, "CLIENT", "SETINFO", "LIB-NAME").Err()
+ require.Error(t, err)
+ })
+}
diff --git a/tests/gocase/unit/monitor/monitor_test.go
b/tests/gocase/unit/monitor/monitor_test.go
index a76a84a52..096f2e2ad 100644
--- a/tests/gocase/unit/monitor/monitor_test.go
+++ b/tests/gocase/unit/monitor/monitor_test.go
@@ -34,7 +34,7 @@ func TestMonitor(t *testing.T) {
defer srv.Close()
ctx := context.Background()
- rdb := srv.NewClient()
+ rdb := srv.NewClientWithOption(&redis.Options{DisableIdentity: true})
defer func() { require.NoError(t, rdb.Close()) }()
t.Run("MONITOR can log executed commands", func(t *testing.T) {
@@ -85,7 +85,8 @@ func TestMonitorRedactPassword(t *testing.T) {
c.MustRead(t, "+OK")
rdb := srv.NewClientWithOption(&redis.Options{
- Password: "testpass",
+ Password: "testpass",
+ DisableIdentity: true,
})
defer func() { require.NoError(t, rdb.Close()) }()