This is an automated email from the ASF dual-hosted git repository.

twice 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 dca713489 feat(string): Add to Support MSETEX Command (#3318)
dca713489 is described below

commit dca7134890a558f6fcd2a17dea176965c0607ad8
Author: Byeonggyu Park <[email protected]>
AuthorDate: Thu Jan 1 17:54:45 2026 +0900

    feat(string): Add to Support MSETEX Command (#3318)
    
    ## Issue
    closes #3307
    
    ## Description
    In redis, it supports MSETEX which supports MSET with TTL options from
    v8.4.0.
    Syntax of `MSETEX` is below.
    ```
    MSETEX numkeys key value [key value ...] [NX | XX] [EX seconds |
      PX milliseconds | EXAT unix-time-seconds |
      PXAT unix-time-milliseconds | KEEPTTL]
    ```
    This change adds support for the `MSETEX` command in kvrocks, making it
    compatible with Redis.
    
    - Reference: https://redis.io/docs/latest/commands/msetex/
    
    ---------
    
    Co-authored-by: Twice <[email protected]>
---
 src/commands/cmd_string.cc                         | 69 ++++++++++++++++-
 src/types/redis_string.cc                          | 73 ++++++++++++------
 src/types/redis_string.h                           | 13 +++-
 tests/cppunit/iterator_test.cc                     |  2 +-
 tests/cppunit/types/string_test.cc                 | 71 ++++++++++++++++-
 tests/gocase/go.mod                                |  2 +-
 tests/gocase/go.sum                                |  6 +-
 .../integration/slotmigrate/slotmigrate_test.go    |  2 +-
 tests/gocase/unit/type/strings/strings_test.go     | 88 ++++++++++++++++++++++
 9 files changed, 288 insertions(+), 38 deletions(-)

diff --git a/src/commands/cmd_string.cc b/src/commands/cmd_string.cc
index 25ac408ce..9e3bf8aef 100644
--- a/src/commands/cmd_string.cc
+++ b/src/commands/cmd_string.cc
@@ -421,7 +421,7 @@ class CommandMSet : public Commander {
       kvs.emplace_back(StringPair{args_[i], args_[i + 1]});
     }
 
-    auto s = string_db.MSet(ctx, kvs, 0);
+    auto s = string_db.MSet(ctx, kvs);
     if (!s.ok()) {
       return {Status::RedisExecErr, s.ToString()};
     }
@@ -447,6 +447,70 @@ class CommandSetNX : public Commander {
   }
 };
 
+class CommandMSetEX : public Commander {
+ public:
+  Status Parse(const std::vector<std::string> &args) override {
+    auto parsed_num_keys = ParseInt<int>(args[1], 10);
+    if (!parsed_num_keys) {
+      return {Status::RedisParseErr, errValueNotInteger};
+    }
+
+    if (*parsed_num_keys <= 0) return {Status::RedisParseErr, 
errValueMustBePositive};
+    num_keys_ = *parsed_num_keys;
+    min_args_ = 2 + 2 * num_keys_;
+    if (args.size() < min_args_) {
+      return {Status::RedisParseErr, errWrongNumOfArguments};
+    }
+
+    CommandParser parser(args, min_args_);
+    std::string_view ttl_flag, set_flag;
+    while (parser.Good()) {
+      if (auto v = GET_OR_RET(ParseExpireFlags(parser, ttl_flag))) {
+        expire_ = *v;
+      } else if (parser.EatEqICaseFlag("KEEPTTL", ttl_flag)) {
+        keep_ttl_ = true;
+      } else if (parser.EatEqICaseFlag("NX", set_flag)) {
+        set_flag_ = StringSetType::NX;
+      } else if (parser.EatEqICaseFlag("XX", set_flag)) {
+        set_flag_ = StringSetType::XX;
+      } else {
+        return parser.InvalidSyntax();
+      }
+    }
+    return Status::OK();
+  }
+
+  Status Execute(engine::Context &ctx, Server *srv, Connection *conn, 
std::string *output) override {
+    bool ret = false;
+
+    std::vector<StringPair> kvs;
+    for (size_t i = 2; i < min_args_; i += 2) {
+      kvs.emplace_back(StringPair{args_[i], args_[i + 1]});
+    }
+
+    redis::String string_db(srv->storage, conn->GetNamespace());
+    auto s = string_db.MSetEX(ctx, kvs, {expire_, set_flag_, keep_ttl_}, &ret);
+    if (!s.ok()) {
+      return {Status::RedisExecErr, s.ToString()};
+    }
+
+    *output = redis::Integer(ret ? 1 : 0);
+    return Status::OK();
+  }
+
+  static CommandKeyRange Range(const std::vector<std::string> &args) {
+    int num_keys = *ParseInt<int>(args[1], 10);
+    return {2, 2 + 2 * num_keys, 2};
+  }
+
+ private:
+  size_t num_keys_ = 0;
+  size_t min_args_ = 4;
+  StringSetType set_flag_ = StringSetType::NONE;
+  uint64_t expire_ = 0;
+  bool keep_ttl_ = false;
+};
+
 class CommandMSetNX : public Commander {
  public:
   Status Parse(const std::vector<std::string> &args) override {
@@ -465,7 +529,7 @@ class CommandMSetNX : public Commander {
       kvs.emplace_back(StringPair{args_[i], args_[i + 1]});
     }
 
-    auto s = string_db.MSetNX(ctx, kvs, 0, &ret);
+    auto s = string_db.MSetNX(ctx, kvs, &ret);
     if (!s.ok()) {
       return {Status::RedisExecErr, s.ToString()};
     }
@@ -753,6 +817,7 @@ REDIS_REGISTER_COMMANDS(
     MakeCmdAttr<CommandAppend>("append", 3, "write", 1, 1, 1), 
MakeCmdAttr<CommandSet>("set", -3, "write", 1, 1, 1),
     MakeCmdAttr<CommandSetEX>("setex", 4, "write", 1, 1, 1), 
MakeCmdAttr<CommandPSetEX>("psetex", 4, "write", 1, 1, 1),
     MakeCmdAttr<CommandSetNX>("setnx", 3, "write", 1, 1, 1),
+    MakeCmdAttr<CommandMSetEX>("msetex", -4, "write", CommandMSetEX::Range),
     MakeCmdAttr<CommandMSetNX>("msetnx", -3, "write", 1, -1, 2),
     MakeCmdAttr<CommandMSet>("mset", -3, "write", 1, -1, 2), 
MakeCmdAttr<CommandIncrBy>("incrby", 3, "write", 1, 1, 1),
     MakeCmdAttr<CommandIncrByFloat>("incrbyfloat", 3, "write", 1, 1, 1),
diff --git a/src/types/redis_string.cc b/src/types/redis_string.cc
index 00cb099ab..273e51ec4 100644
--- a/src/types/redis_string.cc
+++ b/src/types/redis_string.cc
@@ -199,7 +199,7 @@ rocksdb::Status String::GetDel(engine::Context &ctx, const 
std::string &user_key
 
 rocksdb::Status String::Set(engine::Context &ctx, const std::string &user_key, 
const std::string &value) {
   std::vector<StringPair> pairs{StringPair{user_key, value}};
-  return MSet(ctx, pairs, /*expire=*/0);
+  return MSet(ctx, pairs);
 }
 
 rocksdb::Status String::Set(engine::Context &ctx, const std::string &user_key, 
const std::string &value,
@@ -394,46 +394,71 @@ rocksdb::Status String::IncrByFloat(engine::Context &ctx, 
const std::string &use
   return updateRawValue(ctx, ns_key, raw_value);
 }
 
-rocksdb::Status String::MSet(engine::Context &ctx, const 
std::vector<StringPair> &pairs, uint64_t expire_ms) {
+rocksdb::Status String::MSet(engine::Context &ctx, const 
std::vector<StringPair> &pairs, StringMSetArgs args,
+                             bool *flag) {
+  if (flag != nullptr) {
+    *flag = false;
+  }
+
+  if (args.type != StringSetType::NONE) {
+    int exists = 0;
+    int key_count = static_cast<int>(pairs.size());
+    std::vector<Slice> keys;
+    keys.reserve(pairs.size());
+    for (const auto &pair : pairs) {
+      keys.emplace_back(pair.key);
+    }
+    auto s = Exists(ctx, keys, &exists);
+    if (!s.ok()) return s;
+    if ((args.type == StringSetType::NX && exists > 0) || (args.type == 
StringSetType::XX && exists < key_count)) {
+      return rocksdb::Status::OK();
+    }
+  }
+
   auto batch = storage_->GetWriteBatchBase();
   WriteBatchLogData log_data(kRedisString);
-  auto s = batch->PutLogData(log_data.Encode());
+  rocksdb::Status s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) return s;
+
   for (const auto &pair : pairs) {
     std::string bytes;
     Metadata metadata(kRedisString, false);
-    metadata.expire = expire_ms;
+    if (args.keep_ttl) {
+      uint64_t old_expire = 0;
+      rocksdb::Status s = GetExpireTime(ctx, pair.key, &old_expire);
+      if (s.ok() && old_expire != 0) {
+        metadata.expire = old_expire;
+      }
+    } else {
+      metadata.expire = args.expire;
+    }
     metadata.Encode(&bytes);
     bytes.append(pair.value.data(), pair.value.size());
     std::string ns_key = AppendNamespacePrefix(pair.key);
     s = batch->Put(metadata_cf_handle_, ns_key, bytes);
     if (!s.ok()) return s;
   }
-  return storage_->Write(ctx, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
-}
-
-rocksdb::Status String::MSetNX(engine::Context &ctx, const 
std::vector<StringPair> &pairs, uint64_t expire_ms,
-                               bool *flag) {
-  *flag = false;
-
-  int exists = 0;
-  std::vector<Slice> keys;
-  keys.reserve(pairs.size());
+  s = storage_->Write(ctx, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
+  if (!s.ok()) return s;
 
-  for (StringPair pair : pairs) {
-    std::string ns_key = AppendNamespacePrefix(pair.key);
-    keys.emplace_back(pair.key);
+  if (flag != nullptr) {
+    *flag = true;
   }
 
-  if (Exists(ctx, keys, &exists).ok() && exists > 0) {
-    return rocksdb::Status::OK();
-  }
+  return rocksdb::Status::OK();
+}
 
-  rocksdb::Status s = MSet(ctx, pairs, /*expire_ms=*/expire_ms);
-  if (!s.ok()) return s;
+rocksdb::Status String::MSet(engine::Context &ctx, const 
std::vector<StringPair> &pairs) {
+  return MSet(ctx, pairs, {/*expire=*/0, StringSetType::NONE, 
/*keep_ttl=*/false}, nullptr);
+}
 
-  *flag = true;
-  return rocksdb::Status::OK();
+rocksdb::Status String::MSetEX(engine::Context &ctx, const 
std::vector<StringPair> &pairs, StringMSetArgs args,
+                               bool *flag) {
+  return MSet(ctx, pairs, args, flag);
+}
+
+rocksdb::Status String::MSetNX(engine::Context &ctx, const 
std::vector<StringPair> &pairs, bool *flag) {
+  return MSet(ctx, pairs, {/*expire=*/0, StringSetType::NX, 
/*keep_ttl=*/false}, flag);
 }
 
 // Change the value of user_key to a new_value if the current value of the key 
matches old_value.
diff --git a/src/types/redis_string.h b/src/types/redis_string.h
index a37f63544..4e7bdfeac 100644
--- a/src/types/redis_string.h
+++ b/src/types/redis_string.h
@@ -44,6 +44,13 @@ struct StringSetArgs {
   bool keep_ttl;
 };
 
+struct StringMSetArgs {
+  // Expire time in mill seconds.
+  uint64_t expire;
+  StringSetType type;
+  bool keep_ttl;
+};
+
 enum class StringLCSType { NONE, LEN, IDX };
 
 struct StringLCSArgs {
@@ -100,8 +107,10 @@ class String : public Database {
   rocksdb::Status IncrByFloat(engine::Context &ctx, const std::string 
&user_key, double increment, double *new_value);
   std::vector<rocksdb::Status> MGet(engine::Context &ctx, const 
std::vector<Slice> &keys,
                                     std::vector<std::string> *values);
-  rocksdb::Status MSet(engine::Context &ctx, const std::vector<StringPair> 
&pairs, uint64_t expire_ms);
-  rocksdb::Status MSetNX(engine::Context &ctx, const std::vector<StringPair> 
&pairs, uint64_t expire_ms, bool *flag);
+  rocksdb::Status MSet(engine::Context &ctx, const std::vector<StringPair> 
&pairs, StringMSetArgs args, bool *flag);
+  rocksdb::Status MSet(engine::Context &ctx, const std::vector<StringPair> 
&pairs);
+  rocksdb::Status MSetEX(engine::Context &ctx, const std::vector<StringPair> 
&pairs, StringMSetArgs args, bool *flag);
+  rocksdb::Status MSetNX(engine::Context &ctx, const std::vector<StringPair> 
&pairs, bool *flag);
   rocksdb::Status CAS(engine::Context &ctx, const std::string &user_key, const 
std::string &old_value,
                       const std::string &new_value, uint64_t expire_ms, int 
*flag);
   rocksdb::Status CAD(engine::Context &ctx, const std::string &user_key, const 
std::string &value, int *flag);
diff --git a/tests/cppunit/iterator_test.cc b/tests/cppunit/iterator_test.cc
index 183edcdde..1a3045617 100644
--- a/tests/cppunit/iterator_test.cc
+++ b/tests/cppunit/iterator_test.cc
@@ -390,7 +390,7 @@ TEST_F(WALIteratorTest, BasicString) {
   auto start_seq = storage_->GetDB()->GetLatestSequenceNumber();
   redis::String string(storage_.get(), "test_ns0");
   string.Set(*ctx_, "a", "1");
-  string.MSet(*ctx_, {{"b", "2"}, {"c", "3"}}, 0);
+  string.MSet(*ctx_, {{"b", "2"}, {"c", "3"}});
   ASSERT_TRUE(string.Del(*ctx_, "b").ok());
 
   std::vector<std::string> put_keys, delete_keys;
diff --git a/tests/cppunit/types/string_test.cc 
b/tests/cppunit/types/string_test.cc
index 1b5c1a27c..1b8741767 100644
--- a/tests/cppunit/types/string_test.cc
+++ b/tests/cppunit/types/string_test.cc
@@ -69,7 +69,7 @@ TEST_F(RedisStringTest, GetAndSet) {
 }
 
 TEST_F(RedisStringTest, MGetAndMSet) {
-  string_->MSet(*ctx_, pairs_, 0);
+  string_->MSet(*ctx_, pairs_);
   std::vector<Slice> keys;
   std::vector<std::string> values;
   keys.reserve(pairs_.size());
@@ -186,7 +186,7 @@ TEST_F(RedisStringTest, MSetXX) {
 
 TEST_F(RedisStringTest, MSetNX) {
   bool flag = false;
-  string_->MSetNX(*ctx_, pairs_, 0, &flag);
+  string_->MSetNX(*ctx_, pairs_, &flag);
   EXPECT_TRUE(flag);
   std::vector<Slice> keys;
   std::vector<std::string> values;
@@ -202,7 +202,7 @@ TEST_F(RedisStringTest, MSetNX) {
   std::vector<StringPair> new_pairs{
       {"a", "1"}, {"b", "2"}, {"c", "3"}, {pairs_[0].key, pairs_[0].value}, 
{"d", "4"},
   };
-  string_->MSetNX(*ctx_, pairs_, 0, &flag);
+  string_->MSetNX(*ctx_, pairs_, &flag);
   EXPECT_FALSE(flag);
 
   for (auto &pair : pairs_) {
@@ -219,6 +219,71 @@ TEST_F(RedisStringTest, MSetNXWithTTL) {
   s = string_->Del(*ctx_, key_);
 }
 
+TEST_F(RedisStringTest, MSetEX) {
+  {
+    bool flag = false;
+    string_->MSetEX(*ctx_, pairs_, {0, StringSetType::NX, false}, &flag);
+    EXPECT_TRUE(flag);
+    std::vector<Slice> keys;
+    std::vector<std::string> values;
+    keys.reserve(pairs_.size());
+    for (const auto &pair : pairs_) {
+      keys.emplace_back(pair.key);
+    }
+    string_->MGet(*ctx_, keys, &values);
+    for (const auto &pair : pairs_) {
+      int64_t ttl = 0;
+      auto s = string_->TTL(*ctx_, pair.key.ToString(), &ttl);
+      EXPECT_EQ(ttl, -1);
+    }
+  }
+  {
+    bool flag = false;
+    pairs_.emplace_back(StringPair{"a", "1"});
+    string_->MSetEX(*ctx_, pairs_, {0, StringSetType::XX, true}, &flag);
+    EXPECT_FALSE(flag);
+  }
+  for (auto &pair : pairs_) {
+    auto s = string_->Del(*ctx_, pair.key);
+  }
+}
+
+TEST_F(RedisStringTest, MSetEXWithTTL) {
+  {
+    bool flag = false;
+    string_->MSetEX(*ctx_, pairs_, {util::GetTimeStampMS() + 3000, 
StringSetType::NONE, false}, &flag);
+    EXPECT_TRUE(flag);
+    for (const auto &pair : pairs_) {
+      int64_t ttl = 0;
+      auto s = string_->TTL(*ctx_, pair.key.ToString(), &ttl);
+      EXPECT_TRUE(ttl >= 2000 && ttl <= 4000);
+    }
+  }
+  {
+    bool flag = false;
+    pairs_[0].value = "new-test-strings-value1";
+    pairs_[1].value = "new-test-strings-value2";
+    string_->MSetEX(*ctx_, pairs_, {0, StringSetType::XX, true}, &flag);
+    EXPECT_TRUE(flag);
+    std::vector<Slice> keys;
+    std::vector<std::string> values;
+    keys.reserve(pairs_.size());
+    for (const auto &pair : pairs_) {
+      keys.emplace_back(pair.key);
+    }
+    string_->MGet(*ctx_, keys, &values);
+    for (size_t i = 0; i < pairs_.size(); i++) {
+      EXPECT_EQ(pairs_[i].value, values[i]);
+      int64_t ttl = 0;
+      auto s = string_->TTL(*ctx_, pairs_[i].key.ToString(), &ttl);
+      EXPECT_TRUE(ttl >= 2000 && ttl <= 4000);
+    }
+  }
+  for (auto &pair : pairs_) {
+    auto s = string_->Del(*ctx_, pair.key);
+  }
+}
+
 TEST_F(RedisStringTest, SetEX) {
   string_->SetEX(*ctx_, key_, "test-value", util::GetTimeStampMS() + 3000);
   int64_t ttl = 0;
diff --git a/tests/gocase/go.mod b/tests/gocase/go.mod
index 7ec1c0e2b..bde40811b 100644
--- a/tests/gocase/go.mod
+++ b/tests/gocase/go.mod
@@ -4,7 +4,7 @@ go 1.24.0
 
 require (
        github.com/linxGnu/grocksdb v1.10.2
-       github.com/redis/go-redis/v9 v9.14.0
+       github.com/redis/go-redis/v9 v9.17.0
        github.com/shirou/gopsutil/v4 v4.25.8
        github.com/stretchr/testify v1.11.1
        golang.org/x/exp v0.0.0-20250911091902-df9299821621
diff --git a/tests/gocase/go.sum b/tests/gocase/go.sum
index fc666ae92..2520f45f4 100644
--- a/tests/gocase/go.sum
+++ b/tests/gocase/go.sum
@@ -8,8 +8,6 @@ github.com/davecgh/go-spew v1.1.1 
h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod 
h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f 
h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod 
h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
-github.com/ebitengine/purego v0.8.4 
h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
-github.com/ebitengine/purego v0.8.4/go.mod 
h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/ebitengine/purego v0.9.0 
h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
 github.com/ebitengine/purego v0.9.0/go.mod 
h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/go-ole/go-ole v1.2.6/go.mod 
h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
@@ -25,8 +23,8 @@ github.com/pmezard/go-difflib v1.0.0 
h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod 
h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 
h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
 github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod 
h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
-github.com/redis/go-redis/v9 v9.14.0 
h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
-github.com/redis/go-redis/v9 v9.14.0/go.mod 
h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
+github.com/redis/go-redis/v9 v9.17.0 
h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
+github.com/redis/go-redis/v9 v9.17.0/go.mod 
h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
 github.com/shirou/gopsutil/v4 v4.25.8 
h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
 github.com/shirou/gopsutil/v4 v4.25.8/go.mod 
h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
 github.com/stretchr/testify v1.11.1 
h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
diff --git a/tests/gocase/integration/slotmigrate/slotmigrate_test.go 
b/tests/gocase/integration/slotmigrate/slotmigrate_test.go
index 00aeed9bf..7f82885e6 100644
--- a/tests/gocase/integration/slotmigrate/slotmigrate_test.go
+++ b/tests/gocase/integration/slotmigrate/slotmigrate_test.go
@@ -1336,7 +1336,7 @@ func TestSlotRangeMigrate(t *testing.T) {
                require.Equal(t, "OK", rdb0.Do(ctx, "clusterx", "migrate", 
"112-113", id1).Val())
                waitForMigrateSlotRangeState(t, rdb0, "112-113", 
SlotMigrationStateSuccess)
                for slot := 112; slot <= 118; slot++ {
-                       require.Contains(t, rdb0.LPush(ctx, 
util.SlotTable[slot], 10).Err(), "MOVED")
+                       require.ErrorContains(t, rdb0.LPush(ctx, 
util.SlotTable[slot], 10).Err(), "MOVED")
                }
 
                // overlap
diff --git a/tests/gocase/unit/type/strings/strings_test.go 
b/tests/gocase/unit/type/strings/strings_test.go
index 56b67cb00..584030e65 100644
--- a/tests/gocase/unit/type/strings/strings_test.go
+++ b/tests/gocase/unit/type/strings/strings_test.go
@@ -319,6 +319,94 @@ func testString(t *testing.T, configs 
util.KvrocksServerConfigs) {
                require.ErrorContains(t, r.Err(), "wrong number")
        })
 
+       t.Run("MSETEX wrong args", func(t *testing.T) {
+               r := rdb.Do(ctx, "msetex", "0").Err()
+               require.ErrorContains(t, r, "wrong number")
+               r = rdb.Do(ctx, "msetex", "0", "a", "1").Err()
+               require.ErrorContains(t, r, "value is out of range, must be 
positive")
+               r = rdb.Do(ctx, "msetex", "3", "a", "1", "b", "2").Err()
+               require.ErrorContains(t, r, "wrong number")
+               r = rdb.Do(ctx, "msetex", "1", "a", "1", "b", "2", "xx").Err()
+               require.ErrorContains(t, r, "syntax error")
+               r = rdb.Do(ctx, "msetex", "1", "a", "1", "ex", "-1").Err()
+               require.ErrorContains(t, r, "out of numeric range")
+               r = rdb.Do(ctx, "msetex", "1", "a", "1", "ex", "10", 
"keepttl").Err()
+               require.ErrorContains(t, r, "syntax error")
+       })
+
+       t.Run("MSETEX with NX|XX", func(t *testing.T) {
+               require.NoError(t, rdb.Del(ctx, "xx1", "xx2").Err())
+               res := rdb.MSetEX(ctx, redis.MSetEXArgs{Condition: redis.NX}, 
"xx1", "1", "xx2", "2")
+               require.EqualValues(t, 1, res.Val())
+               require.Equal(t, "1", rdb.Get(ctx, "xx1").Val())
+
+               require.NoError(t, rdb.Set(ctx, "xx3", "pre", 0).Err())
+               res = rdb.MSetEX(ctx, redis.MSetEXArgs{Condition: redis.NX}, 
"xx3", "a", "xx4", "b")
+               require.EqualValues(t, 0, res.Val())
+               require.Equal(t, "pre", rdb.Get(ctx, "xx3").Val())
+               require.EqualValues(t, 0, rdb.Exists(ctx, "xx4").Val())
+
+               res = rdb.MSetEX(ctx, redis.MSetEXArgs{
+                       Condition: redis.XX,
+                       Expiration: &redis.ExpirationOption{
+                               Mode: redis.EX, Value: 10,
+                       }}, "xx1", "new1", "xx2", "new2")
+               require.EqualValues(t, 1, res.Val())
+               require.Equal(t, "new1", rdb.Get(ctx, "xx1").Val())
+               require.Equal(t, "new2", rdb.Get(ctx, "xx2").Val())
+               util.BetweenValues(t, rdb.TTL(ctx, "xx1").Val(), 9*time.Second, 
10*time.Second)
+               util.BetweenValues(t, rdb.TTL(ctx, "xx2").Val(), 9*time.Second, 
10*time.Second)
+       })
+
+       t.Run("MSETEX with TTL", func(t *testing.T) {
+               require.NoError(t, rdb.Del(ctx, "a", "b").Err())
+               res := rdb.MSetEX(ctx, redis.MSetEXArgs{
+                       Condition: redis.NX,
+                       Expiration: &redis.ExpirationOption{
+                               Mode:  redis.EX,
+                               Value: 2,
+                       }}, "a", "1", "b", "2")
+               require.EqualValues(t, 1, res.Val())
+               require.Equal(t, "1", rdb.Get(ctx, "a").Val())
+               util.BetweenValues(t, rdb.TTL(ctx, "a").Val(), 1*time.Second, 
2*time.Second)
+               util.BetweenValues(t, rdb.TTL(ctx, "b").Val(), 1*time.Second, 
2*time.Second)
+
+               res = rdb.MSetEX(ctx, redis.MSetEXArgs{
+                       Condition: redis.XX,
+                       Expiration: &redis.ExpirationOption{
+                               Mode:  redis.PX,
+                               Value: 3000,
+                       }}, "a", "10", "d", "20")
+               require.EqualValues(t, 0, res.Val())
+               require.Equal(t, "1", rdb.Get(ctx, "a").Val())
+
+               res = rdb.MSetEX(ctx, redis.MSetEXArgs{
+                       Condition: redis.XX,
+                       Expiration: &redis.ExpirationOption{
+                               Mode:  redis.PX,
+                               Value: 3000,
+                       }}, "a", "10", "b", "20")
+               require.EqualValues(t, 1, res.Val())
+               require.Equal(t, "10", rdb.Get(ctx, "a").Val())
+               require.Equal(t, "20", rdb.Get(ctx, "b").Val())
+               util.BetweenValues(t, rdb.TTL(ctx, "a").Val(), 2*time.Second, 
3*time.Second)
+               util.BetweenValues(t, rdb.TTL(ctx, "b").Val(), 2*time.Second, 
3*time.Second)
+       })
+
+       t.Run("MSETEX with KEEPTTL", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "k1", "v", 5*time.Second).Err())
+               require.NoError(t, rdb.Del(ctx, "k2").Err())
+               res := rdb.MSetEX(ctx, redis.MSetEXArgs{
+                       Expiration: &redis.ExpirationOption{
+                               Mode: redis.KEEPTTL,
+                       }}, "k1", "v2", "k2", "v3")
+               require.EqualValues(t, 1, res.Val())
+               require.Equal(t, "v2", rdb.Get(ctx, "k1").Val())
+               require.Equal(t, "v3", rdb.Get(ctx, "k2").Val())
+               util.BetweenValues(t, rdb.TTL(ctx, "k1").Val(), 4*time.Second, 
5*time.Second)
+               require.EqualValues(t, -1, rdb.TTL(ctx, "k2").Val())
+       })
+
        t.Run("MSETNX with already existent key", func(t *testing.T) {
                r := rdb.MSetNX(ctx, map[string]interface{}{
                        "x1": "xxx",

Reply via email to