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

PragmaTwice pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks.git


The following commit(s) were added to refs/heads/unstable by this push:
     new cfeab096c feat(hash): add HSETEX and HGETEX support (#3554)
cfeab096c is described below

commit cfeab096c8795ebb0c3df229bd7b2707cf49c900
Author: Twice <[email protected]>
AuthorDate: Sat Jul 18 14:49:22 2026 +0800

    feat(hash): add HSETEX and HGETEX support (#3554)
    
    Adds Redis-compatible HSETEX and HGETEX commands on top of the hash
    field expiration encoding. Implements batched field/value and metadata
    transitions for persistent, expiring, expired, missing, and ghost
    fields, including FXX/FNX, KEEPTTL, PERSIST, and legacy-encoding
    rejection. Adds C++ unit and Go integration coverage for metadata
    invariants, command parsing, replication, restart, and concurrency.
    
    Proposal: https://github.com/apache/kvrocks/discussions/3432
    Tracking issue: https://github.com/apache/kvrocks/issues/3436
    
    Assisted by GPT 5.5 Sol.
---
 src/commands/cmd_hash.cc                           |  278 ++++++
 src/types/redis_hash.cc                            |  450 +++++++++
 src/types/redis_hash.h                             |   36 +
 tests/cppunit/types/hash_test.cc                   | 1021 ++++++++++++++++++++
 .../hash_field_expiration_commands_test.go         |  115 +++
 .../unit/type/hash/hash_hsetex_hgetex_test.go      |  796 +++++++++++++++
 6 files changed, 2696 insertions(+)

diff --git a/src/commands/cmd_hash.cc b/src/commands/cmd_hash.cc
index c56aeae63..05d074e55 100644
--- a/src/commands/cmd_hash.cc
+++ b/src/commands/cmd_hash.cc
@@ -42,6 +42,27 @@ enum class HashFieldExpireTimeMode {
   kAbsoluteMilliseconds,
 };
 
+enum class HashFieldExpireCommandTTLAction {
+  kNone,
+  kKeep,
+  kPersist,
+  kSet,
+};
+
+enum class HashFieldSetCommandCondition {
+  kNone,
+  kFNX,
+  kFXX,
+};
+
+struct HashFieldExpireCommandArgs {
+  HashFieldExpireCommandTTLAction ttl_action = 
HashFieldExpireCommandTTLAction::kNone;
+  HashFieldSetCommandCondition condition = HashFieldSetCommandCondition::kNone;
+  uint64_t expire_at_ms = 0;
+  std::vector<std::string> fields;
+  std::vector<FieldValue> field_values;
+};
+
 std::vector<Slice> ToSlices(const std::vector<std::string> &values) {
   std::vector<Slice> slices;
   slices.reserve(values.size());
@@ -102,6 +123,164 @@ Status ConvertHashFieldExpireAtMs(int64_t expire_arg, 
HashFieldExpireTimeMode ti
   return Status::OK();
 }
 
+template <typename Parser>
+std::optional<HashFieldExpireTimeMode> ParseHashFieldExpireTimeMode(Parser 
&parser) {
+  if (parser.EatEqICase("EX")) return 
HashFieldExpireTimeMode::kRelativeSeconds;
+  if (parser.EatEqICase("PX")) return 
HashFieldExpireTimeMode::kRelativeMilliseconds;
+  if (parser.EatEqICase("EXAT")) return 
HashFieldExpireTimeMode::kAbsoluteSeconds;
+  if (parser.EatEqICase("PXAT")) return 
HashFieldExpireTimeMode::kAbsoluteMilliseconds;
+  return std::nullopt;
+}
+
+template <typename Parser>
+Status TakeHashFieldExpireArgument(Parser &parser, int64_t *expire_arg) {
+  auto parsed = parser.template TakeInt<int64_t>(10);
+  if (!parsed) {
+    return {Status::RedisParseErr, errValueNotInteger};
+  }
+  if (*parsed < 0) {
+    return {Status::RedisParseErr, "invalid expire time, must be >= 0"};
+  }
+  *expire_arg = *parsed;
+  return Status::OK();
+}
+
+Status ConvertHashFieldExpireAtMsForCommand(int64_t expire_arg, 
HashFieldExpireTimeMode time_mode, uint64_t now_ms,
+                                            uint64_t *expire_at_ms) {
+  bool relative = time_mode == HashFieldExpireTimeMode::kRelativeSeconds ||
+                  time_mode == HashFieldExpireTimeMode::kRelativeMilliseconds;
+  if ((!relative || now_ms <= kMaxHashFieldExpireAtMs) &&
+      ConvertHashFieldExpireAtMs(expire_arg, time_mode, now_ms, 
expire_at_ms).IsOK()) {
+    return Status::OK();
+  }
+
+  return {Status::RedisParseErr, "invalid expire time"};
+}
+
+template <bool IsHSetEx, typename Parser>
+Status ParseHashFieldExpireFieldsBlock(Parser &parser, 
HashFieldExpireCommandArgs *parsed) {
+  if (!parser.Good()) {
+    return {Status::RedisParseErr, errWrongNumOfArguments};
+  }
+
+  auto count = parser.template TakeInt<int64_t>(10);
+  if (!count || *count < 1 || *count > std::numeric_limits<int>::max()) {
+    return {Status::RedisParseErr, "invalid number of fields"};
+  }
+
+  constexpr size_t args_per_field = IsHSetEx ? 2 : 1;
+  auto field_count = static_cast<size_t>(*count);
+  if (field_count > parser.Remains() / args_per_field) {
+    return {Status::RedisParseErr, errWrongNumOfArguments};
+  }
+
+  if constexpr (IsHSetEx) {
+    parsed->field_values.reserve(field_count);
+    for (size_t i = 0; i < field_count; ++i) {
+      auto field = GET_OR_RET(parser.TakeStr());
+      auto value = GET_OR_RET(parser.TakeStr());
+      parsed->field_values.emplace_back(std::move(field), std::move(value));
+    }
+  } else {
+    parsed->fields.reserve(field_count);
+    for (size_t i = 0; i < field_count; ++i) {
+      parsed->fields.emplace_back(GET_OR_RET(parser.TakeStr()));
+    }
+  }
+
+  return Status::OK();
+}
+
+template <bool IsHSetEx>
+Status HashFieldExpireTTLConflict() {
+  auto option = IsHSetEx ? "KEEPTTL" : "PERSIST";
+  return {Status::RedisParseErr,
+          "Only one of EX, PX, EXAT, PXAT or " + std::string(option) + " 
arguments can be specified"};
+}
+
+template <bool IsHSetEx>
+Status ParseHashFieldExpireCommandArgs(const std::vector<std::string> &args, 
uint64_t now_ms,
+                                       HashFieldExpireCommandArgs *result) {
+  CommandParser parser(args, 2);
+  HashFieldExpireCommandArgs parsed;
+  bool fields_seen = false;
+
+  while (parser.Good()) {
+    if (parser.EatEqICase("FIELDS")) {
+      if (fields_seen) {
+        return {Status::RedisParseErr, "FIELDS keyword specified multiple 
times"};
+      }
+      fields_seen = true;
+      GET_OR_RET(ParseHashFieldExpireFieldsBlock<IsHSetEx>(parser, &parsed));
+      continue;
+    }
+
+    auto time_mode = ParseHashFieldExpireTimeMode(parser);
+    if (time_mode) {
+      if (parsed.ttl_action != HashFieldExpireCommandTTLAction::kNone) {
+        return HashFieldExpireTTLConflict<IsHSetEx>();
+      }
+      if (!parser.Good()) {
+        return {Status::RedisParseErr, "missing expire time"};
+      }
+
+      int64_t expire_arg = 0;
+      GET_OR_RET(TakeHashFieldExpireArgument(parser, &expire_arg));
+      GET_OR_RET(ConvertHashFieldExpireAtMsForCommand(expire_arg, *time_mode, 
now_ms, &parsed.expire_at_ms));
+      parsed.ttl_action = HashFieldExpireCommandTTLAction::kSet;
+      continue;
+    }
+
+    if constexpr (IsHSetEx) {
+      if (parser.EatEqICase("KEEPTTL")) {
+        if (parsed.ttl_action != HashFieldExpireCommandTTLAction::kNone) {
+          return HashFieldExpireTTLConflict<true>();
+        }
+        parsed.ttl_action = HashFieldExpireCommandTTLAction::kKeep;
+        continue;
+      }
+
+      HashFieldSetCommandCondition condition = 
HashFieldSetCommandCondition::kNone;
+      if (parser.EatEqICase("FXX")) {
+        condition = HashFieldSetCommandCondition::kFXX;
+      } else if (parser.EatEqICase("FNX")) {
+        condition = HashFieldSetCommandCondition::kFNX;
+      }
+      if (condition != HashFieldSetCommandCondition::kNone) {
+        if (parsed.condition != HashFieldSetCommandCondition::kNone) {
+          return {Status::RedisParseErr, "Only one of FXX or FNX arguments can 
be specified"};
+        }
+        parsed.condition = condition;
+        continue;
+      }
+    } else {
+      if (parser.EatEqICase("PERSIST")) {
+        if (parsed.ttl_action != HashFieldExpireCommandTTLAction::kNone) {
+          return HashFieldExpireTTLConflict<false>();
+        }
+        parsed.ttl_action = HashFieldExpireCommandTTLAction::kPersist;
+        continue;
+      }
+    }
+
+    return {Status::RedisParseErr, "unknown argument: " + parser.RawPeek()};
+  }
+
+  if (!fields_seen) {
+    return {Status::RedisParseErr, "missing FIELDS argument"};
+  }
+
+  *result = std::move(parsed);
+  return Status::OK();
+}
+
+Status HashCommandStatus(const rocksdb::Status &status) {
+  if (status.IsInvalidArgument() && status.ToString().find("WRONGTYPE") != 
std::string::npos) {
+    return {Status::RedisWrongType, "Operation against a key holding the wrong 
kind of value"};
+  }
+  return {Status::RedisExecErr, status.ToString()};
+}
+
 std::optional<HashFieldExpireCondition> 
ParseHashExpireCondition(std::string_view token) {
   if (util::EqualICase(token, "NX")) return HashFieldExpireCondition::kNX;
   if (util::EqualICase(token, "XX")) return HashFieldExpireCondition::kXX;
@@ -704,6 +883,103 @@ class CommandHRandField : public Commander {
   bool no_parameters_ = true;
 };
 
+class CommandHSetEx : public Commander {
+ public:
+  Status Parse(const std::vector<std::string> &args) override {
+    now_ms_ = util::GetTimeStampMS();
+    GET_OR_RET(ParseHashFieldExpireCommandArgs<true>(args, now_ms_, &parsed_));
+    return Commander::Parse(args);
+  }
+
+  Status Execute(engine::Context &ctx, Server *srv, Connection *conn, 
std::string *output) override {
+    HashSetExOptions options;
+    switch (parsed_.condition) {
+      case HashFieldSetCommandCondition::kNone:
+        options.condition = HashFieldSetCondition::kNone;
+        break;
+      case HashFieldSetCommandCondition::kFNX:
+        options.condition = HashFieldSetCondition::kFNX;
+        break;
+      case HashFieldSetCommandCondition::kFXX:
+        options.condition = HashFieldSetCondition::kFXX;
+        break;
+    }
+
+    switch (parsed_.ttl_action) {
+      case HashFieldExpireCommandTTLAction::kNone:
+        options.ttl_action = HashSetExOptions::TTLAction::kDiscard;
+        break;
+      case HashFieldExpireCommandTTLAction::kKeep:
+        options.ttl_action = HashSetExOptions::TTLAction::kKeep;
+        break;
+      case HashFieldExpireCommandTTLAction::kSet:
+        options.ttl_action = HashSetExOptions::TTLAction::kSet;
+        options.expire_at_ms = parsed_.expire_at_ms;
+        break;
+      case HashFieldExpireCommandTTLAction::kPersist:
+        return {Status::RedisExecErr, errInvalidSyntax};
+    }
+
+    bool applied = false;
+    redis::Hash hash_db(srv->storage, conn->GetNamespace());
+    auto status = hash_db.SetFieldsWithExpire(ctx, args_[1], 
parsed_.field_values, options, &applied, now_ms_);
+    if (!status.ok()) return HashCommandStatus(status);
+
+    *output = redis::Integer(applied ? 1 : 0);
+    return Status::OK();
+  }
+
+ private:
+  uint64_t now_ms_ = 0;
+  HashFieldExpireCommandArgs parsed_;
+};
+
+class CommandHGetEx : public Commander {
+ public:
+  Status Parse(const std::vector<std::string> &args) override {
+    now_ms_ = util::GetTimeStampMS();
+    GET_OR_RET(ParseHashFieldExpireCommandArgs<false>(args, now_ms_, 
&parsed_));
+    return Commander::Parse(args);
+  }
+
+  Status Execute(engine::Context &ctx, Server *srv, Connection *conn, 
std::string *output) override {
+    HashGetExOptions options;
+    switch (parsed_.ttl_action) {
+      case HashFieldExpireCommandTTLAction::kNone:
+        options.ttl_action = HashGetExOptions::TTLAction::kNone;
+        break;
+      case HashFieldExpireCommandTTLAction::kPersist:
+        options.ttl_action = HashGetExOptions::TTLAction::kPersist;
+        break;
+      case HashFieldExpireCommandTTLAction::kSet:
+        options.ttl_action = HashGetExOptions::TTLAction::kSet;
+        options.expire_at_ms = parsed_.expire_at_ms;
+        break;
+      case HashFieldExpireCommandTTLAction::kKeep:
+        return {Status::RedisExecErr, errInvalidSyntax};
+    }
+
+    std::vector<std::string> values;
+    std::vector<rocksdb::Status> statuses;
+    auto fields = ToSlices(parsed_.fields);
+    redis::Hash hash_db(srv->storage, conn->GetNamespace());
+    auto status = hash_db.GetFieldsWithExpire(ctx, args_[1], fields, options, 
&values, &statuses, now_ms_);
+    if (status.IsNotFound()) {
+      values.resize(fields.size());
+      statuses.resize(fields.size(), rocksdb::Status::NotFound());
+    } else if (!status.ok()) {
+      return HashCommandStatus(status);
+    }
+
+    *output = conn->MultiBulkString(values, statuses);
+    return Status::OK();
+  }
+
+ private:
+  uint64_t now_ms_ = 0;
+  HashFieldExpireCommandArgs parsed_;
+};
+
 template <HashFieldExpireTimeMode kTimeMode>
 class CommandHExpireGeneric : public Commander {
  public:
@@ -809,6 +1085,8 @@ REDIS_REGISTER_COMMANDS(
     MakeCmdAttr<CommandHScan>("hscan", -3, "read-only", 1, 1, 1),
     MakeCmdAttr<CommandHRangeByLex>("hrangebylex", -4, "read-only", 1, 1, 1),
     MakeCmdAttr<CommandHRandField>("hrandfield", -2, "read-only slow", 1, 1, 
1),
+    MakeCmdAttr<CommandHSetEx>("hsetex", -6, "write", 1, 1, 1),
+    MakeCmdAttr<CommandHGetEx>("hgetex", -5, "write no-dbsize-check", 1, 1, 1),
     
MakeCmdAttr<CommandHExpireGeneric<HashFieldExpireTimeMode::kRelativeSeconds>>("hexpire",
 -6, "write", 1, 1, 1),
     
MakeCmdAttr<CommandHExpireGeneric<HashFieldExpireTimeMode::kRelativeMilliseconds>>("hpexpire",
 -6, "write", 1, 1,
                                                                                
        1),
diff --git a/src/types/redis_hash.cc b/src/types/redis_hash.cc
index 1ec515b8d..5ba98b1f8 100644
--- a/src/types/redis_hash.cc
+++ b/src/types/redis_hash.cc
@@ -25,6 +25,7 @@
 #include <algorithm>
 #include <cctype>
 #include <cmath>
+#include <limits>
 #include <optional>
 #include <random>
 #include <unordered_map>
@@ -53,6 +54,9 @@ struct HashFieldState {
   uint64_t expire = 0;
 };
 
+constexpr const char *kHashFieldExpirationLegacyEncodingError =
+    "hash field expiration is not supported by legacy hash encoding";
+
 bool IsFieldExpired(uint64_t expire, uint64_t now) { return expire != 0 && 
expire < now; }
 
 bool IsImmediateExpire(uint64_t expire_at, uint64_t now) { return expire_at <= 
now; }
@@ -85,6 +89,11 @@ void ApplyMissingToPersistent(HashMetadata *metadata) {
   }
 }
 
+void ApplyMissingToTTL(HashMetadata *metadata, uint64_t expire_at) {
+  ExpandExpireBounds(metadata, expire_at);
+  metadata->size += 1;
+}
+
 void ApplyPersistentToTTL(HashMetadata *metadata, uint64_t expire_at) {
   ExpandExpireBounds(metadata, expire_at);
   metadata->persist = SaturatingSub(metadata->persist, 1);
@@ -171,6 +180,155 @@ bool HExpireConditionPasses(HashFieldExpireCondition 
condition, const HashFieldS
   return false;
 }
 
+rocksdb::Status ValidateHashFieldExpirationMetadata(const HashMetadata 
&metadata) {
+  if (metadata.persist > metadata.size) {
+    return rocksdb::Status::Corruption("invalid hash field expiration 
metadata: persist exceeds size");
+  }
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status ValidateMissingFieldTransition(const HashMetadata &metadata) {
+  auto s = ValidateHashFieldExpirationMetadata(metadata);
+  if (!s.ok()) return s;
+  if (metadata.size == std::numeric_limits<uint64_t>::max()) {
+    return rocksdb::Status::Corruption("invalid hash field expiration 
metadata: size overflow");
+  }
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status ValidatePersistentFieldTransition(const HashMetadata 
&metadata) {
+  auto s = ValidateHashFieldExpirationMetadata(metadata);
+  if (!s.ok()) return s;
+  if (metadata.size == 0 || metadata.persist == 0) {
+    return rocksdb::Status::Corruption("invalid hash field expiration 
metadata: no persistent field to update");
+  }
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status ValidateTTLFieldTransition(const HashMetadata &metadata) {
+  auto s = ValidateHashFieldExpirationMetadata(metadata);
+  if (!s.ok()) return s;
+  if (metadata.size == 0 || metadata.persist == metadata.size) {
+    return rocksdb::Status::Corruption("invalid hash field expiration 
metadata: no TTL field to update");
+  }
+  return rocksdb::Status::OK();
+}
+
+bool HashMetadataEqual(const HashMetadata &lhs, const HashMetadata &rhs) {
+  return static_cast<const Metadata &>(lhs) == static_cast<const Metadata 
&>(rhs) && lhs.mode == rhs.mode &&
+         lhs.persist == rhs.persist && lhs.lower == rhs.lower && lhs.upper == 
rhs.upper;
+}
+
+class HashBatchWriter {
+ public:
+  explicit HashBatchWriter(rocksdb::WriteBatchBase *batch) : batch_(batch) {}
+
+  rocksdb::Status Put(const Slice &key, const Slice &value) {
+    auto s = ensureLogData();
+    if (!s.ok()) return s;
+    s = batch_->Put(key, value);
+    if (s.ok()) storage_mutated_ = true;
+    return s;
+  }
+
+  rocksdb::Status Put(rocksdb::ColumnFamilyHandle *column_family, const Slice 
&key, const Slice &value) {
+    auto s = ensureLogData();
+    if (!s.ok()) return s;
+    s = batch_->Put(column_family, key, value);
+    if (s.ok()) storage_mutated_ = true;
+    return s;
+  }
+
+  rocksdb::Status Delete(const Slice &key) {
+    auto s = ensureLogData();
+    if (!s.ok()) return s;
+    s = batch_->Delete(key);
+    if (s.ok()) storage_mutated_ = true;
+    return s;
+  }
+
+  rocksdb::Status Delete(rocksdb::ColumnFamilyHandle *column_family, const 
Slice &key) {
+    auto s = ensureLogData();
+    if (!s.ok()) return s;
+    s = batch_->Delete(column_family, key);
+    if (s.ok()) storage_mutated_ = true;
+    return s;
+  }
+
+  bool HasStorageMutation() const { return storage_mutated_; }
+
+ private:
+  rocksdb::Status ensureLogData() {
+    if (has_log_data_) return rocksdb::Status::OK();
+    WriteBatchLogData log_data(kRedisHash);
+    auto s = batch_->PutLogData(log_data.Encode());
+    if (s.ok()) has_log_data_ = true;
+    return s;
+  }
+
+  rocksdb::WriteBatchBase *batch_;
+  bool has_log_data_ = false;
+  bool storage_mutated_ = false;
+};
+
+rocksdb::Status LoadFieldStates(engine::Storage *storage, engine::Context 
&ctx, const HashMetadata &metadata,
+                                const std::string &ns_key, const 
std::vector<Slice> &fields, uint64_t now,
+                                bool read_existing, 
std::unordered_map<std::string, HashFieldState> *states) {
+  states->clear();
+  states->reserve(fields.size());
+
+  std::vector<std::string> unique_fields;
+  unique_fields.reserve(fields.size());
+  for (const auto &field : fields) {
+    bool inserted = states->emplace(field.ToString(), HashFieldState{}).second;
+    if (inserted) unique_fields.emplace_back(field.ToString());
+  }
+
+  if (!read_existing || unique_fields.empty()) return rocksdb::Status::OK();
+
+  std::vector<std::string> encoded_keys;
+  encoded_keys.reserve(unique_fields.size());
+  for (const auto &field : unique_fields) {
+    encoded_keys.emplace_back(InternalKey(ns_key, field, metadata.version, 
storage->IsSlotIdEncoded()).Encode());
+  }
+
+  std::vector<rocksdb::Slice> keys;
+  keys.reserve(encoded_keys.size());
+  for (const auto &key : encoded_keys) keys.emplace_back(key);
+
+  std::vector<rocksdb::PinnableSlice> raw_values(keys.size());
+  std::vector<rocksdb::Status> statuses(keys.size());
+  storage->MultiGet(ctx, ctx.DefaultMultiGetOptions(), 
storage->GetDB()->DefaultColumnFamily(), keys.size(),
+                    keys.data(), raw_values.data(), statuses.data());
+
+  for (size_t i = 0; i < unique_fields.size(); ++i) {
+    if (statuses[i].IsNotFound()) continue;
+    if (!statuses[i].ok()) return statuses[i];
+    auto s = DecodeFieldState(metadata, Slice(raw_values[i]), now, 
&states->at(unique_fields[i]));
+    if (!s.ok()) return s;
+  }
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status FinalizeHashMetadata(const HashMetadata &original_metadata, 
bool metadata_existed,
+                                     HashMetadata *metadata, 
rocksdb::ColumnFamilyHandle *metadata_cf_handle,
+                                     const std::string &ns_key, 
HashBatchWriter *writer) {
+  auto s = ValidateHashFieldExpirationMetadata(*metadata);
+  if (!s.ok()) return s;
+
+  ClearBoundsIfNoTtlCandidates(metadata);
+  if (metadata_existed && metadata->size == 0) {
+    return writer->Delete(metadata_cf_handle, ns_key);
+  }
+  if (metadata->size == 0 || (metadata_existed && 
HashMetadataEqual(original_metadata, *metadata))) {
+    return rocksdb::Status::OK();
+  }
+
+  std::string encoded_metadata;
+  metadata->Encode(&encoded_metadata);
+  return writer->Put(metadata_cf_handle, ns_key, encoded_metadata);
+}
+
 }  // namespace
 
 HashMetadata Hash::createMetadataForWrite(bool generate_version) const {
@@ -496,6 +654,298 @@ rocksdb::Status Hash::MGet(engine::Context &ctx, const 
Slice &user_key, const st
   return rocksdb::Status::OK();
 }
 
+rocksdb::Status Hash::SetFieldsWithExpire(engine::Context &ctx, const Slice 
&user_key,
+                                          const std::vector<FieldValue> 
&field_values, const HashSetExOptions &options,
+                                          bool *applied, 
std::optional<uint64_t> now_ms) {
+  *applied = false;
+
+  std::string ns_key = AppendNamespacePrefix(user_key);
+  HashMetadata metadata(false);
+  auto s = getMetadata(ctx, ns_key, &metadata);
+  bool metadata_existed = s.ok();
+  if (s.IsNotFound()) {
+    if (options.condition == HashFieldSetCondition::kFXX) return 
rocksdb::Status::OK();
+    metadata = createMetadataForWrite();
+    if (metadata.IsLegacySubkeyEncoding()) {
+      return 
rocksdb::Status::InvalidArgument(kHashFieldExpirationLegacyEncodingError);
+    }
+  } else if (!s.ok()) {
+    return s;
+  } else if (metadata.IsLegacySubkeyEncoding()) {
+    return 
rocksdb::Status::InvalidArgument(kHashFieldExpirationLegacyEncodingError);
+  }
+
+  s = ValidateHashFieldExpirationMetadata(metadata);
+  if (!s.ok()) return s;
+  HashMetadata original_metadata = metadata;
+  uint64_t now = now_ms.value_or(util::GetTimeStampMS());
+
+  std::vector<Slice> fields;
+  fields.reserve(field_values.size());
+  for (const auto &field_value : field_values) 
fields.emplace_back(field_value.field);
+
+  std::unordered_map<std::string, HashFieldState> state_cache;
+  s = LoadFieldStates(storage_, ctx, metadata, ns_key, fields, now, 
metadata_existed, &state_cache);
+  if (!s.ok()) return s;
+
+  auto make_sub_key = [&](const std::string &field) {
+    return InternalKey(ns_key, field, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
+  };
+
+  std::vector<std::string> expired_cleanup_keys;
+  bool condition_passed = true;
+  if (options.condition != HashFieldSetCondition::kNone) {
+    for (const auto &field_value : field_values) {
+      HashFieldState &state = state_cache.at(field_value.field);
+      if (state.kind == HashFieldStateKind::kExpiredTTLPhysical) {
+        s = ValidateTTLFieldTransition(metadata);
+        if (!s.ok()) return s;
+        ApplyTTLToDeleted(&metadata);
+        expired_cleanup_keys.emplace_back(make_sub_key(field_value.field));
+        state = HashFieldState{};
+      }
+
+      bool exists = state.kind == HashFieldStateKind::kPersistent || 
state.kind == HashFieldStateKind::kLiveTTL;
+      if ((options.condition == HashFieldSetCondition::kFNX && exists) ||
+          (options.condition == HashFieldSetCondition::kFXX && !exists)) {
+        condition_passed = false;
+        break;
+      }
+    }
+  }
+
+  auto batch = storage_->GetWriteBatchBase();
+  HashBatchWriter writer(batch.Get());
+  for (const auto &sub_key : expired_cleanup_keys) {
+    s = writer.Delete(sub_key);
+    if (!s.ok()) return s;
+  }
+
+  if (!condition_passed) {
+    s = FinalizeHashMetadata(original_metadata, metadata_existed, &metadata, 
metadata_cf_handle_, ns_key, &writer);
+    if (!s.ok()) return s;
+    if (writer.HasStorageMutation()) {
+      s = storage_->Write(ctx, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
+      if (!s.ok()) return s;
+    }
+    return rocksdb::Status::OK();
+  }
+
+  std::unordered_set<std::string_view> seen_fields;
+  std::vector<const FieldValue *> unique_field_values;
+  seen_fields.reserve(field_values.size());
+  unique_field_values.reserve(field_values.size());
+  for (auto iter = field_values.rbegin(); iter != field_values.rend(); ++iter) 
{
+    if (seen_fields.emplace(iter->field).second) 
unique_field_values.emplace_back(&*iter);
+  }
+  std::reverse(unique_field_values.begin(), unique_field_values.end());
+
+  bool immediate =
+      options.ttl_action == HashSetExOptions::TTLAction::kSet && 
IsImmediateExpire(options.expire_at_ms, now);
+  for (const auto *field_value : unique_field_values) {
+    HashFieldState &state = state_cache.at(field_value->field);
+    std::string sub_key = make_sub_key(field_value->field);
+
+    if (immediate) {
+      if (state.kind == HashFieldStateKind::kMissing) continue;
+      if (state.kind == HashFieldStateKind::kPersistent) {
+        s = ValidatePersistentFieldTransition(metadata);
+        if (!s.ok()) return s;
+        ApplyPersistentToDeleted(&metadata);
+      } else {
+        s = ValidateTTLFieldTransition(metadata);
+        if (!s.ok()) return s;
+        ApplyTTLToDeleted(&metadata);
+      }
+      s = writer.Delete(sub_key);
+      if (!s.ok()) return s;
+      state = HashFieldState{};
+      continue;
+    }
+
+    uint64_t target_expire = 0;
+    switch (options.ttl_action) {
+      case HashSetExOptions::TTLAction::kDiscard:
+        break;
+      case HashSetExOptions::TTLAction::kKeep:
+        if (state.kind == HashFieldStateKind::kLiveTTL || state.kind == 
HashFieldStateKind::kExpiredTTLPhysical) {
+          target_expire = state.expire;
+        }
+        break;
+      case HashSetExOptions::TTLAction::kSet:
+        target_expire = options.expire_at_ms;
+        break;
+    }
+
+    switch (state.kind) {
+      case HashFieldStateKind::kMissing:
+        s = ValidateMissingFieldTransition(metadata);
+        if (!s.ok()) return s;
+        if (target_expire == 0) {
+          ApplyMissingToPersistent(&metadata);
+        } else {
+          ApplyMissingToTTL(&metadata, target_expire);
+        }
+        break;
+      case HashFieldStateKind::kPersistent:
+        if (target_expire != 0) {
+          s = ValidatePersistentFieldTransition(metadata);
+          if (!s.ok()) return s;
+          ApplyPersistentToTTL(&metadata, target_expire);
+        }
+        break;
+      case HashFieldStateKind::kLiveTTL:
+      case HashFieldStateKind::kExpiredTTLPhysical:
+        s = ValidateTTLFieldTransition(metadata);
+        if (!s.ok()) return s;
+        if (target_expire == 0) {
+          ApplyTTLToPersistent(&metadata);
+        } else {
+          ApplyTTLToTTL(&metadata, target_expire);
+        }
+        break;
+    }
+
+    s = writer.Put(sub_key, metadata.EncodeSubkeyValue(field_value->value, 
target_expire));
+    if (!s.ok()) return s;
+    state.value = field_value->value;
+    state.expire = target_expire;
+    if (target_expire == 0) {
+      state.kind = HashFieldStateKind::kPersistent;
+    } else if (IsFieldExpired(target_expire, now)) {
+      state.kind = HashFieldStateKind::kExpiredTTLPhysical;
+    } else {
+      state.kind = HashFieldStateKind::kLiveTTL;
+    }
+  }
+
+  s = FinalizeHashMetadata(original_metadata, metadata_existed, &metadata, 
metadata_cf_handle_, ns_key, &writer);
+  if (!s.ok()) return s;
+  if (writer.HasStorageMutation()) {
+    s = storage_->Write(ctx, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
+    if (!s.ok()) return s;
+  }
+  *applied = true;
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status Hash::GetFieldsWithExpire(engine::Context &ctx, const Slice 
&user_key, const std::vector<Slice> &fields,
+                                          const HashGetExOptions &options, 
std::vector<std::string> *values,
+                                          std::vector<rocksdb::Status> 
*statuses, std::optional<uint64_t> now_ms) {
+  values->clear();
+  statuses->clear();
+  values->reserve(fields.size());
+  statuses->reserve(fields.size());
+
+  std::string ns_key = AppendNamespacePrefix(user_key);
+  HashMetadata metadata(false);
+  auto s = getMetadata(ctx, ns_key, &metadata);
+  if (s.IsNotFound()) {
+    values->resize(fields.size());
+    statuses->resize(fields.size(), rocksdb::Status::NotFound());
+    return rocksdb::Status::OK();
+  }
+  if (!s.ok()) return s;
+  if (metadata.IsLegacySubkeyEncoding()) {
+    return 
rocksdb::Status::InvalidArgument(kHashFieldExpirationLegacyEncodingError);
+  }
+
+  s = ValidateHashFieldExpirationMetadata(metadata);
+  if (!s.ok()) return s;
+  HashMetadata original_metadata = metadata;
+  uint64_t now = now_ms.value_or(util::GetTimeStampMS());
+
+  std::unordered_map<std::string, HashFieldState> state_cache;
+  s = LoadFieldStates(storage_, ctx, metadata, ns_key, fields, now, true, 
&state_cache);
+  if (!s.ok()) return s;
+
+  auto batch = storage_->GetWriteBatchBase();
+  HashBatchWriter writer(batch.Get());
+  auto make_sub_key = [&](const std::string &field) {
+    return InternalKey(ns_key, field, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
+  };
+  bool immediate =
+      options.ttl_action == HashGetExOptions::TTLAction::kSet && 
IsImmediateExpire(options.expire_at_ms, now);
+
+  for (const auto &field_slice : fields) {
+    std::string field = field_slice.ToString();
+    HashFieldState &state = state_cache.at(field);
+    std::string sub_key = make_sub_key(field);
+
+    if (state.kind == HashFieldStateKind::kMissing) {
+      values->emplace_back();
+      statuses->emplace_back(rocksdb::Status::NotFound());
+      continue;
+    }
+    if (state.kind == HashFieldStateKind::kExpiredTTLPhysical) {
+      values->emplace_back();
+      statuses->emplace_back(rocksdb::Status::NotFound());
+      s = ValidateTTLFieldTransition(metadata);
+      if (!s.ok()) return s;
+      ApplyTTLToDeleted(&metadata);
+      s = writer.Delete(sub_key);
+      if (!s.ok()) return s;
+      state = HashFieldState{};
+      continue;
+    }
+
+    values->emplace_back(state.value);
+    statuses->emplace_back(rocksdb::Status::OK());
+
+    if (options.ttl_action == HashGetExOptions::TTLAction::kNone) continue;
+    if (options.ttl_action == HashGetExOptions::TTLAction::kPersist) {
+      if (state.kind == HashFieldStateKind::kPersistent) continue;
+      s = ValidateTTLFieldTransition(metadata);
+      if (!s.ok()) return s;
+      ApplyTTLToPersistent(&metadata);
+      s = writer.Put(sub_key, metadata.EncodeSubkeyValue(state.value));
+      if (!s.ok()) return s;
+      state.kind = HashFieldStateKind::kPersistent;
+      state.expire = 0;
+      continue;
+    }
+
+    if (immediate) {
+      if (state.kind == HashFieldStateKind::kPersistent) {
+        s = ValidatePersistentFieldTransition(metadata);
+        if (!s.ok()) return s;
+        ApplyPersistentToDeleted(&metadata);
+      } else {
+        s = ValidateTTLFieldTransition(metadata);
+        if (!s.ok()) return s;
+        ApplyTTLToDeleted(&metadata);
+      }
+      s = writer.Delete(sub_key);
+      if (!s.ok()) return s;
+      state = HashFieldState{};
+      continue;
+    }
+
+    if (state.kind == HashFieldStateKind::kLiveTTL && state.expire == 
options.expire_at_ms) continue;
+    if (state.kind == HashFieldStateKind::kPersistent) {
+      s = ValidatePersistentFieldTransition(metadata);
+      if (!s.ok()) return s;
+      ApplyPersistentToTTL(&metadata, options.expire_at_ms);
+    } else {
+      s = ValidateTTLFieldTransition(metadata);
+      if (!s.ok()) return s;
+      ApplyTTLToTTL(&metadata, options.expire_at_ms);
+    }
+    s = writer.Put(sub_key, metadata.EncodeSubkeyValue(state.value, 
options.expire_at_ms));
+    if (!s.ok()) return s;
+    state.kind = HashFieldStateKind::kLiveTTL;
+    state.expire = options.expire_at_ms;
+  }
+
+  s = FinalizeHashMetadata(original_metadata, true, &metadata, 
metadata_cf_handle_, ns_key, &writer);
+  if (!s.ok()) return s;
+  if (writer.HasStorageMutation()) {
+    s = storage_->Write(ctx, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
+    if (!s.ok()) return s;
+  }
+  return rocksdb::Status::OK();
+}
+
 rocksdb::Status Hash::Set(engine::Context &ctx, const Slice &user_key, const 
Slice &field, const Slice &value,
                           uint64_t *added_cnt) {
   return MSet(ctx, user_key, {{field.ToString(), value.ToString()}}, false, 
added_cnt);
diff --git a/src/types/redis_hash.h b/src/types/redis_hash.h
index 0728fa10f..659f43987 100644
--- a/src/types/redis_hash.h
+++ b/src/types/redis_hash.h
@@ -48,6 +48,35 @@ enum class HashFieldExpireCondition {
   kLT,
 };
 
+enum class HashFieldSetCondition {
+  kNone,
+  kFNX,
+  kFXX,
+};
+
+struct HashSetExOptions {
+  enum class TTLAction {
+    kDiscard,
+    kKeep,
+    kSet,
+  };
+
+  HashFieldSetCondition condition = HashFieldSetCondition::kNone;
+  TTLAction ttl_action = TTLAction::kDiscard;
+  uint64_t expire_at_ms = 0;
+};
+
+struct HashGetExOptions {
+  enum class TTLAction {
+    kNone,
+    kPersist,
+    kSet,
+  };
+
+  TTLAction ttl_action = TTLAction::kNone;
+  uint64_t expire_at_ms = 0;
+};
+
 namespace redis {
 
 class Hash : public SubKeyScanner {
@@ -71,6 +100,13 @@ class Hash : public SubKeyScanner {
                              std::vector<FieldValue> *field_values);
   rocksdb::Status MGet(engine::Context &ctx, const Slice &user_key, const 
std::vector<Slice> &fields,
                        std::vector<std::string> *values, 
std::vector<rocksdb::Status> *statuses);
+  rocksdb::Status SetFieldsWithExpire(engine::Context &ctx, const Slice 
&user_key,
+                                      const std::vector<FieldValue> 
&field_values, const HashSetExOptions &options,
+                                      bool *applied, std::optional<uint64_t> 
now_ms = std::nullopt);
+  rocksdb::Status GetFieldsWithExpire(engine::Context &ctx, const Slice 
&user_key, const std::vector<Slice> &fields,
+                                      const HashGetExOptions &options, 
std::vector<std::string> *values,
+                                      std::vector<rocksdb::Status> *statuses,
+                                      std::optional<uint64_t> now_ms = 
std::nullopt);
   rocksdb::Status GetAll(engine::Context &ctx, const Slice &user_key, 
std::vector<FieldValue> *field_values,
                          HashFetchType type = HashFetchType::kAll);
   rocksdb::Status Scan(engine::Context &ctx, const Slice &user_key, const 
std::string &cursor, uint64_t limit,
diff --git a/tests/cppunit/types/hash_test.cc b/tests/cppunit/types/hash_test.cc
index e678aaf5c..53b8bd048 100644
--- a/tests/cppunit/types/hash_test.cc
+++ b/tests/cppunit/types/hash_test.cc
@@ -27,8 +27,11 @@
 #include <filesystem>
 #include <fstream>
 #include <memory>
+#include <optional>
 #include <random>
 #include <string>
+#include <tuple>
+#include <utility>
 #include <vector>
 
 #include "parse_util.h"
@@ -146,6 +149,104 @@ class RedisHashFieldExpirationEncodingTest : public 
::testing::Test {
     return storage_->Write(*ctx_, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
   }
 
+  rocksdb::Status getRawHashValue(const std::string &key, const std::string 
&field, std::string *raw_value) {
+    HashMetadata metadata = hashMetadata(key);
+    return storage_->Get(*ctx_, ctx_->GetReadOptions(), hashSubKey(key, field, 
metadata), raw_value);
+  }
+
+  std::pair<std::string, uint64_t> decodedHashValue(const std::string &key, 
const std::string &field) {
+    HashMetadata metadata(false);
+    std::string raw_value = rawHashValue(key, field, &metadata);
+    Slice decoded_value(raw_value);
+    uint64_t expire = 0;
+    auto s = metadata.DecodeSubkeyValue(&decoded_value, &expire);
+    assert(s.ok());
+    return {decoded_value.ToString(), expire};
+  }
+
+  void expectHashMetadata(const std::string &key, uint64_t size, uint64_t 
persist, uint64_t lower, uint64_t upper) {
+    HashMetadata metadata = hashMetadata(key);
+    EXPECT_EQ(metadata.mode, HashSubkeyEncodingMode::kFieldExpiration);
+    EXPECT_EQ(metadata.size, size);
+    EXPECT_EQ(metadata.persist, persist);
+    EXPECT_EQ(metadata.lower, lower);
+    EXPECT_EQ(metadata.upper, upper);
+    EXPECT_LE(metadata.persist, metadata.size);
+    if (metadata.size == metadata.persist) {
+      EXPECT_EQ(metadata.lower, 0);
+      EXPECT_EQ(metadata.upper, 0);
+    } else {
+      EXPECT_GT(metadata.lower, 0);
+      EXPECT_GE(metadata.upper, metadata.lower);
+    }
+  }
+
+  void createFourStateHash(const std::string &key, uint64_t expired_at, 
uint64_t live_expire) {
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"P", "p"}, {"L", "l"}, {"X", "x"}, 
{"K", "k"}}, false, &ret);
+    assert(s.ok());
+    assert(ret == 4);
+    s = putRawHashValue(key, "L", live_expire, "l");
+    assert(s.ok());
+    s = putRawHashValue(key, "X", expired_at, "x");
+    assert(s.ok());
+
+    HashMetadata metadata = hashMetadata(key);
+    metadata.persist = 2;
+    metadata.lower = expired_at;
+    metadata.upper = live_expire;
+    s = putHashMetadata(key, metadata);
+    assert(s.ok());
+  }
+
+  void createKeeperAndGhost(const std::string &key, uint64_t ghost_expire) {
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"K", "k"}, {"G", "g"}}, false, &ret);
+    assert(s.ok());
+    assert(ret == 2);
+    s = putRawHashValue(key, "G", ghost_expire, "g");
+    assert(s.ok());
+
+    HashMetadata metadata = hashMetadata(key);
+    metadata.persist = 1;
+    metadata.lower = ghost_expire;
+    metadata.upper = ghost_expire;
+    s = putHashMetadata(key, metadata);
+    assert(s.ok());
+    s = deleteRawHashValue(key, "G");
+    assert(s.ok());
+  }
+
+  static void expectGetResults(const std::vector<std::string> &values, const 
std::vector<rocksdb::Status> &statuses,
+                               const std::vector<std::optional<std::string>> 
&expected) {
+    ASSERT_EQ(values.size(), expected.size());
+    ASSERT_EQ(statuses.size(), expected.size());
+    for (size_t i = 0; i < expected.size(); ++i) {
+      if (expected[i]) {
+        EXPECT_TRUE(statuses[i].ok()) << statuses[i].ToString();
+        EXPECT_EQ(values[i], *expected[i]);
+      } else {
+        EXPECT_TRUE(statuses[i].IsNotFound()) << statuses[i].ToString();
+      }
+    }
+  }
+
+  static HashSetExOptions setExOptions(HashSetExOptions::TTLAction action, 
uint64_t expire_at = 0,
+                                       HashFieldSetCondition condition = 
HashFieldSetCondition::kNone) {
+    HashSetExOptions options;
+    options.ttl_action = action;
+    options.expire_at_ms = expire_at;
+    options.condition = condition;
+    return options;
+  }
+
+  static HashGetExOptions getExOptions(HashGetExOptions::TTLAction action, 
uint64_t expire_at = 0) {
+    HashGetExOptions options;
+    options.ttl_action = action;
+    options.expire_at_ms = expire_at;
+    return options;
+  }
+
   Config config_;
   std::unique_ptr<engine::Storage> storage_;
   std::unique_ptr<engine::Context> ctx_;
@@ -1034,6 +1135,926 @@ TEST_F(RedisHashFieldExpirationEncodingTest, 
IncrementsKeepLiveTTLAndTreatExpire
   EXPECT_EQ(metadata.upper, now + 120'000);
 }
 
+TEST_F(RedisHashFieldExpirationEncodingTest, 
SetFieldsWithExpireCoversAllPhysicalStatesAndTTLChanges) {
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t expired_at = now - 1'000;
+  const uint64_t live_expire = now + 60'000;
+  const uint64_t new_expire = now + 120'000;
+  const std::vector<FieldValue> updates = {{"P", "new-p"}, {"L", "new-l"}, 
{"X", "new-x"}, {"M", "new-m"}};
+
+  {
+    const std::string key = "hsetex-state-future";
+    createFourStateHash(key, expired_at, live_expire);
+    HashMetadata before = hashMetadata(key);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kSet, new_expire);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, updates, options, 
&applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 5, 1, expired_at, new_expire);
+    HashMetadata after = hashMetadata(key);
+    EXPECT_EQ(after.flags, before.flags);
+    EXPECT_EQ(after.version, before.version);
+    EXPECT_EQ(after.expire, before.expire);
+    for (const auto &field : {"P", "L", "X", "M"}) {
+      EXPECT_EQ(decodedHashValue(key, field).second, new_expire);
+    }
+    EXPECT_EQ(decodedHashValue(key, "P").first, "new-p");
+    EXPECT_EQ(decodedHashValue(key, "L").first, "new-l");
+    EXPECT_EQ(decodedHashValue(key, "X").first, "new-x");
+    EXPECT_EQ(decodedHashValue(key, "M").first, "new-m");
+    EXPECT_EQ(decodedHashValue(key, "K"), (std::pair<std::string, 
uint64_t>{"k", 0}));
+  }
+
+  {
+    const std::string key = "hsetex-state-discard";
+    createFourStateHash(key, expired_at, live_expire);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, updates, options, 
&applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 5, 5, 0, 0);
+    for (const auto &field : {"P", "L", "X", "M", "K"}) {
+      EXPECT_EQ(decodedHashValue(key, field).second, 0);
+    }
+  }
+
+  {
+    const std::string key = "hsetex-state-keep";
+    createFourStateHash(key, expired_at, live_expire);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kKeep);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, updates, options, 
&applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 5, 3, expired_at, live_expire);
+    EXPECT_EQ(decodedHashValue(key, "P"), (std::pair<std::string, 
uint64_t>{"new-p", 0}));
+    EXPECT_EQ(decodedHashValue(key, "L"), (std::pair<std::string, 
uint64_t>{"new-l", live_expire}));
+    EXPECT_EQ(decodedHashValue(key, "X"), (std::pair<std::string, 
uint64_t>{"new-x", expired_at}));
+    EXPECT_EQ(decodedHashValue(key, "M"), (std::pair<std::string, 
uint64_t>{"new-m", 0}));
+    std::string value;
+    EXPECT_TRUE(hash_->Get(*ctx_, key, "X", &value).IsNotFound());
+  }
+
+  {
+    const std::string key = "hsetex-state-immediate";
+    createFourStateHash(key, expired_at, live_expire);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kSet, now);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, updates, options, 
&applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 1, 1, 0, 0);
+    std::string raw_value;
+    for (const auto &field : {"P", "L", "X", "M"}) {
+      EXPECT_TRUE(getRawHashValue(key, field, &raw_value).IsNotFound());
+    }
+    EXPECT_EQ(decodedHashValue(key, "K"), (std::pair<std::string, 
uint64_t>{"k", 0}));
+  }
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
GetFieldsWithExpireCoversAllPhysicalStatesAndTTLChanges) {
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t expired_at = now - 1'000;
+  const uint64_t live_expire = now + 60'000;
+  const uint64_t new_expire = now + 120'000;
+  const std::vector<Slice> fields = {"P", "L", "X", "M"};
+  const std::vector<std::optional<std::string>> expected = {"p", "l", 
std::nullopt, std::nullopt};
+
+  {
+    const std::string key = "hgetex-state-none";
+    createFourStateHash(key, expired_at, live_expire);
+    std::vector<std::string> values;
+    std::vector<rocksdb::Status> statuses;
+    auto options = getExOptions(HashGetExOptions::TTLAction::kNone);
+    auto s = hash_->GetFieldsWithExpire(*ctx_, key, fields, options, &values, 
&statuses, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    expectGetResults(values, statuses, expected);
+    expectHashMetadata(key, 3, 2, expired_at, live_expire);
+    EXPECT_EQ(decodedHashValue(key, "P"), (std::pair<std::string, 
uint64_t>{"p", 0}));
+    EXPECT_EQ(decodedHashValue(key, "L"), (std::pair<std::string, 
uint64_t>{"l", live_expire}));
+  }
+
+  {
+    const std::string key = "hgetex-state-future";
+    createFourStateHash(key, expired_at, live_expire);
+    std::vector<std::string> values;
+    std::vector<rocksdb::Status> statuses;
+    auto options = getExOptions(HashGetExOptions::TTLAction::kSet, new_expire);
+    auto s = hash_->GetFieldsWithExpire(*ctx_, key, fields, options, &values, 
&statuses, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    expectGetResults(values, statuses, expected);
+    expectHashMetadata(key, 3, 1, expired_at, new_expire);
+    EXPECT_EQ(decodedHashValue(key, "P").second, new_expire);
+    EXPECT_EQ(decodedHashValue(key, "L").second, new_expire);
+  }
+
+  {
+    const std::string key = "hgetex-state-persist";
+    createFourStateHash(key, expired_at, live_expire);
+    std::vector<std::string> values;
+    std::vector<rocksdb::Status> statuses;
+    auto options = getExOptions(HashGetExOptions::TTLAction::kPersist);
+    auto s = hash_->GetFieldsWithExpire(*ctx_, key, fields, options, &values, 
&statuses, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    expectGetResults(values, statuses, expected);
+    expectHashMetadata(key, 3, 3, 0, 0);
+    EXPECT_EQ(decodedHashValue(key, "P").second, 0);
+    EXPECT_EQ(decodedHashValue(key, "L").second, 0);
+  }
+
+  {
+    const std::string key = "hgetex-state-immediate";
+    createFourStateHash(key, expired_at, live_expire);
+    std::vector<std::string> values;
+    std::vector<rocksdb::Status> statuses;
+    auto options = getExOptions(HashGetExOptions::TTLAction::kSet, now);
+    auto s = hash_->GetFieldsWithExpire(*ctx_, key, fields, options, &values, 
&statuses, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    expectGetResults(values, statuses, expected);
+    expectHashMetadata(key, 1, 1, 0, 0);
+    std::string raw_value;
+    for (const auto &field : {"P", "L", "X"}) {
+      EXPECT_TRUE(getRawHashValue(key, field, &raw_value).IsNotFound());
+    }
+  }
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
SetAndGetFieldsWithExpireHandleMissingAndDeadlineBoundaries) {
+  const uint64_t now = util::GetTimeStampMS();
+  bool applied = false;
+  auto immediate_set = setExOptions(HashSetExOptions::TTLAction::kSet, now);
+  auto s =
+      hash_->SetFieldsWithExpire(*ctx_, "hsetex-missing-immediate", {{"M", 
"value"}}, immediate_set, &applied, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_TRUE(applied);
+  HashMetadata metadata(false);
+  EXPECT_TRUE(getHashMetadata("hsetex-missing-immediate", 
&metadata).IsNotFound());
+
+  applied = false;
+  auto fnx_immediate = setExOptions(HashSetExOptions::TTLAction::kSet, now, 
HashFieldSetCondition::kFNX);
+  s = hash_->SetFieldsWithExpire(*ctx_, "hsetex-missing-fnx-immediate", {{"M", 
"value"}}, fnx_immediate, &applied, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_TRUE(applied);
+  EXPECT_TRUE(getHashMetadata("hsetex-missing-fnx-immediate", 
&metadata).IsNotFound());
+
+  const std::string key = "hgetex-equal-now";
+  uint64_t ret = 0;
+  s = hash_->MSet(*ctx_, key, {{"f", "value"}}, false, &ret);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(ret, 1);
+  s = putRawHashValue(key, "f", now, "value");
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  metadata = hashMetadata(key);
+  metadata.persist = 0;
+  metadata.lower = now;
+  metadata.upper = now;
+  s = putHashMetadata(key, metadata);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+
+  std::vector<std::string> values;
+  std::vector<rocksdb::Status> statuses;
+  auto immediate_get = getExOptions(HashGetExOptions::TTLAction::kSet, now);
+  s = hash_->GetFieldsWithExpire(*ctx_, key, {"f"}, immediate_get, &values, 
&statuses, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expectGetResults(values, statuses, {"value"});
+  EXPECT_TRUE(getHashMetadata(key, &metadata).IsNotFound());
+
+  const std::string expired_key = "hgetex-only-expired";
+  s = hash_->MSet(*ctx_, expired_key, {{"X", "x"}}, false, &ret);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  s = putRawHashValue(expired_key, "X", now - 1, "x");
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  metadata = hashMetadata(expired_key);
+  metadata.persist = 0;
+  metadata.lower = now - 1;
+  metadata.upper = now - 1;
+  s = putHashMetadata(expired_key, metadata);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  values.clear();
+  statuses.clear();
+  auto no_change = getExOptions(HashGetExOptions::TTLAction::kNone);
+  s = hash_->GetFieldsWithExpire(*ctx_, expired_key, {"X"}, no_change, 
&values, &statuses, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expectGetResults(values, statuses, {std::nullopt});
+  EXPECT_TRUE(getHashMetadata(expired_key, &metadata).IsNotFound());
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
SetFieldsWithExpireConditionsAreAtomicAndCleanupInRequestOrder) {
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t expired_at = now - 1'000;
+  const uint64_t live_expire = now + 60'000;
+
+  {
+    const std::string key = "hsetex-fxx-success";
+    createFourStateHash(key, expired_at, live_expire);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard, 0, 
HashFieldSetCondition::kFXX);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"P", "new-p"}, {"L", 
"new-l"}}, options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 4, 3, expired_at, live_expire);
+    EXPECT_EQ(decodedHashValue(key, "P"), (std::pair<std::string, 
uint64_t>{"new-p", 0}));
+    EXPECT_EQ(decodedHashValue(key, "L"), (std::pair<std::string, 
uint64_t>{"new-l", 0}));
+  }
+
+  {
+    const std::string key = "hsetex-fxx-missing-failure";
+    createFourStateHash(key, expired_at, live_expire);
+    HashMetadata before = hashMetadata(key);
+    bool applied = true;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard, 0, 
HashFieldSetCondition::kFXX);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"P", "new-p"}, {"M", 
"new-m"}}, options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_FALSE(applied);
+    EXPECT_EQ(hashMetadata(key), before);
+    EXPECT_EQ(decodedHashValue(key, "P").first, "p");
+    std::string raw_value;
+    EXPECT_TRUE(getRawHashValue(key, "M", &raw_value).IsNotFound());
+  }
+
+  {
+    const std::string key = "hsetex-fnx-expired-success";
+    createFourStateHash(key, expired_at, live_expire);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard, 0, 
HashFieldSetCondition::kFNX);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"X", "new-x"}, {"M", 
"new-m"}}, options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 5, 4, expired_at, live_expire);
+    EXPECT_EQ(decodedHashValue(key, "X"), (std::pair<std::string, 
uint64_t>{"new-x", 0}));
+    EXPECT_EQ(decodedHashValue(key, "M"), (std::pair<std::string, 
uint64_t>{"new-m", 0}));
+  }
+
+  {
+    const std::string key = "hsetex-fxx-expired-failure";
+    createFourStateHash(key, expired_at, live_expire);
+    bool applied = true;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard, 0, 
HashFieldSetCondition::kFXX);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"P", "new-p"}, {"X", 
"new-x"}}, options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_FALSE(applied);
+    expectHashMetadata(key, 3, 2, expired_at, live_expire);
+    EXPECT_EQ(decodedHashValue(key, "P").first, "p");
+    std::string raw_value;
+    EXPECT_TRUE(getRawHashValue(key, "X", &raw_value).IsNotFound());
+  }
+
+  for (const auto &[name, condition, fields] :
+       std::vector<std::tuple<std::string, HashFieldSetCondition, 
std::vector<FieldValue>>>{
+           {"fxx", HashFieldSetCondition::kFXX, {{"M", "new-m"}, {"X", 
"new-x"}}},
+           {"fnx", HashFieldSetCondition::kFNX, {{"P", "new-p"}, {"X", 
"new-x"}}},
+       }) {
+    const std::string key = "hsetex-ordered-unvisited-x-" + name;
+    createFourStateHash(key, expired_at, live_expire);
+    HashMetadata before = hashMetadata(key);
+    bool applied = true;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard, 0, 
condition);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, fields, options, &applied, 
now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_FALSE(applied);
+    EXPECT_EQ(hashMetadata(key), before);
+    EXPECT_EQ(decodedHashValue(key, "X"), (std::pair<std::string, 
uint64_t>{"x", expired_at}));
+  }
+
+  {
+    const std::string key = "hsetex-ordered-cleaned-x-before-failure";
+    createFourStateHash(key, expired_at, live_expire);
+    bool applied = true;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard, 0, 
HashFieldSetCondition::kFNX);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"X", "new-x"}, {"P", 
"new-p"}}, options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_FALSE(applied);
+    expectHashMetadata(key, 3, 2, expired_at, live_expire);
+    EXPECT_EQ(decodedHashValue(key, "P").first, "p");
+    std::string raw_value;
+    EXPECT_TRUE(getRawHashValue(key, "X", &raw_value).IsNotFound());
+  }
+
+  {
+    const std::string key = "hsetex-missing-fxx";
+    bool applied = true;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard, 0, 
HashFieldSetCondition::kFXX);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"M", "value"}}, options, 
&applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_FALSE(applied);
+    HashMetadata metadata(false);
+    EXPECT_TRUE(getHashMetadata(key, &metadata).IsNotFound());
+  }
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
SetFieldsWithExpireConditionChangesExpiredKeepTTLTransition) {
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t expired_at = now - 1'000;
+  const uint64_t future = now + 60'000;
+
+  auto create_keeper_and_expired = [&](const std::string &key) {
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"K", "k"}, {"X", "x"}}, false, &ret);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    ASSERT_EQ(ret, 2);
+    s = putRawHashValue(key, "X", expired_at, "x");
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    HashMetadata metadata = hashMetadata(key);
+    metadata.persist = 1;
+    metadata.lower = expired_at;
+    metadata.upper = expired_at;
+    s = putHashMetadata(key, metadata);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+  };
+
+  {
+    const std::string key = "hsetex-x-keep-without-condition";
+    create_keeper_and_expired(key);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kKeep);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"X", "unconditional"}}, 
options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 2, 1, expired_at, expired_at);
+    EXPECT_EQ(decodedHashValue(key, "X"), (std::pair<std::string, 
uint64_t>{"unconditional", expired_at}));
+  }
+
+  {
+    const std::string key = "hsetex-x-keep-with-fnx";
+    create_keeper_and_expired(key);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kKeep, 0, 
HashFieldSetCondition::kFNX);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"X", "conditional"}}, 
options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 2, 2, 0, 0);
+    EXPECT_EQ(decodedHashValue(key, "X"), (std::pair<std::string, 
uint64_t>{"conditional", 0}));
+  }
+
+  {
+    const std::string key = "hsetex-x-future-with-fnx";
+    create_keeper_and_expired(key);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kSet, future, 
HashFieldSetCondition::kFNX);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"X", "conditional"}}, 
options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 2, 1, future, future);
+    EXPECT_EQ(decodedHashValue(key, "X"), (std::pair<std::string, 
uint64_t>{"conditional", future}));
+  }
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
SetFieldsWithExpireDuplicatesUsePreWriteStateAndLastValue) {
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t expired_at = now - 1'000;
+  const uint64_t future = now + 60'000;
+
+  {
+    const std::string key = "hsetex-duplicate-missing-fnx";
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard, 0, 
HashFieldSetCondition::kFNX);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"f", "first"}, {"f", 
"last"}}, options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 1, 1, 0, 0);
+    EXPECT_EQ(decodedHashValue(key, "f"), (std::pair<std::string, 
uint64_t>{"last", 0}));
+  }
+
+  {
+    const std::string key = "hsetex-duplicate-existing-fxx";
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"f", "old"}}, false, &ret);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kSet, future, 
HashFieldSetCondition::kFXX);
+    s = hash_->SetFieldsWithExpire(*ctx_, key, {{"f", "first"}, {"f", 
"last"}}, options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 1, 0, future, future);
+    EXPECT_EQ(decodedHashValue(key, "f"), (std::pair<std::string, 
uint64_t>{"last", future}));
+  }
+
+  {
+    const std::string key = "hsetex-duplicate-expired-keep";
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"K", "k"}, {"X", "x"}}, false, &ret);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    s = putRawHashValue(key, "X", expired_at, "x");
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    HashMetadata metadata = hashMetadata(key);
+    metadata.persist = 1;
+    metadata.lower = expired_at;
+    metadata.upper = expired_at;
+    s = putHashMetadata(key, metadata);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kKeep);
+    s = hash_->SetFieldsWithExpire(*ctx_, key, {{"X", "first"}, {"X", 
"last"}}, options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 2, 1, expired_at, expired_at);
+    EXPECT_EQ(decodedHashValue(key, "X"), (std::pair<std::string, 
uint64_t>{"last", expired_at}));
+  }
+
+  {
+    const std::string key = "hsetex-duplicate-immediate";
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"f", "old"}}, false, &ret);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kSet, now);
+    s = hash_->SetFieldsWithExpire(*ctx_, key, {{"f", "first"}, {"f", 
"last"}}, options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    HashMetadata metadata(false);
+    EXPECT_TRUE(getHashMetadata(key, &metadata).IsNotFound());
+  }
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
GetFieldsWithExpireDuplicatesUseCommandLocalState) {
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t expired_at = now - 1'000;
+  const uint64_t future = now + 60'000;
+
+  {
+    const std::string key = "hgetex-duplicate-immediate";
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"f", "value"}}, false, &ret);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    std::vector<std::string> values;
+    std::vector<rocksdb::Status> statuses;
+    auto options = getExOptions(HashGetExOptions::TTLAction::kSet, now);
+    s = hash_->GetFieldsWithExpire(*ctx_, key, {"f", "f"}, options, &values, 
&statuses, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    expectGetResults(values, statuses, {"value", std::nullopt});
+    HashMetadata metadata(false);
+    EXPECT_TRUE(getHashMetadata(key, &metadata).IsNotFound());
+  }
+
+  {
+    const std::string key = "hgetex-duplicate-persist";
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"f", "value"}}, false, &ret);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    s = putRawHashValue(key, "f", future, "value");
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    HashMetadata metadata = hashMetadata(key);
+    metadata.persist = 0;
+    metadata.lower = future;
+    metadata.upper = future;
+    s = putHashMetadata(key, metadata);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    std::vector<std::string> values;
+    std::vector<rocksdb::Status> statuses;
+    auto options = getExOptions(HashGetExOptions::TTLAction::kPersist);
+    s = hash_->GetFieldsWithExpire(*ctx_, key, {"f", "f"}, options, &values, 
&statuses, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    expectGetResults(values, statuses, {"value", "value"});
+    expectHashMetadata(key, 1, 1, 0, 0);
+  }
+
+  {
+    const std::string key = "hgetex-duplicate-future";
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"f", "value"}}, false, &ret);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    std::vector<std::string> values;
+    std::vector<rocksdb::Status> statuses;
+    auto options = getExOptions(HashGetExOptions::TTLAction::kSet, future);
+    s = hash_->GetFieldsWithExpire(*ctx_, key, {"f", "f"}, options, &values, 
&statuses, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    expectGetResults(values, statuses, {"value", "value"});
+    expectHashMetadata(key, 1, 0, future, future);
+  }
+
+  {
+    const std::string key = "hgetex-duplicate-expired";
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"X", "x"}}, false, &ret);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    s = putRawHashValue(key, "X", expired_at, "x");
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    HashMetadata metadata = hashMetadata(key);
+    metadata.persist = 0;
+    metadata.lower = expired_at;
+    metadata.upper = expired_at;
+    s = putHashMetadata(key, metadata);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    std::vector<std::string> values;
+    std::vector<rocksdb::Status> statuses;
+    auto options = getExOptions(HashGetExOptions::TTLAction::kNone);
+    s = hash_->GetFieldsWithExpire(*ctx_, key, {"X", "X"}, options, &values, 
&statuses, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    expectGetResults(values, statuses, {std::nullopt, std::nullopt});
+    EXPECT_TRUE(getHashMetadata(key, &metadata).IsNotFound());
+  }
+
+  {
+    const std::string key = "hgetex-duplicate-ghost";
+    createKeeperAndGhost(key, expired_at);
+    HashMetadata before = hashMetadata(key);
+    std::vector<std::string> values;
+    std::vector<rocksdb::Status> statuses;
+    auto options = getExOptions(HashGetExOptions::TTLAction::kNone);
+    auto s = hash_->GetFieldsWithExpire(*ctx_, key, {"G", "G"}, options, 
&values, &statuses, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    expectGetResults(values, statuses, {std::nullopt, std::nullopt});
+    EXPECT_EQ(hashMetadata(key), before);
+  }
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
SetAndGetFieldsWithExpireKeepConservativeBoundsUntilLastTTL) {
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t t10 = now + 10'000;
+  const uint64_t t20 = now + 20'000;
+  const uint64_t t25 = now + 25'000;
+  const uint64_t t30 = now + 30'000;
+  bool applied = false;
+  auto options = setExOptions(HashSetExOptions::TTLAction::kSet, t20);
+  auto s = hash_->SetFieldsWithExpire(*ctx_, "hsetex-bounds", {{"a", "1"}, 
{"b", "2"}}, options, &applied, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_TRUE(applied);
+  expectHashMetadata("hsetex-bounds", 2, 0, t20, t20);
+
+  options.expire_at_ms = t30;
+  s = hash_->SetFieldsWithExpire(*ctx_, "hsetex-bounds", {{"a", "3"}}, 
options, &applied, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expectHashMetadata("hsetex-bounds", 2, 0, t20, t30);
+
+  options.expire_at_ms = t25;
+  s = hash_->SetFieldsWithExpire(*ctx_, "hsetex-bounds", {{"b", "4"}}, 
options, &applied, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expectHashMetadata("hsetex-bounds", 2, 0, t20, t30);
+
+  std::vector<std::string> values;
+  std::vector<rocksdb::Status> statuses;
+  auto get_options = getExOptions(HashGetExOptions::TTLAction::kSet, t10);
+  s = hash_->GetFieldsWithExpire(*ctx_, "hsetex-bounds", {"a"}, get_options, 
&values, &statuses, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expectHashMetadata("hsetex-bounds", 2, 0, t10, t30);
+
+  get_options.expire_at_ms = t20;
+  s = hash_->GetFieldsWithExpire(*ctx_, "hsetex-bounds", {"a"}, get_options, 
&values, &statuses, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expectHashMetadata("hsetex-bounds", 2, 0, t10, t30);
+
+  get_options = getExOptions(HashGetExOptions::TTLAction::kPersist);
+  s = hash_->GetFieldsWithExpire(*ctx_, "hsetex-bounds", {"a"}, get_options, 
&values, &statuses, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expectHashMetadata("hsetex-bounds", 2, 1, t10, t30);
+
+  options = setExOptions(HashSetExOptions::TTLAction::kDiscard);
+  s = hash_->SetFieldsWithExpire(*ctx_, "hsetex-bounds", {{"b", 
"persistent"}}, options, &applied, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expectHashMetadata("hsetex-bounds", 2, 2, 0, 0);
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
SetFieldsWithExpireDoesNotConsumeCompactionGhosts) {
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t ghost_expire = now - 1'000;
+  const uint64_t future = now + 60'000;
+
+  {
+    const std::string key = "hsetex-ghost-persistent";
+    createKeeperAndGhost(key, ghost_expire);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"G", "new-g"}}, options, 
&applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 3, 2, ghost_expire, ghost_expire);
+    EXPECT_EQ(decodedHashValue(key, "G"), (std::pair<std::string, 
uint64_t>{"new-g", 0}));
+
+    uint64_t size = 0;
+    s = hash_->Size(*ctx_, key, &size, HashLengthMode::kAccurate);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_EQ(size, 2);
+    expectHashMetadata(key, 2, 2, 0, 0);
+  }
+
+  {
+    const std::string key = "hsetex-ghost-future";
+    createKeeperAndGhost(key, ghost_expire);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kSet, future);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"G", "new-g"}}, options, 
&applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 3, 1, ghost_expire, future);
+    EXPECT_EQ(decodedHashValue(key, "G"), (std::pair<std::string, 
uint64_t>{"new-g", future}));
+
+    uint64_t size = 0;
+    s = hash_->Size(*ctx_, key, &size, HashLengthMode::kAccurate);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_EQ(size, 2);
+    expectHashMetadata(key, 2, 1, future, future);
+  }
+
+  {
+    const std::string key = "hsetex-ghost-keep";
+    createKeeperAndGhost(key, ghost_expire);
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kKeep);
+    auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"G", "new-g"}}, options, 
&applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    expectHashMetadata(key, 3, 2, ghost_expire, ghost_expire);
+    EXPECT_EQ(decodedHashValue(key, "G"), (std::pair<std::string, 
uint64_t>{"new-g", 0}));
+  }
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
GetFieldsWithExpireDistinguishesExpiredPhysicalFromGhost) {
+  const std::string key = "hgetex-expired-and-ghost";
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t expired_at = now - 2'000;
+  const uint64_t ghost_expire = now - 1'000;
+  uint64_t ret = 0;
+  auto s = hash_->MSet(*ctx_, key, {{"X", "x"}, {"G", "g"}, {"K", "k"}}, 
false, &ret);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(ret, 3);
+  s = putRawHashValue(key, "X", expired_at, "x");
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  s = putRawHashValue(key, "G", ghost_expire, "g");
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  HashMetadata metadata = hashMetadata(key);
+  metadata.persist = 1;
+  metadata.lower = expired_at;
+  metadata.upper = ghost_expire;
+  s = putHashMetadata(key, metadata);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  s = deleteRawHashValue(key, "G");
+  ASSERT_TRUE(s.ok()) << s.ToString();
+
+  std::vector<std::string> values;
+  std::vector<rocksdb::Status> statuses;
+  auto options = getExOptions(HashGetExOptions::TTLAction::kPersist);
+  s = hash_->GetFieldsWithExpire(*ctx_, key, {"X", "G", "K"}, options, 
&values, &statuses, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expectGetResults(values, statuses, {std::nullopt, std::nullopt, "k"});
+  expectHashMetadata(key, 2, 1, expired_at, ghost_expire);
+  std::string raw_value;
+  EXPECT_TRUE(getRawHashValue(key, "X", &raw_value).IsNotFound());
+  EXPECT_TRUE(getRawHashValue(key, "G", &raw_value).IsNotFound());
+
+  uint64_t size = 0;
+  s = hash_->Size(*ctx_, key, &size, HashLengthMode::kAccurate);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(size, 1);
+  expectHashMetadata(key, 1, 1, 0, 0);
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
SetAndGetFieldsWithExpirePreserveExistingKeyMetadataIdentity) {
+  const std::string key = "hsetex-key-metadata-identity";
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t expired_at = now - 1'000;
+  const uint64_t live_expire = now + 60'000;
+  const uint64_t new_expire = now + 120'000;
+  const uint64_t key_expire = now + 600'000;
+  createFourStateHash(key, expired_at, live_expire);
+  HashMetadata metadata = hashMetadata(key);
+  metadata.expire = key_expire;
+  auto s = putHashMetadata(key, metadata);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  const HashMetadata identity = hashMetadata(key);
+
+  auto expect_identity = [&]() {
+    HashMetadata current = hashMetadata(key);
+    EXPECT_EQ(current.flags, identity.flags);
+    EXPECT_EQ(current.mode, identity.mode);
+    EXPECT_EQ(current.version, identity.version);
+    EXPECT_EQ(current.expire, identity.expire);
+  };
+
+  bool applied = false;
+  auto set_options = setExOptions(HashSetExOptions::TTLAction::kSet, 
new_expire);
+  s = hash_->SetFieldsWithExpire(*ctx_, key, {{"P", "new-p"}}, set_options, 
&applied, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_TRUE(applied);
+  expect_identity();
+
+  std::vector<std::string> values;
+  std::vector<rocksdb::Status> statuses;
+  auto get_options = getExOptions(HashGetExOptions::TTLAction::kSet, 
new_expire);
+  s = hash_->GetFieldsWithExpire(*ctx_, key, {"L"}, get_options, &values, 
&statuses, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expect_identity();
+
+  values.clear();
+  statuses.clear();
+  get_options = getExOptions(HashGetExOptions::TTLAction::kPersist);
+  s = hash_->GetFieldsWithExpire(*ctx_, key, {"L"}, get_options, &values, 
&statuses, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expect_identity();
+
+  set_options = setExOptions(HashSetExOptions::TTLAction::kKeep);
+  s = hash_->SetFieldsWithExpire(*ctx_, key, {{"X", "kept-x"}}, set_options, 
&applied, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  expect_identity();
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
SetFieldsWithExpireRetainsConservativeKeyTTLCornerState) {
+  const std::string key = "hsetex-key-ttl-conservative-upper";
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t short_expire = now - 1'000;
+  const uint64_t long_expire = now + 120'000;
+  const uint64_t key_expire = now + 60'000;
+  uint64_t ret = 0;
+  auto s = hash_->MSet(*ctx_, key, {{"X", "x"}}, false, &ret);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  s = putRawHashValue(key, "X", short_expire, "x");
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  HashMetadata metadata = hashMetadata(key);
+  metadata.persist = 0;
+  metadata.lower = short_expire;
+  metadata.upper = long_expire;
+  metadata.expire = key_expire;
+  s = putHashMetadata(key, metadata);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  const HashMetadata original_metadata = hashMetadata(key);
+
+  bool applied = false;
+  auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard);
+  s = hash_->SetFieldsWithExpire(*ctx_, key, {{"new", "value"}}, options, 
&applied, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_TRUE(applied);
+  metadata = hashMetadata(key);
+  EXPECT_EQ(metadata.size, 2);
+  EXPECT_EQ(metadata.persist, 1);
+  EXPECT_EQ(metadata.lower, short_expire);
+  EXPECT_EQ(metadata.upper, long_expire);
+  EXPECT_EQ(metadata.expire, original_metadata.expire);
+  EXPECT_EQ(metadata.version, original_metadata.version);
+  EXPECT_EQ(metadata.flags, original_metadata.flags);
+  EXPECT_EQ(decodedHashValue(key, "new"), (std::pair<std::string, 
uint64_t>{"value", 0}));
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
SetFieldsWithExpireRecreatesExpiredKeyOrLeavesFXXMissing) {
+  const uint64_t now = util::GetTimeStampMS();
+  const uint64_t key_expired_at = now - 1'000;
+
+  {
+    const std::string key = "hsetex-recreate-expired-key";
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"old", "value"}}, false, &ret);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    HashMetadata metadata = hashMetadata(key);
+    const uint64_t old_version = metadata.version;
+    metadata.expire = key_expired_at;
+    s = putHashMetadata(key, metadata);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+
+    bool applied = false;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard);
+    s = hash_->SetFieldsWithExpire(*ctx_, key, {{"new", "new-value"}}, 
options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_TRUE(applied);
+    metadata = hashMetadata(key);
+    EXPECT_NE(metadata.version, old_version);
+    EXPECT_EQ(metadata.expire, 0);
+    EXPECT_EQ(metadata.size, 1);
+    EXPECT_EQ(metadata.persist, 1);
+    EXPECT_EQ(metadata.lower, 0);
+    EXPECT_EQ(metadata.upper, 0);
+    EXPECT_EQ(decodedHashValue(key, "new"), (std::pair<std::string, 
uint64_t>{"new-value", 0}));
+  }
+
+  {
+    const std::string key = "hsetex-fxx-expired-key";
+    uint64_t ret = 0;
+    auto s = hash_->MSet(*ctx_, key, {{"old", "value"}}, false, &ret);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    HashMetadata metadata = hashMetadata(key);
+    metadata.expire = key_expired_at;
+    s = putHashMetadata(key, metadata);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    std::string raw_before;
+    s = db_->GetRawMetadata(*ctx_, db_->AppendNamespacePrefix(key), 
&raw_before);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+
+    bool applied = true;
+    auto options = setExOptions(HashSetExOptions::TTLAction::kDiscard, 0, 
HashFieldSetCondition::kFXX);
+    s = hash_->SetFieldsWithExpire(*ctx_, key, {{"old", "new-value"}}, 
options, &applied, now);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_FALSE(applied);
+    std::string raw_after;
+    s = db_->GetRawMetadata(*ctx_, db_->AppendNamespacePrefix(key), 
&raw_after);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    EXPECT_EQ(raw_after, raw_before);
+  }
+}
+
+TEST_F(RedisHashTest, 
SetAndGetFieldsWithExpireRejectExistingLegacyHashWithoutMutation) {
+  redis::Database db(storage_.get(), "hash_ns");
+  const std::string key = "hsetex-legacy-existing";
+  uint64_t ret = 0;
+  auto s = hash_->MSet(*ctx_, key, {{"f", "value"}}, false, &ret);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(ret, 1);
+
+  HashMetadata metadata(false);
+  s = db.GetMetadata(*ctx_, {kRedisHash}, db.AppendNamespacePrefix(key), 
&metadata);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(metadata.mode, HashSubkeyEncodingMode::kLegacy);
+  const std::string sub_key =
+      InternalKey(db.AppendNamespacePrefix(key), "f", metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
+  std::string raw_metadata_before;
+  std::string raw_value_before;
+  s = db.GetRawMetadata(*ctx_, db.AppendNamespacePrefix(key), 
&raw_metadata_before);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  s = storage_->Get(*ctx_, ctx_->GetReadOptions(), sub_key, &raw_value_before);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(raw_value_before, "value");
+
+  std::vector<HashSetExOptions> set_options;
+  for (auto action : {HashSetExOptions::TTLAction::kDiscard, 
HashSetExOptions::TTLAction::kKeep}) {
+    HashSetExOptions options;
+    options.ttl_action = action;
+    set_options.push_back(options);
+  }
+  for (uint64_t expire_at : {uint64_t{1}, util::GetTimeStampMS() + 60'000}) {
+    HashSetExOptions options;
+    options.ttl_action = HashSetExOptions::TTLAction::kSet;
+    options.expire_at_ms = expire_at;
+    set_options.push_back(options);
+  }
+  HashSetExOptions conditional;
+  conditional.ttl_action = HashSetExOptions::TTLAction::kDiscard;
+  conditional.condition = HashFieldSetCondition::kFXX;
+  set_options.push_back(conditional);
+
+  for (const auto &options : set_options) {
+    bool applied = false;
+    s = hash_->SetFieldsWithExpire(*ctx_, key, {{"f", "changed"}}, options, 
&applied, util::GetTimeStampMS());
+    EXPECT_TRUE(s.IsInvalidArgument()) << s.ToString();
+  }
+
+  std::vector<HashGetExOptions> get_options;
+  for (auto action : {HashGetExOptions::TTLAction::kNone, 
HashGetExOptions::TTLAction::kPersist}) {
+    HashGetExOptions options;
+    options.ttl_action = action;
+    get_options.push_back(options);
+  }
+  for (uint64_t expire_at : {uint64_t{1}, util::GetTimeStampMS() + 60'000}) {
+    HashGetExOptions options;
+    options.ttl_action = HashGetExOptions::TTLAction::kSet;
+    options.expire_at_ms = expire_at;
+    get_options.push_back(options);
+  }
+  for (const auto &options : get_options) {
+    std::vector<std::string> values;
+    std::vector<rocksdb::Status> statuses;
+    s = hash_->GetFieldsWithExpire(*ctx_, key, {"f"}, options, &values, 
&statuses, util::GetTimeStampMS());
+    EXPECT_TRUE(s.IsInvalidArgument()) << s.ToString();
+  }
+
+  std::string raw_metadata_after;
+  std::string raw_value_after;
+  s = db.GetRawMetadata(*ctx_, db.AppendNamespacePrefix(key), 
&raw_metadata_after);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  s = storage_->Get(*ctx_, ctx_->GetReadOptions(), sub_key, &raw_value_after);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(raw_metadata_after, raw_metadata_before);
+  EXPECT_EQ(raw_value_after, raw_value_before);
+}
+
+TEST_F(RedisHashTest, 
SetAndGetFieldsWithExpireHandleMissingKeyUnderLegacyConfig) {
+  redis::Database db(storage_.get(), "hash_ns");
+  const uint64_t now = util::GetTimeStampMS();
+  const std::string key = "hsetex-legacy-missing";
+
+  for (auto condition : {HashFieldSetCondition::kNone, 
HashFieldSetCondition::kFNX}) {
+    for (auto action : {HashSetExOptions::TTLAction::kDiscard, 
HashSetExOptions::TTLAction::kKeep,
+                        HashSetExOptions::TTLAction::kSet}) {
+      HashSetExOptions options;
+      options.ttl_action = action;
+      options.expire_at_ms = action == HashSetExOptions::TTLAction::kSet ? now 
: 0;
+      options.condition = condition;
+      bool applied = false;
+      auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"f", "value"}}, 
options, &applied, now);
+      EXPECT_TRUE(s.IsInvalidArgument()) << s.ToString();
+      std::string raw_metadata;
+      EXPECT_TRUE(db.GetRawMetadata(*ctx_, db.AppendNamespacePrefix(key), 
&raw_metadata).IsNotFound());
+    }
+  }
+
+  HashSetExOptions fxx;
+  fxx.ttl_action = HashSetExOptions::TTLAction::kDiscard;
+  fxx.condition = HashFieldSetCondition::kFXX;
+  bool applied = true;
+  auto s = hash_->SetFieldsWithExpire(*ctx_, key, {{"f", "value"}}, fxx, 
&applied, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_FALSE(applied);
+
+  std::vector<std::string> values;
+  std::vector<rocksdb::Status> statuses;
+  HashGetExOptions get_options;
+  get_options.ttl_action = HashGetExOptions::TTLAction::kSet;
+  get_options.expire_at_ms = now + 60'000;
+  s = hash_->GetFieldsWithExpire(*ctx_, key, {"a", "b"}, get_options, &values, 
&statuses, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(values.size(), 2);
+  ASSERT_EQ(statuses.size(), 2);
+  EXPECT_TRUE(statuses[0].IsNotFound());
+  EXPECT_TRUE(statuses[1].IsNotFound());
+}
+
 TEST_F(RedisHashTest, HIncr) {
   int64_t value = 0;
   Slice field("hash-incrby-invalid-field");
diff --git 
a/tests/gocase/integration/replication/hash_field_expiration_commands_test.go 
b/tests/gocase/integration/replication/hash_field_expiration_commands_test.go
new file mode 100644
index 000000000..db3138268
--- /dev/null
+++ 
b/tests/gocase/integration/replication/hash_field_expiration_commands_test.go
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package replication
+
+import (
+       "context"
+       "errors"
+       "testing"
+       "time"
+
+       "github.com/redis/go-redis/v9"
+       "github.com/stretchr/testify/require"
+
+       "github.com/apache/kvrocks/tests/gocase/util"
+)
+
+func requireHFEArray(t *testing.T, got interface{}, want ...interface{}) {
+       t.Helper()
+
+       values, ok := got.([]interface{})
+       require.Truef(t, ok, "expected []interface{}, got %T", got)
+       require.Equal(t, want, values)
+}
+
+func waitForHashFieldToExpire(t *testing.T, rdb *redis.Client, ctx 
context.Context, key, field string) {
+       t.Helper()
+
+       require.Eventually(t, func() bool {
+               return errors.Is(rdb.HGet(ctx, key, field).Err(), redis.Nil)
+       }, 5*time.Second, 50*time.Millisecond)
+}
+
+func TestHashFieldExpirationHSetExHGetExReplication(t *testing.T) {
+       configs := util.KvrocksServerConfigs{
+               "hash-encoding-mode":               "field-expiration",
+               "rocksdb.disable_auto_compactions": "yes",
+               "resp3-enabled":                    "yes",
+       }
+       master := util.StartServer(t, configs)
+       defer master.Close()
+       masterClient := master.NewClient()
+       defer func() { require.NoError(t, masterClient.Close()) }()
+
+       replica := util.StartServer(t, configs)
+       defer replica.Close()
+       replicaClient := replica.NewClient()
+       defer func() { require.NoError(t, replicaClient.Close()) }()
+
+       ctx := context.Background()
+       util.SlaveOf(t, replicaClient, master)
+       util.WaitForSync(t, replicaClient)
+
+       key := "hsetex-hgetex-replication"
+       result, err := masterClient.Do(ctx, "hsetex", key, "PX", 600000, 
"FIELDS", 3,
+               "a", "1", "b", "2", "c", "3").Int64()
+       require.NoError(t, err)
+       require.Equal(t, int64(1), result)
+       util.WaitForOffsetSync(t, masterClient, replicaClient, 5*time.Second)
+
+       masterExpire := masterClient.Do(ctx, "hpexpiretime", key, "FIELDS", 3, 
"a", "b", "c").Val()
+       replicaExpire := replicaClient.Do(ctx, "hpexpiretime", key, "FIELDS", 
3, "a", "b", "c").Val()
+       require.Equal(t, masterExpire, replicaExpire)
+       expires := masterExpire.([]interface{})
+       require.Equal(t, expires[0], expires[1])
+       require.Equal(t, expires[1], expires[2])
+       require.Equal(t, util.GetKMetadata(t, masterClient, ctx, key), 
util.GetKMetadata(t, replicaClient, ctx, key))
+
+       got, err := masterClient.Do(ctx, "hgetex", key, "PERSIST", "FIELDS", 2, 
"a", "missing").Result()
+       require.NoError(t, err)
+       requireHFEArray(t, got, "1", nil)
+       result, err = masterClient.Do(ctx, "hsetex", key, "KEEPTTL", "FIELDS", 
2, "b", "20", "d", "40").Int64()
+       require.NoError(t, err)
+       require.Equal(t, int64(1), result)
+       expireAt := time.Now().Add(20 * time.Minute).UnixMilli()
+       got, err = masterClient.Do(ctx, "hgetex", key, "PXAT", expireAt, 
"FIELDS", 2, "a", "c").Result()
+       require.NoError(t, err)
+       requireHFEArray(t, got, "1", "3")
+       util.WaitForOffsetSync(t, masterClient, replicaClient, 5*time.Second)
+
+       require.Equal(t, masterClient.HGetAll(ctx, key).Val(), 
replicaClient.HGetAll(ctx, key).Val())
+       require.Equal(t, masterClient.Do(ctx, "hpexpiretime", key, "FIELDS", 4, 
"a", "b", "c", "d").Val(),
+               replicaClient.Do(ctx, "hpexpiretime", key, "FIELDS", 4, "a", 
"b", "c", "d").Val())
+       require.Equal(t, util.GetKMetadata(t, masterClient, ctx, key), 
util.GetKMetadata(t, replicaClient, ctx, key))
+
+       cleanupKey := "hsetex-condition-cleanup-replication"
+       require.Equal(t, int64(2), masterClient.HSet(ctx, cleanupKey, 
"expired", "value", "keeper", "value").Val())
+       require.Equal(t, []interface{}{int64(1)}, masterClient.Do(ctx, 
"hexpire", cleanupKey, 1, "FIELDS", 1, "expired").Val())
+       util.WaitForOffsetSync(t, masterClient, replicaClient, 5*time.Second)
+       waitForHashFieldToExpire(t, masterClient, ctx, cleanupKey, "expired")
+
+       result, err = masterClient.Do(ctx, "hsetex", cleanupKey, "FXX", 
"FIELDS", 1, "expired", "new").Int64()
+       require.NoError(t, err)
+       require.Equal(t, int64(0), result)
+       util.WaitForOffsetSync(t, masterClient, replicaClient, 5*time.Second)
+       require.Equal(t, map[string]string{"keeper": "value"}, 
replicaClient.HGetAll(ctx, cleanupKey).Val())
+       require.Equal(t, util.GetKMetadata(t, masterClient, ctx, cleanupKey),
+               util.GetKMetadata(t, replicaClient, ctx, cleanupKey))
+}
diff --git a/tests/gocase/unit/type/hash/hash_hsetex_hgetex_test.go 
b/tests/gocase/unit/type/hash/hash_hsetex_hgetex_test.go
new file mode 100644
index 000000000..e8af8d82c
--- /dev/null
+++ b/tests/gocase/unit/type/hash/hash_hsetex_hgetex_test.go
@@ -0,0 +1,796 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package hash
+
+import (
+       "context"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/redis/go-redis/v9"
+       "github.com/stretchr/testify/require"
+
+       "github.com/apache/kvrocks/tests/gocase/util"
+)
+
+func requireIntegerReply(t *testing.T, rdb *redis.Client, ctx context.Context, 
want int64, args ...interface{}) {
+       t.Helper()
+
+       got, err := rdb.Do(ctx, args...).Int64()
+       require.NoError(t, err)
+       require.Equal(t, want, got)
+}
+
+func requireOptionalStringArray(t *testing.T, got interface{}, want 
...interface{}) {
+       t.Helper()
+
+       values, ok := got.([]interface{})
+       require.Truef(t, ok, "expected []interface{}, got %T", got)
+       require.Equal(t, want, values)
+}
+
+func requireMetadataHeaderUnchanged(t *testing.T, before, after 
util.KMetadataResponse) {
+       t.Helper()
+
+       require.Equal(t, before.Flags, after.Flags)
+       require.Equal(t, before.Mode, after.Mode)
+       require.Equal(t, before.Version, after.Version)
+       require.Equal(t, before.Expire, after.Expire)
+}
+
+func TestHashFieldExpirationHSetExStateMatrix(t *testing.T) {
+       runWithFieldExpirationHashConfigs(t, util.KvrocksServerConfigs{
+               "rocksdb.disable_auto_compactions": "yes",
+       }, func(t *testing.T, rdb *redis.Client, ctx context.Context) {
+               t.Run("future deadline", func(t *testing.T) {
+                       key := "hsetex-state-future"
+                       createHashFieldStates(t, rdb, ctx, key)
+                       before := util.GetKMetadata(t, rdb, ctx, key)
+                       expireAt := time.Now().Add(10 * time.Minute).UnixMilli()
+
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"PXAT", expireAt, "FIELDS", 4,
+                               hfePersistentField, "p2", hfeLiveField, "l2", 
hfeExpiredField, "x2", hfeMissingField, "m2")
+
+                       after := util.GetKMetadata(t, rdb, ctx, key)
+                       requireHashMetadata(t, after, 5, 1)
+                       require.Equal(t, before.Lower, after.Lower)
+                       require.Equal(t, expireAt, after.Upper)
+                       requireMetadataHeaderUnchanged(t, before, after)
+                       requireHashValues(t, rdb, ctx, key, map[string]string{
+                               hfePersistentField: "p2",
+                               hfeLiveField:       "l2",
+                               hfeExpiredField:    "x2",
+                               hfeMissingField:    "m2",
+                               hfeKeeperField:     "40",
+                       })
+                       requireIntArray(t, rdb.Do(ctx, "hpexpiretime", key, 
"FIELDS", 4,
+                               hfePersistentField, hfeLiveField, 
hfeExpiredField, hfeMissingField).Val(),
+                               []int64{expireAt, expireAt, expireAt, expireAt})
+               })
+
+               t.Run("discard ttl", func(t *testing.T) {
+                       key := "hsetex-state-discard"
+                       createHashFieldStates(t, rdb, ctx, key)
+                       before := util.GetKMetadata(t, rdb, ctx, key)
+
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"FIELDS", 4,
+                               hfePersistentField, "p2", hfeLiveField, "l2", 
hfeExpiredField, "x2", hfeMissingField, "m2")
+
+                       after := util.GetKMetadata(t, rdb, ctx, key)
+                       requireHashMetadata(t, after, 5, 5)
+                       requireMetadataHeaderUnchanged(t, before, after)
+                       requireHashValues(t, rdb, ctx, key, map[string]string{
+                               hfePersistentField: "p2",
+                               hfeLiveField:       "l2",
+                               hfeExpiredField:    "x2",
+                               hfeMissingField:    "m2",
+                               hfeKeeperField:     "40",
+                       })
+                       requireIntArray(t, rdb.Do(ctx, "hpexpiretime", key, 
"FIELDS", 4,
+                               hfePersistentField, hfeLiveField, 
hfeExpiredField, hfeMissingField).Val(),
+                               []int64{-1, -1, -1, -1})
+               })
+
+               t.Run("keep ttl", func(t *testing.T) {
+                       key := "hsetex-state-keep"
+                       createHashFieldStates(t, rdb, ctx, key)
+                       before := util.GetKMetadata(t, rdb, ctx, key)
+
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"KEEPTTL", "FIELDS", 4,
+                               hfePersistentField, "p2", hfeLiveField, "l2", 
hfeExpiredField, "x2", hfeMissingField, "m2")
+
+                       after := util.GetKMetadata(t, rdb, ctx, key)
+                       requireHashMetadata(t, after, 5, 3)
+                       require.Equal(t, before.Lower, after.Lower)
+                       require.Equal(t, before.Upper, after.Upper)
+                       requireMetadataHeaderUnchanged(t, before, after)
+                       require.Equal(t, "p2", rdb.HGet(ctx, key, 
hfePersistentField).Val())
+                       require.Equal(t, "l2", rdb.HGet(ctx, key, 
hfeLiveField).Val())
+                       require.ErrorIs(t, rdb.HGet(ctx, key, 
hfeExpiredField).Err(), redis.Nil)
+                       require.Equal(t, "m2", rdb.HGet(ctx, key, 
hfeMissingField).Val())
+               })
+
+               t.Run("past deadline", func(t *testing.T) {
+                       key := "hsetex-state-past"
+                       createHashFieldStates(t, rdb, ctx, key)
+                       before := util.GetKMetadata(t, rdb, ctx, key)
+
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"PXAT", 1, "FIELDS", 4,
+                               hfePersistentField, "p2", hfeLiveField, "l2", 
hfeExpiredField, "x2", hfeMissingField, "m2")
+
+                       after := util.GetKMetadata(t, rdb, ctx, key)
+                       requireHashMetadata(t, after, 1, 1)
+                       requireMetadataHeaderUnchanged(t, before, after)
+                       require.Equal(t, map[string]string{hfeKeeperField: 
"40"}, rdb.HGetAll(ctx, key).Val())
+               })
+       })
+}
+
+func TestHashFieldExpirationHGetExStateMatrix(t *testing.T) {
+       runWithFieldExpirationHashConfigs(t, util.KvrocksServerConfigs{
+               "rocksdb.disable_auto_compactions": "yes",
+       }, func(t *testing.T, rdb *redis.Client, ctx context.Context) {
+               tests := []struct {
+                       name        string
+                       option      []interface{}
+                       wantSize    int64
+                       wantPersist int64
+               }{
+                       {name: "no modifier", wantSize: 3, wantPersist: 2},
+                       {name: "persist", option: []interface{}{"PERSIST"}, 
wantSize: 3, wantPersist: 3},
+                       {name: "past deadline", option: []interface{}{"PXAT", 
int64(1)}, wantSize: 1, wantPersist: 1},
+               }
+               for _, test := range tests {
+                       t.Run(test.name, func(t *testing.T) {
+                               key := "hgetex-state-" + test.name
+                               createHashFieldStates(t, rdb, ctx, key)
+                               before := util.GetKMetadata(t, rdb, ctx, key)
+                               args := []interface{}{"hgetex", key}
+                               args = append(args, test.option...)
+                               args = append(args, "FIELDS", 4, 
hfePersistentField, hfeLiveField, hfeExpiredField, hfeMissingField)
+
+                               got, err := rdb.Do(ctx, args...).Result()
+                               require.NoError(t, err)
+                               requireOptionalStringArray(t, got, "10", "20", 
nil, nil)
+
+                               after := util.GetKMetadata(t, rdb, ctx, key)
+                               requireHashMetadata(t, after, test.wantSize, 
test.wantPersist)
+                               requireMetadataHeaderUnchanged(t, before, after)
+                               if test.name == "no modifier" {
+                                       require.Equal(t, before.Lower, 
after.Lower)
+                                       require.Equal(t, before.Upper, 
after.Upper)
+                               }
+                       })
+               }
+
+               t.Run("future deadline", func(t *testing.T) {
+                       key := "hgetex-state-future"
+                       createHashFieldStates(t, rdb, ctx, key)
+                       before := util.GetKMetadata(t, rdb, ctx, key)
+                       expireAt := time.Now().Add(10 * time.Minute).UnixMilli()
+
+                       got, err := rdb.Do(ctx, "hgetex", key, "PXAT", 
expireAt, "FIELDS", 4,
+                               hfePersistentField, hfeLiveField, 
hfeExpiredField, hfeMissingField).Result()
+                       require.NoError(t, err)
+                       requireOptionalStringArray(t, got, "10", "20", nil, nil)
+
+                       after := util.GetKMetadata(t, rdb, ctx, key)
+                       requireHashMetadata(t, after, 3, 1)
+                       require.Equal(t, before.Lower, after.Lower)
+                       require.Equal(t, expireAt, after.Upper)
+                       requireMetadataHeaderUnchanged(t, before, after)
+               })
+
+               t.Run("empty string differs from missing", func(t *testing.T) {
+                       key := "hgetex-empty"
+                       require.Equal(t, int64(1), rdb.HSet(ctx, key, "empty", 
"").Val())
+                       got, err := rdb.Do(ctx, "hgetex", key, "FIELDS", 2, 
"empty", "missing").Result()
+                       require.NoError(t, err)
+                       requireOptionalStringArray(t, got, "", nil)
+               })
+       })
+}
+
+func TestHashFieldExpirationHSetExConditionsAndDuplicates(t *testing.T) {
+       runWithFieldExpirationHashConfigs(t, util.KvrocksServerConfigs{
+               "rocksdb.disable_auto_compactions": "yes",
+       }, func(t *testing.T, rdb *redis.Client, ctx context.Context) {
+               t.Run("FXX is atomic", func(t *testing.T) {
+                       key := "hsetex-fxx"
+                       createHashFieldStates(t, rdb, ctx, key)
+                       before := util.GetKMetadata(t, rdb, ctx, key)
+
+                       requireIntegerReply(t, rdb, ctx, 0, "hsetex", key, 
"FXX", "FIELDS", 2,
+                               hfePersistentField, "changed", hfeMissingField, 
"created")
+                       require.Equal(t, "10", rdb.HGet(ctx, key, 
hfePersistentField).Val())
+                       require.ErrorIs(t, rdb.HGet(ctx, key, 
hfeMissingField).Err(), redis.Nil)
+                       require.Equal(t, before, util.GetKMetadata(t, rdb, ctx, 
key))
+
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"FXX", "FIELDS", 2,
+                               hfePersistentField, "p2", hfeLiveField, "l2")
+                       require.Equal(t, "p2", rdb.HGet(ctx, key, 
hfePersistentField).Val())
+                       require.Equal(t, "l2", rdb.HGet(ctx, key, 
hfeLiveField).Val())
+               })
+
+               t.Run("ordered expired cleanup", func(t *testing.T) {
+                       key := "hsetex-ordered-cleanup"
+                       createHashFieldStates(t, rdb, ctx, key)
+                       before := util.GetKMetadata(t, rdb, ctx, key)
+
+                       requireIntegerReply(t, rdb, ctx, 0, "hsetex", key, 
"FNX", "FIELDS", 2,
+                               hfeExpiredField, "x2", hfePersistentField, "p2")
+                       after := util.GetKMetadata(t, rdb, ctx, key)
+                       requireHashMetadata(t, after, 3, 2)
+                       require.Equal(t, before.Lower, after.Lower)
+                       require.Equal(t, before.Upper, after.Upper)
+                       require.Equal(t, "10", rdb.HGet(ctx, key, 
hfePersistentField).Val())
+                       require.ErrorIs(t, rdb.HGet(ctx, key, 
hfeExpiredField).Err(), redis.Nil)
+
+                       key = "hsetex-no-late-cleanup"
+                       createHashFieldStates(t, rdb, ctx, key)
+                       before = util.GetKMetadata(t, rdb, ctx, key)
+                       requireIntegerReply(t, rdb, ctx, 0, "hsetex", key, 
"FNX", "FIELDS", 2,
+                               hfePersistentField, "p2", hfeExpiredField, "x2")
+                       require.Equal(t, before, util.GetKMetadata(t, rdb, ctx, 
key))
+               })
+
+               t.Run("FNX rebuilds expired as missing", func(t *testing.T) {
+                       key := "hsetex-fnx-expired"
+                       createHashFieldStates(t, rdb, ctx, key)
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"FNX", "KEEPTTL", "FIELDS", 2,
+                               hfeExpiredField, "x2", hfeMissingField, "m2")
+                       require.Equal(t, "x2", rdb.HGet(ctx, key, 
hfeExpiredField).Val())
+                       require.Equal(t, "m2", rdb.HGet(ctx, key, 
hfeMissingField).Val())
+                       requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, 
key), 5, 4)
+               })
+
+               t.Run("duplicates use one transition and last value", func(t 
*testing.T) {
+                       key := "hsetex-duplicates"
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"FNX", "FIELDS", 3,
+                               "field", "first", "field", "second", "field", 
"last")
+                       require.Equal(t, "last", rdb.HGet(ctx, key, 
"field").Val())
+                       requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, 
key), 1, 1)
+
+                       expireAt := time.Now().Add(10 * time.Minute).UnixMilli()
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"FXX", "PXAT", expireAt, "FIELDS", 2,
+                               "field", "next", "field", "final")
+                       require.Equal(t, "final", rdb.HGet(ctx, key, 
"field").Val())
+                       meta := util.GetKMetadata(t, rdb, ctx, key)
+                       requireHashMetadata(t, meta, 1, 0)
+                       require.Equal(t, expireAt, meta.Lower)
+                       require.Equal(t, expireAt, meta.Upper)
+               })
+       })
+}
+
+func TestHashFieldExpirationHGetExDuplicates(t *testing.T) {
+       runWithFieldExpirationHashConfigs(t, util.KvrocksServerConfigs{
+               "rocksdb.disable_auto_compactions": "yes",
+       }, func(t *testing.T, rdb *redis.Client, ctx context.Context) {
+               t.Run("past deadline returns value then null", func(t 
*testing.T) {
+                       key := "hgetex-duplicate-delete"
+                       require.Equal(t, int64(1), rdb.HSet(ctx, key, "field", 
"value").Val())
+                       got, err := rdb.Do(ctx, "hgetex", key, "PXAT", 1, 
"FIELDS", 2, "field", "field").Result()
+                       require.NoError(t, err)
+                       requireOptionalStringArray(t, got, "value", nil)
+                       require.Equal(t, int64(0), rdb.Exists(ctx, key).Val())
+               })
+
+               t.Run("persist changes counter once", func(t *testing.T) {
+                       key := "hgetex-duplicate-persist"
+                       require.Equal(t, int64(1), rdb.HSet(ctx, key, "field", 
"value").Val())
+                       requireIntArray(t, rdb.Do(ctx, "hexpire", key, 300, 
"FIELDS", 1, "field").Val(), []int64{1})
+                       got, err := rdb.Do(ctx, "hgetex", key, "PERSIST", 
"FIELDS", 2, "field", "field").Result()
+                       require.NoError(t, err)
+                       requireOptionalStringArray(t, got, "value", "value")
+                       requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, 
key), 1, 1)
+               })
+
+               t.Run("expired physical cleans once", func(t *testing.T) {
+                       key := "hgetex-duplicate-expired"
+                       require.Equal(t, int64(2), rdb.HSet(ctx, key, "field", 
"value", "keeper", "value").Val())
+                       requireIntArray(t, rdb.Do(ctx, "hexpire", key, 1, 
"FIELDS", 1, "field").Val(), []int64{1})
+                       waitHashFieldExpired(t, rdb, ctx, key, "field")
+                       got, err := rdb.Do(ctx, "hgetex", key, "FIELDS", 2, 
"field", "field").Result()
+                       require.NoError(t, err)
+                       requireOptionalStringArray(t, got, nil, nil)
+                       requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, 
key), 1, 1)
+               })
+       })
+}
+
+func TestHashFieldExpirationHSetExHGetExParserCompatibility(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               key := "hsetex-hgetex-parser"
+               require.Equal(t, int64(2), rdb.HSet(ctx, key, "a", "1", "b", 
"2").Val())
+
+               tests := []struct {
+                       name        string
+                       args        []interface{}
+                       errContains string
+                       exactError  string
+               }{
+                       {name: "hsetex repeated fields", args: 
[]interface{}{"hsetex", key, "FIELDS", 1, "a", "x", "FIELDS", 1, "b", "y"}, 
errContains: "FIELDS keyword specified multiple times"},
+                       {name: "hgetex repeated fields", args: 
[]interface{}{"hgetex", key, "FIELDS", 1, "a", "FIELDS", 1, "b"}, errContains: 
"FIELDS keyword specified multiple times"},
+                       {name: "hsetex zero fields", args: 
[]interface{}{"hsetex", key, "FIELDS", 0, "a", "x"}, errContains: "invalid 
number of fields"},
+                       {name: "hgetex negative fields", args: 
[]interface{}{"hgetex", key, "FIELDS", -1, "a"}, errContains: "invalid number 
of fields"},
+                       {name: "hsetex invalid fields integer", args: 
[]interface{}{"hsetex", key, "FIELDS", "invalid", "a", "x"}, errContains: 
"invalid number of fields"},
+                       {name: "hgetex fields integer overflow", args: 
[]interface{}{"hgetex", key, "FIELDS", "9223372036854775808", "a"}, 
errContains: "invalid number of fields"},
+                       {name: "hsetex short block", args: 
[]interface{}{"hsetex", key, "FIELDS", 2, "a", "x"}, errContains: "wrong number 
of arguments"},
+                       {name: "hgetex short block", args: 
[]interface{}{"hgetex", key, "FIELDS", 2, "a"}, errContains: "wrong number of 
arguments"},
+                       {name: "hsetex int max short block", args: 
[]interface{}{"hsetex", key, "FIELDS", 2147483647, "a", "x"}, errContains: 
"wrong number of arguments"},
+                       {name: "hgetex int max short block", args: 
[]interface{}{"hgetex", key, "FIELDS", 2147483647, "a"}, errContains: "wrong 
number of arguments"},
+                       {name: "hsetex fields above int max", args: 
[]interface{}{"hsetex", key, "FIELDS", "2147483648", "a", "x"}, errContains: 
"invalid number of fields"},
+                       {name: "hgetex fields above int max", args: 
[]interface{}{"hgetex", key, "FIELDS", "2147483648", "a"}, errContains: 
"invalid number of fields"},
+                       {name: "hsetex missing field count", args: 
[]interface{}{"hsetex", key, "FNX", "EX", 10, "FIELDS"}, exactError: "ERR wrong 
number of arguments"},
+                       {name: "hgetex missing field count", args: 
[]interface{}{"hgetex", key, "EX", 10, "FIELDS"}, exactError: "ERR wrong number 
of arguments"},
+                       {name: "hsetex duplicate fields before missing count", 
args: []interface{}{"hsetex", key, "FIELDS", 1, "a", "x", "FIELDS"}, 
errContains: "FIELDS keyword specified multiple times"},
+                       {name: "hgetex duplicate fields before missing count", 
args: []interface{}{"hgetex", key, "FIELDS", 1, "a", "FIELDS"}, errContains: 
"FIELDS keyword specified multiple times"},
+                       {name: "hsetex unknown trailing token", args: 
[]interface{}{"hsetex", key, "FIELDS", 1, "a", "x", "unknown"}, errContains: 
"unknown argument: unknown"},
+                       {name: "hgetex unknown trailing token", args: 
[]interface{}{"hgetex", key, "FIELDS", 1, "a", "unknown"}, errContains: 
"unknown argument: unknown"},
+                       {name: "hsetex condition conflict", args: 
[]interface{}{"hsetex", key, "FXX", "FNX", "FIELDS", 1, "a", "x"}, errContains: 
"Only one of FXX or FNX"},
+                       {name: "hsetex repeated fxx", args: 
[]interface{}{"hsetex", key, "FXX", "FXX", "FIELDS", 1, "a", "x"}, errContains: 
"Only one of FXX or FNX"},
+                       {name: "hsetex repeated fnx", args: 
[]interface{}{"hsetex", key, "FNX", "FNX", "FIELDS", 1, "missing", "x"}, 
errContains: "Only one of FXX or FNX"},
+                       {name: "hsetex expiration conflict", args: 
[]interface{}{"hsetex", key, "EX", 10, "KEEPTTL", "FIELDS", 1, "a", "x"}, 
errContains: "Only one of EX, PX, EXAT, PXAT or KEEPTTL"},
+                       {name: "hsetex repeated expiration", args: 
[]interface{}{"hsetex", key, "EX", 10, "EX", 20, "FIELDS", 1, "a", "x"}, 
errContains: "Only one of EX, PX, EXAT, PXAT or KEEPTTL"},
+                       {name: "hsetex repeated keepttl", args: 
[]interface{}{"hsetex", key, "KEEPTTL", "KEEPTTL", "FIELDS", 1, "a", "x"}, 
errContains: "Only one of EX, PX, EXAT, PXAT or KEEPTTL"},
+                       {name: "hgetex expiration conflict", args: 
[]interface{}{"hgetex", key, "PERSIST", "PX", 10, "FIELDS", 1, "a"}, 
errContains: "Only one of EX, PX, EXAT, PXAT or PERSIST"},
+                       {name: "hgetex repeated expiration", args: 
[]interface{}{"hgetex", key, "PX", 10, "PX", 20, "FIELDS", 1, "a"}, 
errContains: "Only one of EX, PX, EXAT, PXAT or PERSIST"},
+                       {name: "hgetex repeated persist", args: 
[]interface{}{"hgetex", key, "PERSIST", "PERSIST", "FIELDS", 1, "a"}, 
errContains: "Only one of EX, PX, EXAT, PXAT or PERSIST"},
+                       {name: "hsetex missing fields", args: 
[]interface{}{"hsetex", key, "EX", 10, "UNKNOWN", "x"}, errContains: "unknown 
argument: UNKNOWN"},
+                       {name: "hgetex missing fields", args: 
[]interface{}{"hgetex", key, "EX", 10, "UNKNOWN"}, errContains: "unknown 
argument: UNKNOWN"},
+                       {name: "hsetex missing expire", args: 
[]interface{}{"hsetex", key, "FIELDS", 1, "a", "x", "EX"}, errContains: 
"missing expire time"},
+                       {name: "hgetex missing expire", args: 
[]interface{}{"hgetex", key, "FIELDS", 1, "a", "EX"}, errContains: "missing 
expire time"},
+                       {name: "hsetex conflict before missing expire", args: 
[]interface{}{"hsetex", key, "EX", 10, "FIELDS", 1, "a", "x", "PX"}, 
errContains: "Only one of EX, PX, EXAT, PXAT or KEEPTTL"},
+                       {name: "hgetex conflict before missing expire", args: 
[]interface{}{"hgetex", key, "PERSIST", "FIELDS", 1, "a", "PX"}, errContains: 
"Only one of EX, PX, EXAT, PXAT or PERSIST"},
+                       {name: "hsetex fields consumed as time", args: 
[]interface{}{"hsetex", key, "EX", "FIELDS", 1, "a", "x"}, errContains: "value 
is not an integer or out of range"},
+                       {name: "hgetex fields consumed as time", args: 
[]interface{}{"hgetex", key, "EX", "FIELDS", 1, "a"}, errContains: "value is 
not an integer or out of range"},
+                       {name: "hsetex negative expire", args: 
[]interface{}{"hsetex", key, "EX", -1, "FIELDS", 1, "a", "x"}, errContains: 
"invalid expire time, must be >= 0"},
+                       {name: "hgetex negative expire", args: 
[]interface{}{"hgetex", key, "PX", -1, "FIELDS", 1, "a"}, errContains: "invalid 
expire time, must be >= 0"},
+                       {name: "hsetex expire integer overflow", args: 
[]interface{}{"hsetex", key, "PXAT", "9223372036854775808", "FIELDS", 1, "a", 
"x"}, errContains: "value is not an integer or out of range"},
+                       {name: "hsetex absolute expire too large", args: 
[]interface{}{"hsetex", key, "PXAT", hfeMaxAbsTimeMs + 1, "FIELDS", 1, "a", 
"x"}, exactError: "ERR invalid expire time"},
+                       {name: "hgetex absolute expire too large", args: 
[]interface{}{"hgetex", key, "PXAT", hfeMaxAbsTimeMs + 1, "FIELDS", 1, "a"}, 
exactError: "ERR invalid expire time"},
+                       {name: "hsetex option from hgetex", args: 
[]interface{}{"hsetex", key, "PERSIST", "FIELDS", 1, "a", "x"}, errContains: 
"unknown argument: PERSIST"},
+                       {name: "hgetex option from hsetex", args: 
[]interface{}{"hgetex", key, "KEEPTTL", "FIELDS", 1, "a"}, errContains: 
"unknown argument: KEEPTTL"},
+               }
+
+               for _, test := range tests {
+                       t.Run(test.name, func(t *testing.T) {
+                               before := util.GetKMetadata(t, rdb, ctx, key)
+                               valuesBefore := rdb.HGetAll(ctx, key).Val()
+                               err := rdb.Do(ctx, test.args...).Err()
+                               if test.exactError != "" {
+                                       require.EqualError(t, err, 
test.exactError)
+                               } else {
+                                       require.ErrorContains(t, err, 
test.errContains)
+                               }
+                               require.Equal(t, valuesBefore, rdb.HGetAll(ctx, 
key).Val())
+                               require.Equal(t, before, util.GetKMetadata(t, 
rdb, ctx, key))
+                       })
+               }
+
+               t.Run("flexible ordering and option-like data", func(t 
*testing.T) {
+                       expireAt := time.Now().Add(10 * time.Minute).UnixMilli()
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"PXAT", expireAt, "FIELDS", 1, "a", "3")
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"FIELDS", 1, "a", "5", "FXX", "KEEPTTL")
+                       requireIntArray(t, rdb.Do(ctx, "hpexpiretime", key, 
"FIELDS", 1, "a").Val(), []int64{expireAt})
+
+                       requireIntegerReply(t, rdb, ctx, 0, "hsetex", key, 
"FIELDS", 1, "missing-after-fields", "x", "FXX")
+                       require.False(t, rdb.HExists(ctx, key, 
"missing-after-fields").Val())
+
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"FIELDS", 2, "EX", "60", "FIELDS", "value")
+                       require.Equal(t, "60", rdb.HGet(ctx, key, "EX").Val())
+                       require.Equal(t, "value", rdb.HGet(ctx, key, 
"FIELDS").Val())
+
+                       got, err := rdb.Do(ctx, "hgetex", key, "FIELDS", 2, 
"EX", "FIELDS", "PERSIST").Result()
+                       require.NoError(t, err)
+                       requireOptionalStringArray(t, got, "60", "value")
+
+                       expireAt = time.Now().Add(20 * time.Minute).UnixMilli()
+                       got, err = rdb.Do(ctx, "hgetex", key, "FIELDS", 1, "a", 
"PXAT", expireAt).Result()
+                       require.NoError(t, err)
+                       requireOptionalStringArray(t, got, "5")
+                       requireIntArray(t, rdb.Do(ctx, "hpexpiretime", key, 
"FIELDS", 1, "a").Val(), []int64{expireAt})
+
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"FIELDS", 2,
+                               "ordinary", "FIELDS", "another", "EX")
+                       require.Equal(t, "FIELDS", rdb.HGet(ctx, key, 
"ordinary").Val())
+                       require.Equal(t, "EX", rdb.HGet(ctx, key, 
"another").Val())
+               })
+
+               t.Run("keywords are case insensitive", func(t *testing.T) {
+                       expireAt := time.Now().Add(30 * time.Minute).UnixMilli()
+                       requireIntegerReply(t, rdb, ctx, 1, "HsEtEx", key, 
"pXaT", expireAt, "fIeLdS", 1, "a", "case")
+                       requireIntegerReply(t, rdb, ctx, 1, "HsEtEx", key, 
"fXx", "fIeLdS", 1, "a", "4", "kEePtTl")
+                       requireIntArray(t, rdb.Do(ctx, "hpexpiretime", key, 
"FIELDS", 1, "a").Val(), []int64{expireAt})
+                       got, err := rdb.Do(ctx, "HgEtEx", key, "fIeLdS", 1, 
"a", "pErSiSt").Result()
+                       require.NoError(t, err)
+                       requireOptionalStringArray(t, got, "4")
+                       requireIntArray(t, rdb.Do(ctx, "hpexpiretime", key, 
"FIELDS", 1, "a").Val(), []int64{-1})
+               })
+       })
+}
+
+func TestHashFieldExpirationHSetExHGetExParsingAndCommandFlags(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               require.EqualError(t, rdb.Do(ctx, "hsetex", "key").Err(),
+                       "ERR wrong number of arguments")
+               require.EqualError(t, rdb.Do(ctx, "hgetex", "key", 
"FIELDS").Err(),
+                       "ERR wrong number of arguments")
+
+               wrongTypeKey := "hsetex-hgetex-wrongtype"
+               require.NoError(t, rdb.Set(ctx, wrongTypeKey, "value", 0).Err())
+
+               hsetErr := rdb.Do(ctx, "hsetex", wrongTypeKey, "FIELDS", 0, 
"a", "x").Err()
+               require.ErrorContains(t, hsetErr, "invalid number of fields")
+               require.NotContains(t, hsetErr.Error(), "WRONGTYPE")
+
+               hgetErr := rdb.Do(ctx, "hgetex", wrongTypeKey, "FIELDS", 0, 
"a").Err()
+               require.ErrorContains(t, hgetErr, "invalid number of fields")
+               require.NotContains(t, hgetErr.Error(), "WRONGTYPE")
+
+               require.ErrorContains(t, rdb.Do(ctx, "hsetex", wrongTypeKey, 
"FIELDS", 1, "a", "x").Err(), "WRONGTYPE")
+               require.ErrorContains(t, rdb.Do(ctx, "hgetex", wrongTypeKey, 
"FIELDS", 1, "a").Err(), "WRONGTYPE")
+
+               t.Run("parse error aborts multi", func(t *testing.T) {
+                       key := "hgetex-parse-error-multi"
+                       require.NoError(t, rdb.Del(ctx, key).Err())
+                       require.NoError(t, rdb.Do(ctx, "MULTI").Err())
+                       require.Equal(t, "QUEUED", rdb.Do(ctx, "SET", key, 
"value").Val())
+                       require.ErrorContains(t, rdb.Do(ctx, "HGETEX", 
wrongTypeKey, "FIELDS", 0, "a").Err(),
+                               "invalid number of fields")
+                       require.EqualError(t, rdb.Do(ctx, "EXEC").Err(), 
"EXECABORT Transaction discarded")
+                       require.Zero(t, rdb.Exists(ctx, key).Val())
+               })
+
+               for _, test := range []struct {
+                       command string
+                       arity   int64
+                       flag    string
+               }{
+                       {command: "hsetex", arity: -6, flag: "write"},
+                       {command: "hgetex", arity: -5, flag: "no-dbsize-check"},
+               } {
+                       info, err := rdb.Do(ctx, "command", "info", 
test.command).Slice()
+                       require.NoError(t, err)
+                       require.Len(t, info, 1)
+                       commandInfo := info[0].([]interface{})
+                       require.Equal(t, test.arity, commandInfo[1])
+                       require.Contains(t, commandInfo[2].([]interface{}), 
test.flag)
+               }
+
+               key := "hsetex-hgetex-eval-ro"
+               require.Equal(t, int64(1), rdb.HSet(ctx, key, "a", "1").Val())
+               require.ErrorContains(t,
+                       rdb.Do(ctx, "eval_ro", `return redis.call('hsetex', 
KEYS[1], 'FIELDS', 1, 'a', '2')`, 1, key).Err(),
+                       "Write commands are not allowed from read-only scripts")
+               require.ErrorContains(t,
+                       rdb.Do(ctx, "eval_ro", `return redis.call('hgetex', 
KEYS[1], 'FIELDS', 1, 'a')`, 1, key).Err(),
+                       "Write commands are not allowed from read-only scripts")
+       })
+}
+
+func TestHashFieldExpirationHSetExHGetExLegacyPolicy(t *testing.T) {
+       srv := util.StartServer(t, util.KvrocksServerConfigs{
+               "hash-encoding-mode": "legacy",
+               "resp3-enabled":      "yes",
+       })
+       defer srv.Close()
+       ctx := context.Background()
+       rdb := srv.NewClient()
+       defer func() { require.NoError(t, rdb.Close()) }()
+
+       key := "hsetex-hgetex-legacy"
+       require.Equal(t, int64(2), rdb.HSet(ctx, key, "a", "1", "b", "2").Val())
+       require.NoError(t, rdb.PExpire(ctx, key, time.Minute).Err())
+       before := util.GetKMetadata(t, rdb, ctx, key)
+       valuesBefore := rdb.HGetAll(ctx, key).Val()
+       for _, args := range [][]interface{}{
+               {"hsetex", key, "FIELDS", 1, "a", "x"},
+               {"hsetex", key, "KEEPTTL", "FIELDS", 1, "a", "x"},
+               {"hsetex", key, "PXAT", 1, "FIELDS", 1, "a", "x"},
+               {"hsetex", key, "FXX", "FIELDS", 1, "missing", "x"},
+               {"hsetex", key, "FNX", "FIELDS", 1, "a", "x"},
+               {"hgetex", key, "FIELDS", 1, "a"},
+               {"hgetex", key, "PERSIST", "FIELDS", 1, "a"},
+               {"hgetex", key, "PXAT", 1, "FIELDS", 1, "a"},
+               {"hgetex", key, "PX", 60000, "FIELDS", 1, "missing"},
+       } {
+               require.ErrorContains(t, rdb.Do(ctx, args...).Err(),
+                       "hash field expiration is not supported by legacy hash 
encoding")
+               require.Equal(t, valuesBefore, rdb.HGetAll(ctx, key).Val())
+               require.Equal(t, before, util.GetKMetadata(t, rdb, ctx, key))
+       }
+
+       missing := "hsetex-hgetex-legacy-missing"
+       got, err := rdb.Do(ctx, "hgetex", missing, "PX", 1000, "FIELDS", 2, 
"a", "b").Result()
+       require.NoError(t, err)
+       requireOptionalStringArray(t, got, nil, nil)
+       requireIntegerReply(t, rdb, ctx, 0, "hsetex", missing, "FXX", "FIELDS", 
1, "a", "1")
+       for _, args := range [][]interface{}{
+               {"hsetex", missing, "FIELDS", 1, "a", "1"},
+               {"hsetex", missing, "FNX", "PXAT", 1, "FIELDS", 1, "a", "1"},
+               {"hsetex", missing, "KEEPTTL", "FIELDS", 1, "a", "1"},
+       } {
+               require.ErrorContains(t, rdb.Do(ctx, args...).Err(), "hash 
field expiration")
+               require.Equal(t, int64(0), rdb.Exists(ctx, missing).Val())
+       }
+}
+
+func TestHashFieldExpirationHSetExHGetExMetadataSequence(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               key := "hsetex-hgetex-metadata-sequence"
+               t1 := time.Now().Add(10 * time.Minute).UnixMilli()
+               t2 := t1 + int64((10*time.Minute)/time.Millisecond)
+
+               requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, "PXAT", t1, 
"FIELDS", 3,
+                       "a", "1", "b", "2", "c", "3")
+               meta := util.GetKMetadata(t, rdb, ctx, key)
+               requireHashMetadata(t, meta, 3, 0)
+               require.Equal(t, t1, meta.Lower)
+               require.Equal(t, t1, meta.Upper)
+               originalHeader := meta
+
+               got, err := rdb.Do(ctx, "hgetex", key, "PERSIST", "FIELDS", 2, 
"a", "missing").Result()
+               require.NoError(t, err)
+               requireOptionalStringArray(t, got, "1", nil)
+               meta = util.GetKMetadata(t, rdb, ctx, key)
+               requireHashMetadata(t, meta, 3, 1)
+               require.Equal(t, t1, meta.Lower)
+               require.Equal(t, t1, meta.Upper)
+               requireMetadataHeaderUnchanged(t, originalHeader, meta)
+
+               requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, "KEEPTTL", 
"FIELDS", 3,
+                       "a", "10", "b", "20", "d", "40")
+               meta = util.GetKMetadata(t, rdb, ctx, key)
+               requireHashMetadata(t, meta, 4, 2)
+               require.Equal(t, t1, meta.Lower)
+               require.Equal(t, t1, meta.Upper)
+
+               got, err = rdb.Do(ctx, "hgetex", key, "PXAT", t2, "FIELDS", 3, 
"a", "c", "missing").Result()
+               require.NoError(t, err)
+               requireOptionalStringArray(t, got, "10", "3", nil)
+               meta = util.GetKMetadata(t, rdb, ctx, key)
+               requireHashMetadata(t, meta, 4, 1)
+               require.Equal(t, t1, meta.Lower)
+               require.Equal(t, t2, meta.Upper)
+
+               require.Equal(t, int64(1), rdb.HDel(ctx, key, "b").Val())
+               meta = util.GetKMetadata(t, rdb, ctx, key)
+               requireHashMetadata(t, meta, 3, 1)
+               require.Equal(t, t1, meta.Lower)
+               require.Equal(t, t2, meta.Upper)
+
+               require.Equal(t, int64(10), rdb.HIncrBy(ctx, key, "c", 7).Val())
+               require.Equal(t, meta, util.GetKMetadata(t, rdb, ctx, key))
+
+               requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, "FIELDS", 1, 
"a", "100")
+               meta = util.GetKMetadata(t, rdb, ctx, key)
+               requireHashMetadata(t, meta, 3, 2)
+               require.Equal(t, t1, meta.Lower)
+               require.Equal(t, t2, meta.Upper)
+
+               got, err = rdb.Do(ctx, "hgetex", key, "PERSIST", "FIELDS", 1, 
"c").Result()
+               require.NoError(t, err)
+               requireOptionalStringArray(t, got, "10")
+               requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, key), 3, 
3)
+
+               requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, "PXAT", 1, 
"FIELDS", 2,
+                       "a", "0", "c", "0")
+               requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, key), 1, 
1)
+               require.Equal(t, map[string]string{"d": "40"}, rdb.HGetAll(ctx, 
key).Val())
+               require.Equal(t, int64(1), rdb.Do(ctx, "hlen", key, 
"REPAIR").Val())
+               requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, key), 1, 
1)
+       })
+}
+
+func TestHashFieldExpirationHSetExHGetExConservativeBounds(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               key := "hsetex-hgetex-bounds"
+               now := time.Now().UnixMilli()
+               t10 := now + 10*60*1000
+               t20 := now + 20*60*1000
+               t25 := now + 25*60*1000
+               t30 := now + 30*60*1000
+
+               requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, "PXAT", t20, 
"FIELDS", 2,
+                       "a", "1", "b", "2")
+               meta := util.GetKMetadata(t, rdb, ctx, key)
+               require.Equal(t, t20, meta.Lower)
+               require.Equal(t, t20, meta.Upper)
+
+               requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, "PXAT", t30, 
"FIELDS", 1, "a", "3")
+               meta = util.GetKMetadata(t, rdb, ctx, key)
+               require.Equal(t, t20, meta.Lower)
+               require.Equal(t, t30, meta.Upper)
+
+               requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, "PXAT", t25, 
"FIELDS", 1, "b", "4")
+               meta = util.GetKMetadata(t, rdb, ctx, key)
+               require.Equal(t, t20, meta.Lower)
+               require.Equal(t, t30, meta.Upper)
+
+               got, err := rdb.Do(ctx, "hgetex", key, "PXAT", t10, "FIELDS", 
1, "a").Result()
+               require.NoError(t, err)
+               requireOptionalStringArray(t, got, "3")
+               meta = util.GetKMetadata(t, rdb, ctx, key)
+               require.Equal(t, t10, meta.Lower)
+               require.Equal(t, t30, meta.Upper)
+
+               got, err = rdb.Do(ctx, "hgetex", key, "PXAT", t20, "FIELDS", 1, 
"a").Result()
+               require.NoError(t, err)
+               requireOptionalStringArray(t, got, "3")
+               meta = util.GetKMetadata(t, rdb, ctx, key)
+               require.Equal(t, t10, meta.Lower)
+               require.Equal(t, t30, meta.Upper)
+
+               got, err = rdb.Do(ctx, "hgetex", key, "PERSIST", "FIELDS", 1, 
"a").Result()
+               require.NoError(t, err)
+               requireOptionalStringArray(t, got, "3")
+               meta = util.GetKMetadata(t, rdb, ctx, key)
+               require.Equal(t, int64(1), meta.Persist)
+               require.Equal(t, t10, meta.Lower)
+               require.Equal(t, t30, meta.Upper)
+
+               requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, "FIELDS", 1, 
"b", "5")
+               requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, key), 2, 
2)
+       })
+}
+
+func TestHashFieldExpirationHSetExHGetExKeyLifecycle(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               t.Run("preserve key ttl and version", func(t *testing.T) {
+                       key := "hsetex-hgetex-key-ttl"
+                       require.Equal(t, int64(2), rdb.HSet(ctx, key, "a", "1", 
"b", "2").Val())
+                       require.NoError(t, rdb.PExpire(ctx, key, 
time.Minute).Err())
+                       before := util.GetKMetadata(t, rdb, ctx, key)
+                       expireAt := time.Now().Add(10 * time.Minute).UnixMilli()
+
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"PXAT", expireAt, "FIELDS", 1, "a", "3")
+                       after := util.GetKMetadata(t, rdb, ctx, key)
+                       requireMetadataHeaderUnchanged(t, before, after)
+
+                       got, err := rdb.Do(ctx, "hgetex", key, "PERSIST", 
"FIELDS", 1, "a").Result()
+                       require.NoError(t, err)
+                       requireOptionalStringArray(t, got, "3")
+                       after = util.GetKMetadata(t, rdb, ctx, key)
+                       requireMetadataHeaderUnchanged(t, before, after)
+               })
+
+               t.Run("past deadline on missing key leaves no tombstone", 
func(t *testing.T) {
+                       key := "hsetex-missing-past"
+                       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, 
"PXAT", 1, "FIELDS", 2,
+                               "a", "1", "b", "2")
+                       require.Equal(t, int64(0), rdb.Exists(ctx, key).Val())
+                       require.Error(t, rdb.Do(ctx, "kmetadata", key).Err())
+               })
+
+               t.Run("missing key replies", func(t *testing.T) {
+                       key := "hsetex-hgetex-missing"
+                       requireIntegerReply(t, rdb, ctx, 0, "hsetex", key, 
"FXX", "FIELDS", 1, "a", "1")
+                       got, err := rdb.Do(ctx, "hgetex", key, "PERSIST", 
"FIELDS", 2, "a", "b").Result()
+                       require.NoError(t, err)
+                       requireOptionalStringArray(t, got, nil, nil)
+                       require.Equal(t, int64(0), rdb.Exists(ctx, key).Val())
+               })
+
+               t.Run("last physical field deletes metadata", func(t 
*testing.T) {
+                       key := "hgetex-last-field"
+                       require.Equal(t, int64(1), rdb.HSet(ctx, key, "a", 
"1").Val())
+                       got, err := rdb.Do(ctx, "hgetex", key, "PXAT", 1, 
"FIELDS", 1, "a").Result()
+                       require.NoError(t, err)
+                       requireOptionalStringArray(t, got, "1")
+                       require.Equal(t, int64(0), rdb.Exists(ctx, key).Val())
+                       require.Error(t, rdb.Do(ctx, "kmetadata", key).Err())
+               })
+       })
+}
+
+func TestHashFieldExpirationHSetExHGetExSingleTimeSnapshot(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               key := "hsetex-hgetex-one-clock"
+               requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, "PX", 
600000, "FIELDS", 3,
+                       "a", "1", "b", "2", "c", "3")
+               expires := rdb.Do(ctx, "hpexpiretime", key, "FIELDS", 3, "a", 
"b", "c").Val().([]interface{})
+               require.Equal(t, expires[0], expires[1])
+               require.Equal(t, expires[1], expires[2])
+
+               got, err := rdb.Do(ctx, "hgetex", key, "PX", 700000, "FIELDS", 
3, "a", "b", "c").Result()
+               require.NoError(t, err)
+               requireOptionalStringArray(t, got, "1", "2", "3")
+               expires = rdb.Do(ctx, "hpexpiretime", key, "FIELDS", 3, "a", 
"b", "c").Val().([]interface{})
+               require.Equal(t, expires[0], expires[1])
+               require.Equal(t, expires[1], expires[2])
+       })
+}
+
+func TestHashFieldExpirationHSetExFNXConcurrency(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               const clients = 16
+               key := "hsetex-fnx-concurrent"
+               start := make(chan struct{})
+               results := make(chan int64, clients)
+               errors := make(chan error, clients)
+               var wg sync.WaitGroup
+               for i := 0; i < clients; i++ {
+                       wg.Add(1)
+                       go func(value int) {
+                               defer wg.Done()
+                               <-start
+                               result, err := rdb.Do(ctx, "hsetex", key, 
"FNX", "FIELDS", 1, "field", value).Int64()
+                               if err != nil {
+                                       errors <- err
+                                       return
+                               }
+                               results <- result
+                       }(i)
+               }
+               close(start)
+               wg.Wait()
+               close(results)
+               close(errors)
+
+               for err := range errors {
+                       require.NoError(t, err)
+               }
+               succeeded := 0
+               for result := range results {
+                       if result == 1 {
+                               succeeded++
+                       } else {
+                               require.Equal(t, int64(0), result)
+                       }
+               }
+               require.Equal(t, 1, succeeded)
+               requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, key), 1, 
1)
+       })
+}
+
+func TestHashFieldExpirationHSetExHGetExRestart(t *testing.T) {
+       srv := util.StartServer(t, util.KvrocksServerConfigs{
+               "hash-encoding-mode": "field-expiration",
+               "resp3-enabled":      "yes",
+       })
+       defer srv.Close()
+       ctx := context.Background()
+       rdb := srv.NewClient()
+
+       key := "hsetex-hgetex-restart"
+       expireAt := time.Now().Add(time.Hour).UnixMilli()
+       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, "PXAT", expireAt, 
"FIELDS", 3,
+               "a", "1", "b", "2", "c", "3")
+       got, err := rdb.Do(ctx, "hgetex", key, "PERSIST", "FIELDS", 1, 
"a").Result()
+       require.NoError(t, err)
+       requireOptionalStringArray(t, got, "1")
+       requireIntegerReply(t, rdb, ctx, 1, "hsetex", key, "KEEPTTL", "FIELDS", 
2,
+               "b", "20", "d", "40")
+
+       valuesBefore := rdb.HGetAll(ctx, key).Val()
+       expiresBefore := rdb.Do(ctx, "hpexpiretime", key, "FIELDS", 4, "a", 
"b", "c", "d").Val()
+       metadataBefore := util.GetKMetadata(t, rdb, ctx, key)
+       require.NoError(t, rdb.Close())
+
+       srv.Restart()
+       rdb = srv.NewClient()
+       defer func() { require.NoError(t, rdb.Close()) }()
+       require.Equal(t, valuesBefore, rdb.HGetAll(ctx, key).Val())
+       require.Equal(t, expiresBefore, rdb.Do(ctx, "hpexpiretime", key, 
"FIELDS", 4, "a", "b", "c", "d").Val())
+       require.Equal(t, metadataBefore, util.GetKMetadata(t, rdb, ctx, key))
+}

Reply via email to