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 37140d6c3 feat(hash): initialize field expiration metadata and subkey 
en/decoding (#3444)
37140d6c3 is described below

commit 37140d6c30be4fa91e8a810e8be83981d618ce12
Author: Twice <[email protected]>
AuthorDate: Tue Apr 14 23:03:38 2026 +0800

    feat(hash): initialize field expiration metadata and subkey en/decoding 
(#3444)
    
    Part of #3436. See proposal #3432.
    
    This PR includes:
    - metadata changes for the new encoding
    - subkey encoding/decoding and related logic in `redis_hash.cc`
    - new config option adding
    - an enhancement to `SubKeyScanner` so that it can be used for HFE
    - run hash test cases for both modes
    
    Assisted-by: Codex
---
 kvrocks.conf                                       |  20 +++
 src/config/config.cc                               |  15 ++
 src/config/config.h                                |   2 +
 src/search/indexer.cc                              |  10 +-
 src/search/indexer.h                               |   2 +-
 src/storage/redis_db.cc                            |  37 +---
 src/storage/redis_db.h                             |  64 +++++++
 src/storage/redis_metadata.cc                      |  75 ++++++++
 src/storage/redis_metadata.h                       |  30 +++-
 src/types/redis_hash.cc                            | 115 ++++++++----
 src/types/redis_hash.h                             |   5 +-
 tests/cppunit/config_test.cc                       |   2 +
 tests/cppunit/metadata_test.cc                     |  57 ++++++
 tests/cppunit/types/hash_test.cc                   | 104 +++++++++++
 tests/gocase/unit/config/config_test.go            | 102 +++++++++--
 tests/gocase/unit/geo/geo_test.go                  |  10 +-
 tests/gocase/unit/kmetadata/kmetadata_test.go      |   5 +-
 tests/gocase/unit/pubsub/pubsub_test.go            |  10 +-
 tests/gocase/unit/scripting/function_test.go       |  10 +-
 tests/gocase/unit/type/bloom/bloom_test.go         |   5 +-
 tests/gocase/unit/type/hash/hash_test.go           | 193 ++++++++++++---------
 tests/gocase/unit/type/incr/incr_test.go           |   5 +-
 tests/gocase/unit/type/json/json_test.go           |   5 +-
 tests/gocase/unit/type/list/list_test.go           |  25 ++-
 tests/gocase/unit/type/set/set_test.go             |  10 +-
 tests/gocase/unit/type/sint/sint_test.go           |   5 +-
 tests/gocase/unit/type/stream/stream_test.go       |  10 +-
 tests/gocase/unit/type/strings/strings_test.go     |   5 +-
 tests/gocase/unit/type/tdigest/tdigest_test.go     |  15 +-
 .../gocase/unit/type/timeseries/timeseries_test.go |   5 +-
 tests/gocase/unit/type/zset/zset_test.go           |  10 +-
 tests/gocase/util/configs.go                       |  52 ++----
 32 files changed, 731 insertions(+), 289 deletions(-)

diff --git a/kvrocks.conf b/kvrocks.conf
index 12b019ca1..f1541c496 100644
--- a/kvrocks.conf
+++ b/kvrocks.conf
@@ -77,6 +77,26 @@ repl-namespace-enabled no
 #
 # proto-max-bulk-len 536870912
 
+# The encoding mode for newly created hash keys.
+# - legacy: use the original subkey format (no per-field expire support).
+#   Existing data written in this mode is always readable regardless of this 
setting.
+# - field-expiration: use the new subkey format that prepends an 8-byte expire
+#   timestamp to each field value. Required for HEXPIRE/HPERSIST/HPTTL 
commands.
+# NOTE: this only affects newly created keys. Existing keys retain their 
original mode.
+# Default: legacy
+# Available options: legacy, field-expiration
+hash-encoding-mode legacy
+
+# How HLEN computes the number of fields when some fields have a TTL.
+# - accurate: return the exact count. O(1) when no fields have TTL or none have
+#   expired yet; otherwise performs a scan to remove expired fields and update 
metadata.
+# - approximate: always return the stored size without scanning. The count may 
include
+#   fields that have already expired but have not yet been cleaned up.
+# This option has no effect when hash-encoding-mode is legacy.
+# Default: accurate
+# Available options: accurate, approximate
+hash-length-mode accurate
+
 # Persist the cluster nodes topology in local file($dir/nodes.conf). This 
configuration
 # takes effect only if the cluster mode was enabled.
 #
diff --git a/src/config/config.cc b/src/config/config.cc
index 416eabd9a..492eb975b 100644
--- a/src/config/config.cc
+++ b/src/config/config.cc
@@ -93,6 +93,16 @@ const std::vector<ConfigEnum<BlockCacheType>> cache_types{[] 
{
 const std::vector<ConfigEnum<MigrationType>> migration_types{{"redis-command", 
MigrationType::kRedisCommand},
                                                              {"raw-key-value", 
MigrationType::kRawKeyValue}};
 
+const std::vector<ConfigEnum<HashSubkeyEncodingMode>> 
hash_subkey_encoding_modes{
+    {"legacy", HashSubkeyEncodingMode::kLegacy},
+    {"field-expiration", HashSubkeyEncodingMode::kFieldExpiration},
+};
+
+const std::vector<ConfigEnum<HashLengthMode>> hash_length_modes{
+    {"accurate", HashLengthMode::kAccurate},
+    {"approximate", HashLengthMode::kApproximate},
+};
+
 std::string TrimRocksDbPrefix(std::string s) {
   constexpr std::string_view prefix = "rocksdb.";
   if (!util::StartsWithICase(s, prefix)) return s;
@@ -237,6 +247,11 @@ Config::Config() {
       {"redis-cursor-compatible", false, new 
YesNoField(&redis_cursor_compatible, true)},
       {"redis-databases", true, new IntField(&redis_databases, 0, 0, INT_MAX)},
       {"resp3-enabled", false, new YesNoField(&resp3_enabled, true)},
+      {"hash-encoding-mode", false,
+       new EnumField<HashSubkeyEncodingMode>(&hash_encoding_mode, 
hash_subkey_encoding_modes,
+                                             HashSubkeyEncodingMode::kLegacy)},
+      {"hash-length-mode", false,
+       new EnumField<HashLengthMode>(&hash_length_mode, hash_length_modes, 
HashLengthMode::kAccurate)},
       {"repl-namespace-enabled", false, new 
YesNoField(&repl_namespace_enabled, false)},
       {"proto-max-bulk-len", false,
        new IntWithUnitField<uint64_t>(&proto_max_bulk_len, std::to_string(512 
* MiB), 1 * MiB,
diff --git a/src/config/config.h b/src/config/config.h
index 159fb83b5..c17b331e7 100644
--- a/src/config/config.h
+++ b/src/config/config.h
@@ -180,6 +180,8 @@ struct Config {
   int redis_databases = 0;
   bool resp3_enabled = false;
   int log_retention_days;
+  HashSubkeyEncodingMode hash_encoding_mode = HashSubkeyEncodingMode::kLegacy;
+  HashLengthMode hash_length_mode = HashLengthMode::kAccurate;
 
   // load_tokens is used to buffer the tokens when loading,
   // don't use it to authenticate or rewrite the configuration file.
diff --git a/src/search/indexer.cc b/src/search/indexer.cc
index 730a59089..07ece4990 100644
--- a/src/search/indexer.cc
+++ b/src/search/indexer.cc
@@ -43,7 +43,7 @@ StatusOr<FieldValueRetriever> 
FieldValueRetriever::Create(IndexOnDataType type,
     std::string ns_key = db.AppendNamespacePrefix(key);
     HashMetadata metadata(false);
 
-    auto s = db.GetMetadata(ctx, ns_key, &metadata);
+    auto s = db.getMetadata(ctx, ns_key, &metadata);
     if (!s.ok()) return {s.IsNotFound() ? Status::NotFound : Status::NotOK, 
s.ToString()};
     return FieldValueRetriever(db, metadata, key);
   } else if (type == IndexOnDataType::JSON) {
@@ -98,7 +98,7 @@ StatusOr<kqir::Value> 
FieldValueRetriever::ParseFromJson(const jsoncons::json &v
   }
 }
 
-StatusOr<kqir::Value> FieldValueRetriever::ParseFromHash(const std::string 
&value,
+StatusOr<kqir::Value> FieldValueRetriever::ParseFromHash(std::string_view 
value,
                                                          const 
redis::IndexFieldMetadata *type) {
   if (auto numeric [[maybe_unused]] = dynamic_cast<const 
redis::NumericFieldMetadata *>(type)) {
     auto num = GET_OR_RET(ParseFloat(value));
@@ -137,7 +137,11 @@ StatusOr<kqir::Value> 
FieldValueRetriever::Retrieve(engine::Context &ctx, std::s
     if (s.IsNotFound()) return {Status::NotFound, s.ToString()};
     if (!s.ok()) return {Status::NotOK, s.ToString()};
 
-    return ParseFromHash(value, type);
+    Slice decoded_value(value);
+    s = metadata.DecodeSubkeyValue(&decoded_value);
+    if (!s.ok()) return {Status::NotOK, s.ToString()};
+
+    return ParseFromHash(decoded_value.ToStringView(), type);
   } else if (std::holds_alternative<JsonData>(db)) {
     auto &value = std::get<JsonData>(db);
 
diff --git a/src/search/indexer.h b/src/search/indexer.h
index b6d004310..18942029a 100644
--- a/src/search/indexer.h
+++ b/src/search/indexer.h
@@ -67,7 +67,7 @@ struct FieldValueRetriever {
   StatusOr<kqir::Value> Retrieve(engine::Context &ctx, std::string_view field, 
const redis::IndexFieldMetadata *type);
 
   static StatusOr<kqir::Value> ParseFromJson(const jsoncons::json &value, 
const redis::IndexFieldMetadata *type);
-  static StatusOr<kqir::Value> ParseFromHash(const std::string &value, const 
redis::IndexFieldMetadata *type);
+  static StatusOr<kqir::Value> ParseFromHash(std::string_view value, const 
redis::IndexFieldMetadata *type);
 };
 
 struct IndexUpdater {
diff --git a/src/storage/redis_db.cc b/src/storage/redis_db.cc
index 99b3d96a3..1452d01ee 100644
--- a/src/storage/redis_db.cc
+++ b/src/storage/redis_db.cc
@@ -558,42 +558,7 @@ rocksdb::Status Database::KeyExist(engine::Context &ctx, 
const std::string &key)
 rocksdb::Status SubKeyScanner::Scan(engine::Context &ctx, RedisType type, 
const Slice &user_key,
                                     const std::string &cursor, uint64_t limit, 
const std::string &subkey_prefix,
                                     std::vector<std::string> *keys, 
std::vector<std::string> *values) {
-  uint64_t cnt = 0;
-  std::string ns_key = AppendNamespacePrefix(user_key);
-  Metadata metadata(type, false);
-  rocksdb::Status s = GetMetadata(ctx, {type}, ns_key, &metadata);
-  if (!s.ok()) return s;
-
-  auto iter = util::UniqueIterator(ctx, ctx.DefaultScanOptions());
-  std::string match_prefix_key =
-      InternalKey(ns_key, subkey_prefix, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
-
-  std::string start_key;
-  if (!cursor.empty()) {
-    start_key = InternalKey(ns_key, cursor, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
-  } else {
-    start_key = match_prefix_key;
-  }
-  for (iter->Seek(start_key); iter->Valid(); iter->Next()) {
-    if (!cursor.empty() && iter->key() == start_key) {
-      // if cursor is not empty, then we need to skip start_key
-      // because we already return that key in the last scan
-      continue;
-    }
-    if (!iter->key().starts_with(match_prefix_key)) {
-      break;
-    }
-    InternalKey ikey(iter->key(), storage_->IsSlotIdEncoded());
-    keys->emplace_back(ikey.GetSubKey().ToString());
-    if (values != nullptr) {
-      values->emplace_back(iter->value().ToString());
-    }
-    cnt++;
-    if (limit > 0 && cnt >= limit) {
-      break;
-    }
-  }
-  return iter->status();
+  return scanSubkeys<Metadata>(ctx, type, user_key, cursor, limit, 
subkey_prefix, keys, values);
 }
 
 RedisType WriteBatchLogData::GetRedisType() const { return type_; }
diff --git a/src/storage/redis_db.h b/src/storage/redis_db.h
index 506dc250b..8986790a2 100644
--- a/src/storage/redis_db.h
+++ b/src/storage/redis_db.h
@@ -22,11 +22,13 @@
 
 #include <optional>
 #include <string>
+#include <type_traits>
 #include <utility>
 #include <variant>
 #include <vector>
 
 #include "cluster/cluster_defs.h"
+#include "common/db_util.h"
 #include "redis_metadata.h"
 #include "storage.h"
 
@@ -182,6 +184,68 @@ class SubKeyScanner : public redis::Database {
   rocksdb::Status Scan(engine::Context &ctx, RedisType type, const Slice 
&user_key, const std::string &cursor,
                        uint64_t limit, const std::string &subkey_prefix, 
std::vector<std::string> *keys,
                        std::vector<std::string> *values = nullptr);
+
+ protected:
+  struct RawSubkeyValueAdapter {
+    template <typename MetadataT>
+    rocksdb::Status operator()(const MetadataT &, Slice *) const {
+      return rocksdb::Status::OK();
+    }
+  };
+
+  template <typename MetadataT>
+  static MetadataT createScanMetadata(RedisType type) {
+    if constexpr (std::is_same_v<MetadataT, Metadata>) {
+      return Metadata(type, false);
+    } else {
+      return MetadataT(false);
+    }
+  }
+
+  template <typename MetadataT, typename ValueAdapter = RawSubkeyValueAdapter>
+  rocksdb::Status scanSubkeys(engine::Context &ctx, RedisType type, const 
Slice &user_key, const std::string &cursor,
+                              uint64_t limit, const std::string 
&subkey_prefix, std::vector<std::string> *keys,
+                              std::vector<std::string> *values = nullptr, 
ValueAdapter value_adapter = {}) {
+    uint64_t cnt = 0;
+    std::string ns_key = AppendNamespacePrefix(user_key);
+    auto metadata = createScanMetadata<MetadataT>(type);
+    rocksdb::Status s = GetMetadata(ctx, {type}, ns_key, &metadata);
+    if (!s.ok()) return s;
+
+    auto iter = util::UniqueIterator(ctx, ctx.DefaultScanOptions());
+    std::string match_prefix_key =
+        InternalKey(ns_key, subkey_prefix, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
+
+    std::string start_key;
+    if (!cursor.empty()) {
+      start_key = InternalKey(ns_key, cursor, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
+    } else {
+      start_key = match_prefix_key;
+    }
+    for (iter->Seek(start_key); iter->Valid(); iter->Next()) {
+      if (!cursor.empty() && iter->key() == start_key) {
+        // if cursor is not empty, then we need to skip start_key
+        // because we already return that key in the last scan
+        continue;
+      }
+      if (!iter->key().starts_with(match_prefix_key)) {
+        break;
+      }
+      InternalKey ikey(iter->key(), storage_->IsSlotIdEncoded());
+      keys->emplace_back(ikey.GetSubKey().ToString());
+      if (values != nullptr) {
+        Slice value(iter->value());
+        s = value_adapter(metadata, &value);
+        if (!s.ok()) return s;
+        values->emplace_back(value.data(), value.size());
+      }
+      cnt++;
+      if (limit > 0 && cnt >= limit) {
+        break;
+      }
+    }
+    return iter->status();
+  }
 };
 
 class WriteBatchLogData {
diff --git a/src/storage/redis_metadata.cc b/src/storage/redis_metadata.cc
index 692f8804d..6945a0a6d 100644
--- a/src/storage/redis_metadata.cc
+++ b/src/storage/redis_metadata.cc
@@ -38,6 +38,7 @@ const int VersionCounterBits = 11;
 static std::atomic<uint64_t> version_counter_ = 0;
 
 constexpr const char *kErrMetadataTooShort = "metadata is too short";
+constexpr const char *kErrHashSubkeyValueTooShort = "hash subkey value is too 
short";
 
 InternalKey::InternalKey(Slice input, bool slot_id_encoded) : 
slot_id_encoded_(slot_id_encoded) {
   uint32_t key_size = 0;
@@ -339,6 +340,80 @@ bool Metadata::IsEmptyableType() const {
 
 bool Metadata::Expired() const { return ExpireAt(util::GetTimeStampMS()); }
 
+void HashMetadata::Encode(std::string *dst) const {
+  Metadata::Encode(dst);
+  if (IsLegacySubkeyEncoding()) {
+    return;
+  }
+
+  PutFixed8(dst, static_cast<uint8_t>(mode));
+  PutFixed64(dst, expsz);
+  PutFixed64(dst, lower);
+  PutFixed64(dst, upper);
+}
+
+rocksdb::Status HashMetadata::Decode(Slice *input) {
+  if (auto s = Metadata::Decode(input); !s.ok()) {
+    return s;
+  }
+
+  if (input->empty()) {
+    mode = HashSubkeyEncodingMode::kLegacy;
+    expsz = 0;
+    lower = 0;
+    upper = 0;
+    return rocksdb::Status::OK();
+  }
+
+  if (input->size() < 1 + 8 + 8 + 8) {
+    return rocksdb::Status::InvalidArgument(kErrMetadataTooShort);
+  }
+
+  uint8_t encoded_mode = 0;
+  GetFixed8(input, &encoded_mode);
+  if (encoded_mode > 
static_cast<uint8_t>(HashSubkeyEncodingMode::kFieldExpiration)) {
+    return rocksdb::Status::InvalidArgument("invalid hash subkey encoding 
mode");
+  }
+
+  mode = static_cast<HashSubkeyEncodingMode>(encoded_mode);
+  GetFixed64(input, &expsz);
+  GetFixed64(input, &lower);
+  GetFixed64(input, &upper);
+  return rocksdb::Status::OK();
+}
+
+std::string HashMetadata::EncodeSubkeyValue(Slice value, uint64_t expire) 
const {
+  if (IsLegacySubkeyEncoding()) {
+    return value.ToString();
+  }
+
+  std::string encoded;
+  encoded.reserve(kFieldExpirationPrefixSize + value.size());
+  PutFixed64(&encoded, expire);
+  encoded.append(value.data(), value.size());
+  return encoded;
+}
+
+rocksdb::Status HashMetadata::DecodeSubkeyValue(Slice *value, uint64_t 
*expire) const {
+  if (IsLegacySubkeyEncoding()) {
+    if (expire != nullptr) {
+      *expire = 0;
+    }
+    return rocksdb::Status::OK();
+  }
+
+  if (value->size() < kFieldExpirationPrefixSize) {
+    return rocksdb::Status::InvalidArgument(kErrHashSubkeyValueTooShort);
+  }
+
+  uint64_t encoded_expire = 0;
+  GetFixed64(value, &encoded_expire);
+  if (expire != nullptr) {
+    *expire = encoded_expire;
+  }
+  return rocksdb::Status::OK();
+}
+
 ListMetadata::ListMetadata(bool generate_version)
     : Metadata(kRedisList, generate_version), head(UINT64_MAX / 2), tail(head) 
{}
 
diff --git a/src/storage/redis_metadata.h b/src/storage/redis_metadata.h
index fd80e5a5b..2e79cf232 100644
--- a/src/storage/redis_metadata.h
+++ b/src/storage/redis_metadata.h
@@ -103,6 +103,16 @@ enum RedisCommand {
 constexpr const char *kErrMsgWrongType = "WRONGTYPE Operation against a key 
holding the wrong kind of value";
 constexpr const char *kErrMsgKeyExpired = "the key was expired";
 
+enum class HashSubkeyEncodingMode : uint8_t {
+  kLegacy = 0,
+  kFieldExpiration = 1,
+};
+
+enum class HashLengthMode : uint8_t {
+  kAccurate = 0,
+  kApproximate = 1,
+};
+
 using rocksdb::Slice;
 
 struct KeyNumStats {
@@ -211,7 +221,25 @@ class Metadata {
 
 class HashMetadata : public Metadata {
  public:
-  explicit HashMetadata(bool generate_version = true) : Metadata(kRedisHash, 
generate_version) {}
+  static constexpr size_t kFieldExpirationPrefixSize = sizeof(uint64_t);
+
+  HashSubkeyEncodingMode mode = HashSubkeyEncodingMode::kLegacy;
+  uint64_t expsz = 0;
+  uint64_t lower = 0;
+  uint64_t upper = 0;
+
+  explicit HashMetadata(bool generate_version = true, HashSubkeyEncodingMode 
mode = HashSubkeyEncodingMode::kLegacy)
+      : Metadata(kRedisHash, generate_version), mode(mode) {}
+
+  bool IsLegacySubkeyEncoding() const { return mode == 
HashSubkeyEncodingMode::kLegacy; }
+  bool IsFieldExpirationEncoding() const { return mode == 
HashSubkeyEncodingMode::kFieldExpiration; }
+
+  [[nodiscard]] std::string EncodeSubkeyValue(Slice value, uint64_t expire = 
0) const;
+  [[nodiscard]] rocksdb::Status DecodeSubkeyValue(Slice *value, uint64_t 
*expire = nullptr) const;
+
+  void Encode(std::string *dst) const override;
+  using Metadata::Decode;
+  rocksdb::Status Decode(Slice *input) override;
 };
 
 class SetMetadata : public Metadata {
diff --git a/src/types/redis_hash.cc b/src/types/redis_hash.cc
index 26cdc43ab..be481110b 100644
--- a/src/types/redis_hash.cc
+++ b/src/types/redis_hash.cc
@@ -35,16 +35,28 @@
 
 namespace redis {
 
-rocksdb::Status Hash::GetMetadata(engine::Context &ctx, const Slice &ns_key, 
HashMetadata *metadata) {
+HashMetadata Hash::createMetadataForWrite(bool generate_version) const {
+  return HashMetadata(generate_version, 
storage_->GetConfig()->hash_encoding_mode);
+}
+
+rocksdb::Status Hash::getMetadata(engine::Context &ctx, const Slice &ns_key, 
HashMetadata *metadata) {
   return Database::GetMetadata(ctx, {kRedisHash}, ns_key, metadata);
 }
 
+rocksdb::Status Hash::getRawValue(engine::Context &ctx, const std::string 
&sub_key, std::string *value) {
+  return storage_->Get(ctx, ctx.GetReadOptions(), sub_key, value);
+}
+
+rocksdb::Status Hash::decodeValue(const HashMetadata &metadata, Slice *value, 
uint64_t *expire) {
+  return metadata.DecodeSubkeyValue(value, expire);
+}
+
 rocksdb::Status Hash::Size(engine::Context &ctx, const Slice &user_key, 
uint64_t *size) {
   *size = 0;
 
   std::string ns_key = AppendNamespacePrefix(user_key);
   HashMetadata metadata(false);
-  rocksdb::Status s = GetMetadata(ctx, ns_key, &metadata);
+  rocksdb::Status s = getMetadata(ctx, ns_key, &metadata);
   if (!s.ok()) return s;
   *size = metadata.size;
   return rocksdb::Status::OK();
@@ -53,11 +65,17 @@ rocksdb::Status Hash::Size(engine::Context &ctx, const 
Slice &user_key, uint64_t
 rocksdb::Status Hash::Get(engine::Context &ctx, const Slice &user_key, const 
Slice &field, std::string *value) {
   std::string ns_key = AppendNamespacePrefix(user_key);
   HashMetadata metadata(false);
-  rocksdb::Status s = GetMetadata(ctx, ns_key, &metadata);
+  rocksdb::Status s = getMetadata(ctx, ns_key, &metadata);
   if (!s.ok()) return s;
-  rocksdb::ReadOptions read_options;
   std::string sub_key = InternalKey(ns_key, field, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
-  return storage_->Get(ctx, ctx.GetReadOptions(), sub_key, value);
+  std::string raw_value;
+  s = getRawValue(ctx, sub_key, &raw_value);
+  if (!s.ok()) return s;
+  Slice payload(raw_value);
+  s = decodeValue(metadata, &payload);
+  if (!s.ok()) return s;
+  value->assign(payload.data(), payload.size());
+  return rocksdb::Status::OK();
 }
 
 rocksdb::Status Hash::IncrBy(engine::Context &ctx, const Slice &user_key, 
const Slice &field, int64_t increment,
@@ -67,17 +85,21 @@ rocksdb::Status Hash::IncrBy(engine::Context &ctx, const 
Slice &user_key, const
 
   std::string ns_key = AppendNamespacePrefix(user_key);
 
-  HashMetadata metadata;
-  rocksdb::Status s = GetMetadata(ctx, ns_key, &metadata);
+  HashMetadata metadata = createMetadataForWrite();
+  rocksdb::Status s = getMetadata(ctx, ns_key, &metadata);
   if (!s.ok() && !s.IsNotFound()) return s;
 
   std::string sub_key = InternalKey(ns_key, field, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
   if (s.ok()) {
-    std::string value_bytes;
-    s = storage_->Get(ctx, ctx.GetReadOptions(), sub_key, &value_bytes);
+    std::string raw_value;
+    Slice value_bytes;
+    s = getRawValue(ctx, sub_key, &raw_value);
     if (!s.ok() && !s.IsNotFound()) return s;
     if (s.ok()) {
-      auto parse_result = ParseInt<int64_t>(value_bytes, 10);
+      value_bytes = Slice(raw_value);
+      s = decodeValue(metadata, &value_bytes);
+      if (!s.ok()) return s;
+      auto parse_result = ParseInt<int64_t>(value_bytes.ToStringView(), 10);
       if (!parse_result) {
         return rocksdb::Status::InvalidArgument(parse_result.Msg());
       }
@@ -98,7 +120,8 @@ rocksdb::Status Hash::IncrBy(engine::Context &ctx, const 
Slice &user_key, const
   WriteBatchLogData log_data(kRedisHash);
   s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) return s;
-  s = batch->Put(sub_key, std::to_string(*new_value));
+  std::string encoded_value = 
metadata.EncodeSubkeyValue(std::to_string(*new_value));
+  s = batch->Put(sub_key, encoded_value);
   if (!s.ok()) return s;
   if (!exists) {
     metadata.size += 1;
@@ -117,17 +140,21 @@ rocksdb::Status Hash::IncrByFloat(engine::Context &ctx, 
const Slice &user_key, c
 
   std::string ns_key = AppendNamespacePrefix(user_key);
 
-  HashMetadata metadata;
-  rocksdb::Status s = GetMetadata(ctx, ns_key, &metadata);
+  HashMetadata metadata = createMetadataForWrite();
+  rocksdb::Status s = getMetadata(ctx, ns_key, &metadata);
   if (!s.ok() && !s.IsNotFound()) return s;
 
   std::string sub_key = InternalKey(ns_key, field, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
   if (s.ok()) {
-    std::string value_bytes;
-    s = storage_->Get(ctx, ctx.GetReadOptions(), sub_key, &value_bytes);
+    std::string raw_value;
+    Slice value_bytes;
+    s = getRawValue(ctx, sub_key, &raw_value);
     if (!s.ok() && !s.IsNotFound()) return s;
     if (s.ok()) {
-      auto value_stat = ParseFloat(value_bytes);
+      value_bytes = Slice(raw_value);
+      s = decodeValue(metadata, &value_bytes);
+      if (!s.ok()) return s;
+      auto value_stat = ParseFloat(value_bytes.ToStringView());
       if (!value_stat || isspace(value_bytes[0])) {
         return rocksdb::Status::InvalidArgument("value is not a number");
       }
@@ -145,7 +172,8 @@ rocksdb::Status Hash::IncrByFloat(engine::Context &ctx, 
const Slice &user_key, c
   WriteBatchLogData log_data(kRedisHash);
   s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) return s;
-  s = batch->Put(sub_key, util::Float2String(*new_value));
+  std::string encoded_value = 
metadata.EncodeSubkeyValue(util::Float2String(*new_value));
+  s = batch->Put(sub_key, encoded_value);
   if (!s.ok()) return s;
   if (!exists) {
     metadata.size += 1;
@@ -164,7 +192,7 @@ rocksdb::Status Hash::MGet(engine::Context &ctx, const 
Slice &user_key, const st
 
   std::string ns_key = AppendNamespacePrefix(user_key);
   HashMetadata metadata(false);
-  rocksdb::Status s = GetMetadata(ctx, ns_key, &metadata);
+  rocksdb::Status s = getMetadata(ctx, ns_key, &metadata);
   if (!s.ok()) {
     return s;
   }
@@ -189,7 +217,14 @@ rocksdb::Status Hash::MGet(engine::Context &ctx, const 
Slice &user_key, const st
                      values_vector.data(), statuses_vector.data());
   for (size_t i = 0; i < keys.size(); i++) {
     if (!statuses_vector[i].ok() && !statuses_vector[i].IsNotFound()) return 
statuses_vector[i];
-    values->emplace_back(values_vector[i].ToString());
+    if (statuses_vector[i].ok()) {
+      Slice value(values_vector[i]);
+      s = decodeValue(metadata, &value);
+      if (!s.ok()) return s;
+      values->emplace_back(value.data(), value.size());
+    } else {
+      values->emplace_back("");
+    }
     statuses->emplace_back(statuses_vector[i]);
   }
   return rocksdb::Status::OK();
@@ -211,7 +246,7 @@ rocksdb::Status Hash::Delete(engine::Context &ctx, const 
Slice &user_key, const
   auto s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) return s;
 
-  s = GetMetadata(ctx, ns_key, &metadata);
+  s = getMetadata(ctx, ns_key, &metadata);
   if (!s.ok()) return s.IsNotFound() ? rocksdb::Status::OK() : s;
 
   std::string value;
@@ -244,8 +279,8 @@ rocksdb::Status Hash::MSet(engine::Context &ctx, const 
Slice &user_key, const st
   *added_cnt = 0;
   std::string ns_key = AppendNamespacePrefix(user_key);
 
-  HashMetadata metadata;
-  rocksdb::Status s = GetMetadata(ctx, ns_key, &metadata);
+  HashMetadata metadata = createMetadataForWrite();
+  rocksdb::Status s = getMetadata(ctx, ns_key, &metadata);
   if (!s.ok() && !s.IsNotFound()) return s;
   bool ttl_updated = false;
   if (expire > 0 && metadata.expire != expire) {
@@ -292,7 +327,13 @@ rocksdb::Status Hash::MSet(engine::Context &ctx, const 
Slice &user_key, const st
         return field_status;
       }
       if (field_status.ok()) {
-        if (nx || values_vector[field_index] == values[field_index]) {
+        if (nx) {
+          continue;
+        }
+        Slice existing_value(values_vector[field_index]);
+        s = decodeValue(metadata, &existing_value);
+        if (!s.ok()) return s;
+        if (existing_value.ToStringView() == values[field_index]) {
           continue;
         }
         exists = true;
@@ -303,7 +344,8 @@ rocksdb::Status Hash::MSet(engine::Context &ctx, const 
Slice &user_key, const st
       added++;
     }
 
-    s = batch->Put(field_key, values[field_index]);
+    std::string encoded_value = 
metadata.EncodeSubkeyValue(values[field_index]);
+    s = batch->Put(field_key, encoded_value);
     if (!s.ok()) return s;
   }
 
@@ -327,7 +369,7 @@ rocksdb::Status Hash::RangeByLex(engine::Context &ctx, 
const Slice &user_key, co
   }
   std::string ns_key = AppendNamespacePrefix(user_key);
   HashMetadata metadata(false);
-  rocksdb::Status s = GetMetadata(ctx, ns_key, &metadata);
+  rocksdb::Status s = getMetadata(ctx, ns_key, &metadata);
   if (!s.ok()) return s.IsNotFound() ? rocksdb::Status::OK() : s;
 
   std::string start_member = spec.reversed ? spec.max : spec.min;
@@ -370,7 +412,10 @@ rocksdb::Status Hash::RangeByLex(engine::Context &ctx, 
const Slice &user_key, co
     }
     if (spec.offset >= 0 && pos++ < spec.offset) continue;
 
-    field_values->emplace_back(ikey.GetSubKey().ToString(), 
iter->value().ToString());
+    Slice value(iter->value());
+    s = decodeValue(metadata, &value);
+    if (!s.ok()) return s;
+    field_values->emplace_back(ikey.GetSubKey().ToString(), 
std::string(value.data(), value.size()));
     if (spec.count > 0 && field_values->size() >= 
static_cast<unsigned>(spec.count)) break;
   }
   return rocksdb::Status::OK();
@@ -382,7 +427,7 @@ rocksdb::Status Hash::GetAll(engine::Context &ctx, const 
Slice &user_key, std::v
 
   std::string ns_key = AppendNamespacePrefix(user_key);
   HashMetadata metadata(false);
-  rocksdb::Status s = GetMetadata(ctx, ns_key, &metadata);
+  rocksdb::Status s = getMetadata(ctx, ns_key, &metadata);
   if (!s.ok()) return s.IsNotFound() ? rocksdb::Status::OK() : s;
 
   std::string prefix_key = InternalKey(ns_key, "", metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
@@ -399,10 +444,16 @@ rocksdb::Status Hash::GetAll(engine::Context &ctx, const 
Slice &user_key, std::v
       InternalKey ikey(iter->key(), storage_->IsSlotIdEncoded());
       field_values->emplace_back(ikey.GetSubKey().ToString(), "");
     } else if (type == HashFetchType::kOnlyValue) {
-      field_values->emplace_back("", iter->value().ToString());
+      Slice value(iter->value());
+      s = decodeValue(metadata, &value);
+      if (!s.ok()) return s;
+      field_values->emplace_back("", std::string(value.data(), value.size()));
     } else {
       InternalKey ikey(iter->key(), storage_->IsSlotIdEncoded());
-      field_values->emplace_back(ikey.GetSubKey().ToString(), 
iter->value().ToString());
+      Slice value(iter->value());
+      s = decodeValue(metadata, &value);
+      if (!s.ok()) return s;
+      field_values->emplace_back(ikey.GetSubKey().ToString(), 
std::string(value.data(), value.size()));
     }
   }
   return rocksdb::Status::OK();
@@ -411,7 +462,9 @@ rocksdb::Status Hash::GetAll(engine::Context &ctx, const 
Slice &user_key, std::v
 rocksdb::Status Hash::Scan(engine::Context &ctx, const Slice &user_key, const 
std::string &cursor, uint64_t limit,
                            const std::string &field_prefix, 
std::vector<std::string> *fields,
                            std::vector<std::string> *values) {
-  return SubKeyScanner::Scan(ctx, kRedisHash, user_key, cursor, limit, 
field_prefix, fields, values);
+  return scanSubkeys<HashMetadata>(
+      ctx, kRedisHash, user_key, cursor, limit, field_prefix, fields, values,
+      [](const HashMetadata &metadata, Slice *value) { return 
metadata.DecodeSubkeyValue(value); });
 }
 
 rocksdb::Status Hash::RandField(engine::Context &ctx, const Slice &user_key, 
int64_t command_count,
@@ -421,7 +474,7 @@ rocksdb::Status Hash::RandField(engine::Context &ctx, const 
Slice &user_key, int
 
   std::string ns_key = AppendNamespacePrefix(user_key);
   HashMetadata metadata(/*generate_version=*/false);
-  rocksdb::Status s = GetMetadata(ctx, ns_key, &metadata);
+  rocksdb::Status s = getMetadata(ctx, ns_key, &metadata);
   if (!s.ok()) return s;
 
   std::vector<FieldValue> samples;
diff --git a/src/types/redis_hash.h b/src/types/redis_hash.h
index 94f722c4f..400c2401b 100644
--- a/src/types/redis_hash.h
+++ b/src/types/redis_hash.h
@@ -70,7 +70,10 @@ class Hash : public SubKeyScanner {
                             std::vector<FieldValue> *field_values, 
HashFetchType type = HashFetchType::kOnlyKey);
 
  private:
-  rocksdb::Status GetMetadata(engine::Context &ctx, const Slice &ns_key, 
HashMetadata *metadata);
+  [[nodiscard]] HashMetadata createMetadataForWrite(bool generate_version = 
true) const;
+  rocksdb::Status getMetadata(engine::Context &ctx, const Slice &ns_key, 
HashMetadata *metadata);
+  rocksdb::Status getRawValue(engine::Context &ctx, const std::string 
&sub_key, std::string *value);
+  static rocksdb::Status decodeValue(const HashMetadata &metadata, Slice 
*value, uint64_t *expire = nullptr);
 
   friend struct FieldValueRetriever;
 };
diff --git a/tests/cppunit/config_test.cc b/tests/cppunit/config_test.cc
index 3c78a48f0..aff85b86d 100644
--- a/tests/cppunit/config_test.cc
+++ b/tests/cppunit/config_test.cc
@@ -61,6 +61,8 @@ TEST(Config, GetAndSet) {
       {"profiling-sample-record-threshold-ms", "50"},
       {"profiling-sample-commands", "get,set"},
       {"backup-dir", "test_dir/backup"},
+      {"hash-encoding-mode", "field-expiration"},
+      {"hash-length-mode", "approximate"},
 
       {"rocksdb.compression", "no"},
       {"rocksdb.max_open_files", "1234"},
diff --git a/tests/cppunit/metadata_test.cc b/tests/cppunit/metadata_test.cc
index ffbaa88ba..dfd8a43bb 100644
--- a/tests/cppunit/metadata_test.cc
+++ b/tests/cppunit/metadata_test.cc
@@ -66,6 +66,63 @@ TEST(Metadata, EncodeAndDecode) {
   ASSERT_EQ(list_md, list_md1);
 }
 
+TEST(HashMetadata, DecodeLegacyMetadataWithoutExtensions) {
+  Metadata legacy_md(kRedisHash, false);
+  legacy_md.expire = 123000;
+  legacy_md.version = 99;
+  legacy_md.size = 7;
+
+  std::string encoded_bytes;
+  legacy_md.Encode(&encoded_bytes);
+
+  HashMetadata decoded(false, HashSubkeyEncodingMode::kFieldExpiration);
+  ASSERT_TRUE(decoded.Decode(encoded_bytes).ok());
+  EXPECT_EQ(decoded.expire, legacy_md.expire);
+  EXPECT_EQ(decoded.version, legacy_md.version);
+  EXPECT_EQ(decoded.size, legacy_md.size);
+  EXPECT_EQ(decoded.mode, HashSubkeyEncodingMode::kLegacy);
+  EXPECT_EQ(decoded.expsz, 0);
+  EXPECT_EQ(decoded.lower, 0);
+  EXPECT_EQ(decoded.upper, 0);
+}
+
+TEST(HashMetadata, EncodeAndDecodeWithExtensions) {
+  HashMetadata metadata(false, HashSubkeyEncodingMode::kFieldExpiration);
+  metadata.expire = 123000;
+  metadata.version = 9;
+  metadata.size = 11;
+  metadata.expsz = 3;
+  metadata.lower = 1000;
+  metadata.upper = 2000;
+
+  std::string encoded_bytes;
+  metadata.Encode(&encoded_bytes);
+
+  HashMetadata decoded(false);
+  ASSERT_TRUE(decoded.Decode(encoded_bytes).ok());
+  EXPECT_EQ(decoded.expire, metadata.expire);
+  EXPECT_EQ(decoded.version, metadata.version);
+  EXPECT_EQ(decoded.size, metadata.size);
+  EXPECT_EQ(decoded.mode, HashSubkeyEncodingMode::kFieldExpiration);
+  EXPECT_EQ(decoded.expsz, metadata.expsz);
+  EXPECT_EQ(decoded.lower, metadata.lower);
+  EXPECT_EQ(decoded.upper, metadata.upper);
+}
+
+TEST(HashMetadata, EncodeAndDecodeSubkeyValueWithFieldExpirationMode) {
+  HashMetadata metadata(false, HashSubkeyEncodingMode::kFieldExpiration);
+  constexpr uint64_t expire = 123456789;
+
+  std::string encoded = metadata.EncodeSubkeyValue("value", expire);
+  EXPECT_EQ(encoded.size(), HashMetadata::kFieldExpirationPrefixSize + 
std::string("value").size());
+
+  Slice decoded_value(encoded);
+  uint64_t decoded_expire = 0;
+  ASSERT_TRUE(metadata.DecodeSubkeyValue(&decoded_value, 
&decoded_expire).ok());
+  EXPECT_EQ(decoded_value.ToStringView(), "value");
+  EXPECT_EQ(decoded_expire, expire);
+}
+
 class RedisTypeTest : public TestBase {
  public:
   RedisTypeTest() {
diff --git a/tests/cppunit/types/hash_test.cc b/tests/cppunit/types/hash_test.cc
index f4cca4c34..e939e5ad5 100644
--- a/tests/cppunit/types/hash_test.cc
+++ b/tests/cppunit/types/hash_test.cc
@@ -21,7 +21,11 @@
 #include <gtest/gtest.h>
 
 #include <algorithm>
+#include <cassert>
 #include <climits>
+#include <cstdint>
+#include <filesystem>
+#include <fstream>
 #include <memory>
 #include <random>
 #include <string>
@@ -45,6 +49,64 @@ class RedisHashTest : public TestBase {
   std::unique_ptr<redis::Hash> hash_;
 };
 
+class RedisHashFieldExpirationEncodingTest : public ::testing::Test {
+ public:
+  RedisHashFieldExpirationEncodingTest(const 
RedisHashFieldExpirationEncodingTest &) = delete;
+  RedisHashFieldExpirationEncodingTest &operator=(const 
RedisHashFieldExpirationEncodingTest &) = delete;
+
+ protected:
+  RedisHashFieldExpirationEncodingTest() {
+    const char *path = "test_hash_field_expiration.conf";
+    unlink(path);
+    std::ofstream output_file(path, std::ios::out);
+    output_file << "hash-encoding-mode field-expiration\n";
+    output_file.close();
+
+    auto s = config_.Load(CLIOptions(path));
+    assert(s.IsOK());
+    config_.db_dir = "testdb_hash_field_expiration";
+    config_.rocks_db.compression = rocksdb::CompressionType::kNoCompression;
+    config_.rocks_db.write_buffer_size = 1;
+    config_.rocks_db.block_size = 100;
+
+    storage_ = std::make_unique<engine::Storage>(&config_);
+    s = storage_->Open();
+    assert(s.IsOK());
+
+    ctx_ = std::make_unique<engine::Context>(storage_.get());
+    hash_ = std::make_unique<redis::Hash>(storage_.get(), "hash_ns");
+    db_ = std::make_unique<redis::Database>(storage_.get(), "hash_ns");
+  }
+
+  ~RedisHashFieldExpirationEncodingTest() override {
+    ctx_.reset();
+    hash_.reset();
+    db_.reset();
+    storage_.reset();
+
+    std::error_code ec;
+    std::filesystem::remove_all(config_.db_dir, ec);
+    unlink("test_hash_field_expiration.conf");
+  }
+
+  std::string rawHashValue(const std::string &key, const std::string &field, 
HashMetadata *metadata) {
+    std::string ns_key = db_->AppendNamespacePrefix(key);
+    auto s = db_->GetMetadata(*ctx_, {kRedisHash}, ns_key, metadata);
+    assert(s.ok());
+    std::string sub_key = InternalKey(ns_key, field, metadata->version, 
storage_->IsSlotIdEncoded()).Encode();
+    std::string raw_value;
+    s = storage_->Get(*ctx_, ctx_->GetReadOptions(), sub_key, &raw_value);
+    assert(s.ok());
+    return raw_value;
+  }
+
+  Config config_;
+  std::unique_ptr<engine::Storage> storage_;
+  std::unique_ptr<engine::Context> ctx_;
+  std::unique_ptr<redis::Hash> hash_;
+  std::unique_ptr<redis::Database> db_;
+};
+
 TEST_F(RedisHashTest, GetAndSet) {
   uint64_t ret = 0;
   for (size_t i = 0; i < fields_.size(); i++) {
@@ -176,6 +238,48 @@ TEST_F(RedisHashTest, HGetAll) {
   s = hash_->Del(*ctx_, key_);
 }
 
+TEST_F(RedisHashFieldExpirationEncodingTest, 
StoreAndScanValuesWithModeOneEncoding) {
+  const Slice key = "mode-one-hash";
+  const Slice field = "field-1";
+  const Slice value = "value-1";
+
+  uint64_t added = 0;
+  auto s = hash_->Set(*ctx_, key, field, value, &added);
+  ASSERT_TRUE(s.ok());
+  ASSERT_EQ(added, 1);
+
+  HashMetadata metadata(false);
+  std::string raw_value = rawHashValue(key.ToString(), field.ToString(), 
&metadata);
+  EXPECT_EQ(metadata.mode, HashSubkeyEncodingMode::kFieldExpiration);
+  EXPECT_EQ(metadata.expsz, 0);
+  EXPECT_EQ(raw_value.size(), HashMetadata::kFieldExpirationPrefixSize + 
value.size());
+
+  Slice decoded_value(raw_value);
+  uint64_t field_expire = UINT64_MAX;
+  ASSERT_TRUE(metadata.DecodeSubkeyValue(&decoded_value, &field_expire).ok());
+  EXPECT_EQ(decoded_value.ToStringView(), value.ToStringView());
+  EXPECT_EQ(field_expire, 0);
+
+  std::string got;
+  s = hash_->Get(*ctx_, key, field, &got);
+  ASSERT_TRUE(s.ok());
+  EXPECT_EQ(got, value.ToString());
+
+  std::vector<std::string> fields;
+  std::vector<std::string> values;
+  s = hash_->Scan(*ctx_, key, "", 10, "", &fields, &values);
+  ASSERT_TRUE(s.ok());
+  ASSERT_EQ(fields, std::vector<std::string>({"field-1"}));
+  ASSERT_EQ(values, std::vector<std::string>({"value-1"}));
+
+  std::vector<FieldValue> field_values;
+  s = hash_->GetAll(*ctx_, key, &field_values);
+  ASSERT_TRUE(s.ok());
+  ASSERT_EQ(field_values.size(), 1);
+  EXPECT_EQ(field_values[0].field, "field-1");
+  EXPECT_EQ(field_values[0].value, "value-1");
+}
+
 TEST_F(RedisHashTest, HIncr) {
   int64_t value = 0;
   Slice field("hash-incrby-invalid-field");
diff --git a/tests/gocase/unit/config/config_test.go 
b/tests/gocase/unit/config/config_test.go
index b9e087e9b..d80218344 100644
--- a/tests/gocase/unit/config/config_test.go
+++ b/tests/gocase/unit/config/config_test.go
@@ -308,27 +308,91 @@ func TestGetConfigTxnContext(t *testing.T) {
 
 func TestGenerateConfigsMatrix(t *testing.T) {
        t.Parallel()
-       configOptions := []util.ConfigOptions{
-               {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
-               },
-               {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
-               },
-       }
+       t.Run("Generate one by one matrix", func(t *testing.T) {
+               configOptions := []util.ConfigOptions{
+                       {
+                               Name:    "resp3-enabled",
+                               Options: []string{"yes"},
+                       },
+               }
 
-       configsMatrix, err := util.GenerateConfigsMatrix(configOptions)
+               configsMatrix, err := util.GenerateConfigsMatrix(configOptions)
 
-       require.NoError(t, err)
-       require.Equal(t, 4, len(configsMatrix))
-       require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"txn-context-enabled": "yes", "resp3-enabled": "yes"})
-       require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"txn-context-enabled": "yes", "resp3-enabled": "no"})
-       require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"txn-context-enabled": "no", "resp3-enabled": "yes"})
-       require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"txn-context-enabled": "no", "resp3-enabled": "no"})
+               require.NoError(t, err)
+               require.Equal(t, []util.KvrocksServerConfigs{
+                       {"resp3-enabled": "yes"},
+               }, configsMatrix)
+       })
+
+       t.Run("Generate one by two matrix", func(t *testing.T) {
+               configOptions := []util.ConfigOptions{
+                       {
+                               Name:    "txn-context-enabled",
+                               Options: []string{"yes"},
+                       },
+                       {
+                               Name:    "resp3-enabled",
+                               Options: []string{"yes", "no"},
+                       },
+               }
+
+               configsMatrix, err := util.GenerateConfigsMatrix(configOptions)
+
+               require.NoError(t, err)
+               require.Equal(t, []util.KvrocksServerConfigs{
+                       {"txn-context-enabled": "yes", "resp3-enabled": "yes"},
+                       {"txn-context-enabled": "yes", "resp3-enabled": "no"},
+               }, configsMatrix)
+       })
+
+       t.Run("Generate two by two matrix", func(t *testing.T) {
+               configOptions := []util.ConfigOptions{
+                       {
+                               Name:    "txn-context-enabled",
+                               Options: []string{"yes", "no"},
+                       },
+                       {
+                               Name:    "resp3-enabled",
+                               Options: []string{"yes", "no"},
+                       },
+               }
+
+               configsMatrix, err := util.GenerateConfigsMatrix(configOptions)
+
+               require.NoError(t, err)
+               require.Equal(t, 4, len(configsMatrix))
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"txn-context-enabled": "yes", "resp3-enabled": "yes"})
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"txn-context-enabled": "yes", "resp3-enabled": "no"})
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"txn-context-enabled": "no", "resp3-enabled": "yes"})
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"txn-context-enabled": "no", "resp3-enabled": "no"})
+       })
+
+       t.Run("Generate three by three matrix without reusing result maps", 
func(t *testing.T) {
+               configOptions := []util.ConfigOptions{
+                       {
+                               Name:    "a",
+                               Options: []string{"1", "2", "3"},
+                       },
+                       {
+                               Name:    "b",
+                               Options: []string{"x", "y", "z"},
+                       },
+               }
+
+               configsMatrix, err := util.GenerateConfigsMatrix(configOptions)
+
+               require.NoError(t, err)
+               require.Len(t, configsMatrix, 9)
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"a": "1", "b": "x"})
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"a": "1", "b": "y"})
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"a": "1", "b": "z"})
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"a": "2", "b": "x"})
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"a": "2", "b": "y"})
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"a": "2", "b": "z"})
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"a": "3", "b": "x"})
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"a": "3", "b": "y"})
+               require.Contains(t, configsMatrix, 
util.KvrocksServerConfigs{"a": "3", "b": "z"})
+       })
 }
 
 func TestGetConfigSkipBlockCacheDeallocationOnClose(t *testing.T) {
diff --git a/tests/gocase/unit/geo/geo_test.go 
b/tests/gocase/unit/geo/geo_test.go
index d3d8f0b1b..7113336f8 100644
--- a/tests/gocase/unit/geo/geo_test.go
+++ b/tests/gocase/unit/geo/geo_test.go
@@ -100,14 +100,12 @@ var geoAddAndGeoRangeservers []*util.KvrocksServer
 func TestGeo(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
                {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/kmetadata/kmetadata_test.go 
b/tests/gocase/unit/kmetadata/kmetadata_test.go
index 87e5bdfdf..7960c4b9b 100644
--- a/tests/gocase/unit/kmetadata/kmetadata_test.go
+++ b/tests/gocase/unit/kmetadata/kmetadata_test.go
@@ -90,9 +90,8 @@ func ExtractKMetadataResponse(result interface{}) 
(*kMetadataResponse, error) {
 func TestKMetadata(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes"},
-                       ConfigType: util.YesNo,
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes"},
                },
        }
        configsMatrix, err := util.GenerateConfigsMatrix(configOptions)
diff --git a/tests/gocase/unit/pubsub/pubsub_test.go 
b/tests/gocase/unit/pubsub/pubsub_test.go
index 6c398757e..e9bc34b95 100644
--- a/tests/gocase/unit/pubsub/pubsub_test.go
+++ b/tests/gocase/unit/pubsub/pubsub_test.go
@@ -39,14 +39,12 @@ func receiveType[T any](t *testing.T, pubsub *redis.PubSub, 
typ T) T {
 func TestPubSub(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
                {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/scripting/function_test.go 
b/tests/gocase/unit/scripting/function_test.go
index 5d307b488..c505a7521 100644
--- a/tests/gocase/unit/scripting/function_test.go
+++ b/tests/gocase/unit/scripting/function_test.go
@@ -100,14 +100,12 @@ func decodeListLibResult(t *testing.T, v interface{}) 
ListLibResult {
 func TestFunctions(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
                {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/type/bloom/bloom_test.go 
b/tests/gocase/unit/type/bloom/bloom_test.go
index 13009afd4..d6ac6af08 100644
--- a/tests/gocase/unit/type/bloom/bloom_test.go
+++ b/tests/gocase/unit/type/bloom/bloom_test.go
@@ -32,9 +32,8 @@ import (
 func TestBloomInfo(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/type/hash/hash_test.go 
b/tests/gocase/unit/type/hash/hash_test.go
index 1c972fa86..d5f23a3b7 100644
--- a/tests/gocase/unit/type/hash/hash_test.go
+++ b/tests/gocase/unit/type/hash/hash_test.go
@@ -52,17 +52,35 @@ func getVals(hash map[string]string) []string {
        return r
 }
 
+func runWithHashConfigs(t *testing.T, configOptions []util.ConfigOptions,
+       fn func(t *testing.T, configs util.KvrocksServerConfigs)) {
+       t.Helper()
+
+       configOptions = append(configOptions, util.ConfigOptions{
+               Name:    "hash-encoding-mode",
+               Options: []string{"legacy", "field-expiration"},
+       })
+       configsMatrix, err := util.GenerateConfigsMatrix(configOptions)
+       require.NoError(t, err)
+
+       for _, configs := range configsMatrix {
+               fn(t, configs)
+       }
+}
+
 func TestHash(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
+               },
+               {
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes", "no"},
                },
                {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "hash-encoding-mode",
+                       Options: []string{"legacy", "field-expiration"},
                },
        }
 
@@ -993,107 +1011,122 @@ var testHash = func(t *testing.T, configs 
util.KvrocksServerConfigs) {
 }
 
 func TestHGetAllWithRESP3(t *testing.T) {
-       srv := util.StartServer(t, map[string]string{
-               "resp3-enabled": "yes",
-       })
-       defer srv.Close()
+       runWithHashConfigs(t, []util.ConfigOptions{
+               {
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes"},
+               },
+       }, func(t *testing.T, configs util.KvrocksServerConfigs) {
+               srv := util.StartServer(t, configs)
+               defer srv.Close()
 
-       rdb := srv.NewClient()
-       defer func() { require.NoError(t, rdb.Close()) }()
+               rdb := srv.NewClient()
+               defer func() { require.NoError(t, rdb.Close()) }()
 
-       ctx := context.Background()
+               ctx := context.Background()
 
-       testKey := "test-hash-1"
-       require.NoError(t, rdb.Del(ctx, testKey).Err())
-       require.NoError(t, rdb.HSet(ctx, testKey, "key1", "value1", "key2", 
"value2", "key3", "value3").Err())
-       result, err := rdb.HGetAll(ctx, testKey).Result()
-       require.NoError(t, err)
-       require.Len(t, result, 3)
-       require.EqualValues(t, map[string]string{
-               "key1": "value1",
-               "key2": "value2",
-               "key3": "value3",
-       }, result)
+               testKey := "test-hash-1"
+               require.NoError(t, rdb.Del(ctx, testKey).Err())
+               require.NoError(t, rdb.HSet(ctx, testKey, "key1", "value1", 
"key2", "value2", "key3", "value3").Err())
+               result, err := rdb.HGetAll(ctx, testKey).Result()
+               require.NoError(t, err)
+               require.Len(t, result, 3)
+               require.EqualValues(t, map[string]string{
+                       "key1": "value1",
+                       "key2": "value2",
+                       "key3": "value3",
+               }, result)
+       })
 }
 
 func TestHashWithAsyncIOEnabled(t *testing.T) {
-       srv := util.StartServer(t, map[string]string{
-               "rocksdb.read_options.async_io": "yes",
-       })
-       defer srv.Close()
+       runWithHashConfigs(t, []util.ConfigOptions{
+               {
+                       Name:    "rocksdb.read_options.async_io",
+                       Options: []string{"yes"},
+               },
+       }, func(t *testing.T, configs util.KvrocksServerConfigs) {
+               srv := util.StartServer(t, configs)
+               defer srv.Close()
 
-       rdb := srv.NewClient()
-       defer func() { require.NoError(t, rdb.Close()) }()
+               rdb := srv.NewClient()
+               defer func() { require.NoError(t, rdb.Close()) }()
 
-       ctx := context.Background()
+               ctx := context.Background()
 
-       t.Run("Test bug with large value after compaction", func(t *testing.T) {
-               testKey := "test-hash-1"
-               require.NoError(t, rdb.Del(ctx, testKey).Err())
+               t.Run("Test bug with large value after compaction", func(t 
*testing.T) {
+                       testKey := "test-hash-1"
+                       require.NoError(t, rdb.Del(ctx, testKey).Err())
 
-               src := rand.NewSource(time.Now().UnixNano())
-               dd := make([]byte, 5000)
-               for i := 1; i <= 50; i++ {
-                       for j := range dd {
-                               dd[j] = byte(src.Int63())
+                       src := rand.NewSource(time.Now().UnixNano())
+                       dd := make([]byte, 5000)
+                       for i := 1; i <= 50; i++ {
+                               for j := range dd {
+                                       dd[j] = byte(src.Int63())
+                               }
+                               key := util.RandString(10, 20, util.Alpha)
+                               require.NoError(t, rdb.HSet(ctx, testKey, key, 
string(dd)).Err())
                        }
-                       key := util.RandString(10, 20, util.Alpha)
-                       require.NoError(t, rdb.HSet(ctx, testKey, key, 
string(dd)).Err())
-               }
 
-               require.EqualValues(t, 50, rdb.HLen(ctx, testKey).Val())
-               require.Len(t, rdb.HGetAll(ctx, testKey).Val(), 50)
-               require.Len(t, rdb.HKeys(ctx, testKey).Val(), 50)
-               require.Len(t, rdb.HVals(ctx, testKey).Val(), 50)
+                       require.EqualValues(t, 50, rdb.HLen(ctx, testKey).Val())
+                       require.Len(t, rdb.HGetAll(ctx, testKey).Val(), 50)
+                       require.Len(t, rdb.HKeys(ctx, testKey).Val(), 50)
+                       require.Len(t, rdb.HVals(ctx, testKey).Val(), 50)
 
-               require.NoError(t, rdb.Do(ctx, "COMPACT").Err())
+                       require.NoError(t, rdb.Do(ctx, "COMPACT").Err())
 
-               time.Sleep(5 * time.Second)
+                       time.Sleep(5 * time.Second)
 
-               require.EqualValues(t, 50, rdb.HLen(ctx, testKey).Val())
-               require.Len(t, rdb.HGetAll(ctx, testKey).Val(), 50)
-               require.Len(t, rdb.HKeys(ctx, testKey).Val(), 50)
-               require.Len(t, rdb.HVals(ctx, testKey).Val(), 50)
+                       require.EqualValues(t, 50, rdb.HLen(ctx, testKey).Val())
+                       require.Len(t, rdb.HGetAll(ctx, testKey).Val(), 50)
+                       require.Len(t, rdb.HKeys(ctx, testKey).Val(), 50)
+                       require.Len(t, rdb.HVals(ctx, testKey).Val(), 50)
+               })
        })
 }
 
 func TestHashWithAsyncIODisabled(t *testing.T) {
-       srv := util.StartServer(t, map[string]string{
-               "rocksdb.read_options.async_io": "no",
-       })
-       defer srv.Close()
+       runWithHashConfigs(t, []util.ConfigOptions{
+               {
+                       Name:    "rocksdb.read_options.async_io",
+                       Options: []string{"no"},
+               },
+       }, func(t *testing.T, configs util.KvrocksServerConfigs) {
+               srv := util.StartServer(t, configs)
+               defer srv.Close()
 
-       rdb := srv.NewClient()
-       defer func() { require.NoError(t, rdb.Close()) }()
+               rdb := srv.NewClient()
+               defer func() { require.NoError(t, rdb.Close()) }()
 
-       ctx := context.Background()
+               ctx := context.Background()
 
-       t.Run("Test bug with large value after compaction", func(t *testing.T) {
-               testKey := "test-hash-1"
-               require.NoError(t, rdb.Del(ctx, testKey).Err())
+               t.Run("Test bug with large value after compaction", func(t 
*testing.T) {
+                       testKey := "test-hash-1"
+                       require.NoError(t, rdb.Del(ctx, testKey).Err())
 
-               src := rand.NewSource(time.Now().UnixNano())
-               dd := make([]byte, 5000)
-               for i := 1; i <= 50; i++ {
-                       for j := range dd {
-                               dd[j] = byte(src.Int63())
+                       src := rand.NewSource(time.Now().UnixNano())
+                       dd := make([]byte, 5000)
+                       for i := 1; i <= 50; i++ {
+                               for j := range dd {
+                                       dd[j] = byte(src.Int63())
+                               }
+                               key := util.RandString(10, 20, util.Alpha)
+                               require.NoError(t, rdb.HSet(ctx, testKey, key, 
string(dd)).Err())
                        }
-                       key := util.RandString(10, 20, util.Alpha)
-                       require.NoError(t, rdb.HSet(ctx, testKey, key, 
string(dd)).Err())
-               }
 
-               require.EqualValues(t, 50, rdb.HLen(ctx, testKey).Val())
-               require.Len(t, rdb.HGetAll(ctx, testKey).Val(), 50)
-               require.Len(t, rdb.HKeys(ctx, testKey).Val(), 50)
-               require.Len(t, rdb.HVals(ctx, testKey).Val(), 50)
+                       require.EqualValues(t, 50, rdb.HLen(ctx, testKey).Val())
+                       require.Len(t, rdb.HGetAll(ctx, testKey).Val(), 50)
+                       require.Len(t, rdb.HKeys(ctx, testKey).Val(), 50)
+                       require.Len(t, rdb.HVals(ctx, testKey).Val(), 50)
 
-               require.NoError(t, rdb.Do(ctx, "COMPACT").Err())
+                       require.NoError(t, rdb.Do(ctx, "COMPACT").Err())
 
-               time.Sleep(5 * time.Second)
+                       time.Sleep(5 * time.Second)
 
-               require.EqualValues(t, 50, rdb.HLen(ctx, testKey).Val())
-               require.Len(t, rdb.HGetAll(ctx, testKey).Val(), 50)
-               require.Len(t, rdb.HKeys(ctx, testKey).Val(), 50)
-               require.Len(t, rdb.HVals(ctx, testKey).Val(), 50)
+                       require.EqualValues(t, 50, rdb.HLen(ctx, testKey).Val())
+                       require.Len(t, rdb.HGetAll(ctx, testKey).Val(), 50)
+                       require.Len(t, rdb.HKeys(ctx, testKey).Val(), 50)
+                       require.Len(t, rdb.HVals(ctx, testKey).Val(), 50)
+               })
        })
 }
diff --git a/tests/gocase/unit/type/incr/incr_test.go 
b/tests/gocase/unit/type/incr/incr_test.go
index 99a0c6b6b..487a4f398 100644
--- a/tests/gocase/unit/type/incr/incr_test.go
+++ b/tests/gocase/unit/type/incr/incr_test.go
@@ -31,9 +31,8 @@ import (
 func TestIncr(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/type/json/json_test.go 
b/tests/gocase/unit/type/json/json_test.go
index a233aaf97..9dddaa539 100644
--- a/tests/gocase/unit/type/json/json_test.go
+++ b/tests/gocase/unit/type/json/json_test.go
@@ -32,9 +32,8 @@ import (
 func TestJson(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/type/list/list_test.go 
b/tests/gocase/unit/type/list/list_test.go
index 084547883..2fe9f36dd 100644
--- a/tests/gocase/unit/type/list/list_test.go
+++ b/tests/gocase/unit/type/list/list_test.go
@@ -45,9 +45,8 @@ var largeValue = map[string]string{
 func TestLTRIM(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
@@ -105,9 +104,8 @@ func testLTRIM(t *testing.T, configs 
util.KvrocksServerConfigs) {
 func TestZipList(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
@@ -270,14 +268,12 @@ func testZipList(t *testing.T, configs 
util.KvrocksServerConfigs) {
 func TestList(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
                {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
@@ -1576,9 +1572,8 @@ func ExtractKMetadataResponse(result interface{}) 
(*kMetadataResponse, error) {
 func TestRPOPLPUSH(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes"},
-                       ConfigType: util.YesNo,
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes"},
                },
        }
 
diff --git a/tests/gocase/unit/type/set/set_test.go 
b/tests/gocase/unit/type/set/set_test.go
index bb20d4b55..cb6670bfe 100644
--- a/tests/gocase/unit/type/set/set_test.go
+++ b/tests/gocase/unit/type/set/set_test.go
@@ -59,14 +59,12 @@ func GetArrayUnion(arrays ...[]string) []string {
 func TestSet(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
                {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/type/sint/sint_test.go 
b/tests/gocase/unit/type/sint/sint_test.go
index 4b4f0cb35..0b953b6bc 100644
--- a/tests/gocase/unit/type/sint/sint_test.go
+++ b/tests/gocase/unit/type/sint/sint_test.go
@@ -30,9 +30,8 @@ import (
 func TestString(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/type/stream/stream_test.go 
b/tests/gocase/unit/type/stream/stream_test.go
index 14d3d361d..df065ebf9 100644
--- a/tests/gocase/unit/type/stream/stream_test.go
+++ b/tests/gocase/unit/type/stream/stream_test.go
@@ -38,14 +38,12 @@ import (
 func TestStream(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
                {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/type/strings/strings_test.go 
b/tests/gocase/unit/type/strings/strings_test.go
index 3c2515210..8f6648a51 100644
--- a/tests/gocase/unit/type/strings/strings_test.go
+++ b/tests/gocase/unit/type/strings/strings_test.go
@@ -37,9 +37,8 @@ import (
 func TestString(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/type/tdigest/tdigest_test.go 
b/tests/gocase/unit/type/tdigest/tdigest_test.go
index 6c58d8402..48721b24b 100644
--- a/tests/gocase/unit/type/tdigest/tdigest_test.go
+++ b/tests/gocase/unit/type/tdigest/tdigest_test.go
@@ -77,9 +77,8 @@ func toTdigestInfo(t *testing.T, value interface{}) 
tdigestInfo {
 func TestTDigest(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
@@ -839,14 +838,12 @@ func tdigestTests(t *testing.T, configs 
util.KvrocksServerConfigs) {
 func TestTDigestByRankAndByRevRank(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
                {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/type/timeseries/timeseries_test.go 
b/tests/gocase/unit/type/timeseries/timeseries_test.go
index a4763c099..1fd08c2aa 100644
--- a/tests/gocase/unit/type/timeseries/timeseries_test.go
+++ b/tests/gocase/unit/type/timeseries/timeseries_test.go
@@ -34,9 +34,8 @@ import (
 func TestTimeSeries(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/unit/type/zset/zset_test.go 
b/tests/gocase/unit/type/zset/zset_test.go
index 9d3e125fa..0f9fa6f44 100644
--- a/tests/gocase/unit/type/zset/zset_test.go
+++ b/tests/gocase/unit/type/zset/zset_test.go
@@ -1940,14 +1940,12 @@ func stressTests(t *testing.T, rdb *redis.Client, ctx 
context.Context, encoding
 func TestZSet(t *testing.T) {
        configOptions := []util.ConfigOptions{
                {
-                       Name:       "txn-context-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "txn-context-enabled",
+                       Options: []string{"yes", "no"},
                },
                {
-                       Name:       "resp3-enabled",
-                       Options:    []string{"yes", "no"},
-                       ConfigType: util.YesNo,
+                       Name:    "resp3-enabled",
+                       Options: []string{"yes", "no"},
                },
        }
 
diff --git a/tests/gocase/util/configs.go b/tests/gocase/util/configs.go
index c575f25a8..9dc37e354 100644
--- a/tests/gocase/util/configs.go
+++ b/tests/gocase/util/configs.go
@@ -19,35 +19,13 @@
 
 package util
 
-import "fmt"
-
-type FieldType int
-
-const (
-       YesNo FieldType = iota
-)
-
 type ConfigOptions struct {
-       Name       string
-       Options    []string
-       ConfigType FieldType
+       Name    string
+       Options []string
 }
 
 type KvrocksServerConfigs map[string]string
 
-func verifyConfigOptions(configType FieldType, option string) error {
-       switch configType {
-       case YesNo:
-               if option == "yes" || option == "no" {
-                       break
-               }
-               return fmt.Errorf("invalid option for yes/no config")
-       default:
-               return fmt.Errorf("unsupported config type")
-       }
-       return nil
-}
-
 // / GenerateConfigsMatrix generates all possible combinations of config 
options
 func GenerateConfigsMatrix(configOptions []ConfigOptions) 
([]KvrocksServerConfigs, error) {
        configsMatrix := make([]KvrocksServerConfigs, 0)
@@ -56,27 +34,29 @@ func GenerateConfigsMatrix(configOptions []ConfigOptions) 
([]KvrocksServerConfig
 
        helper = func(configs []ConfigOptions, currentIndex int, currentConfig 
map[string]string) error {
                if currentIndex == len(configOptions) {
-                       configsMatrix = append(configsMatrix, currentConfig)
+                       config := make(KvrocksServerConfigs, len(currentConfig))
+                       for k, v := range currentConfig {
+                               config[k] = v
+                       }
+                       configsMatrix = append(configsMatrix, config)
                        return nil
                }
 
-               currentConfigBackup := make(KvrocksServerConfigs, 
len(currentConfig))
-               for k, v := range currentConfig {
-                       currentConfigBackup[k] = v
-               }
+               configName := configs[currentIndex].Name
+               originalValue, hadOriginalValue := currentConfig[configName]
 
                for _, option := range configs[currentIndex].Options {
-                       err := 
verifyConfigOptions(configs[currentIndex].ConfigType, option)
+                       currentConfig[configName] = option
+                       err := helper(configs, currentIndex+1, currentConfig)
                        if err != nil {
                                return err
                        }
+               }
 
-                       currentConfig[configs[currentIndex].Name] = option
-                       err = helper(configs, currentIndex+1, currentConfig)
-                       if err != nil {
-                               return err
-                       }
-                       currentConfig = currentConfigBackup
+               if hadOriginalValue {
+                       currentConfig[configName] = originalValue
+               } else {
+                       delete(currentConfig, configName)
                }
 
                return nil

Reply via email to