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 e51eb2ed9 feat(hash): add HFE expire and TTL commands (#3506)
e51eb2ed9 is described below

commit e51eb2ed9132385c40b3f6b06f30287681d8e2eb
Author: Twice <[email protected]>
AuthorDate: Sat May 30 22:28:09 2026 +0800

    feat(hash): add HFE expire and TTL commands (#3506)
    
    Completes the hash field expiration command family by adding the
    remaining HFE commands and wiring them through the existing metadata and
    field-expiration helpers.
    
    Commands covered in this PR:
    - `HPEXPIRE`
    - `HEXPIREAT`
    - `HPEXPIREAT`
    - `HTTL`
    - `HPTTL`
    - `HEXPIRETIME`
    - `HPEXPIRETIME`
    
    The PR also keeps `HEXPIRE` and `HPERSIST` on the same shared
    implementation path, and adds C++/Go coverage for command semantics,
    parsing, time boundaries, legacy encoding rejection, and metadata
    behavior.
    
    Tracking issue: https://github.com/apache/kvrocks/issues/3436
    Proposal: https://github.com/apache/kvrocks/discussions/3432
    
    Assisted-by: Codex/GPT5.5
---
 src/commands/cmd_hash.cc                     | 329 ++++++++++++------
 src/types/redis_hash.cc                      |  48 ++-
 src/types/redis_hash.h                       |   7 +-
 tests/cppunit/types/hash_test.cc             |  97 ++++++
 tests/gocase/unit/type/hash/hash_hfe_test.go | 502 ++++++++++++++++++++-------
 5 files changed, 753 insertions(+), 230 deletions(-)

diff --git a/src/commands/cmd_hash.cc b/src/commands/cmd_hash.cc
index a8489b3ca..c56aeae63 100644
--- a/src/commands/cmd_hash.cc
+++ b/src/commands/cmd_hash.cc
@@ -20,6 +20,7 @@
 
 #include <limits>
 #include <optional>
+#include <string_view>
 
 #include "commander.h"
 #include "commands/command_parser.h"
@@ -32,6 +33,83 @@
 namespace redis {
 namespace {
 
+constexpr uint64_t kMaxHashFieldExpireAtMs = (static_cast<uint64_t>(1) << 46) 
- 1;
+
+enum class HashFieldExpireTimeMode {
+  kRelativeSeconds,
+  kRelativeMilliseconds,
+  kAbsoluteSeconds,
+  kAbsoluteMilliseconds,
+};
+
+std::vector<Slice> ToSlices(const std::vector<std::string> &values) {
+  std::vector<Slice> slices;
+  slices.reserve(values.size());
+  for (const auto &value : values) {
+    slices.emplace_back(value);
+  }
+  return slices;
+}
+
+std::string IntegerArray(const std::vector<int64_t> &results) {
+  std::vector<std::string> entries;
+  entries.reserve(results.size());
+  for (auto result : results) {
+    entries.emplace_back(redis::Integer(result));
+  }
+  return redis::Array(entries);
+}
+
+uint64_t CeilDiv1000(uint64_t value) { return value / 1000 + (value % 1000 != 
0 ? 1 : 0); }
+
+Status ParseHashFieldExpireArgument(std::string_view raw, int64_t *expire_arg) 
{
+  auto parsed = ParseInt<int64_t>(raw, 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 ConvertHashFieldExpireAtMs(int64_t expire_arg, HashFieldExpireTimeMode 
time_mode, uint64_t now_ms,
+                                  uint64_t *expire_at_ms) {
+  auto value = static_cast<uint64_t>(expire_arg);
+  bool seconds =
+      time_mode == HashFieldExpireTimeMode::kRelativeSeconds || time_mode == 
HashFieldExpireTimeMode::kAbsoluteSeconds;
+  bool relative = time_mode == HashFieldExpireTimeMode::kRelativeSeconds ||
+                  time_mode == HashFieldExpireTimeMode::kRelativeMilliseconds;
+
+  if (seconds) {
+    if (value > kMaxHashFieldExpireAtMs / 1000) {
+      return {Status::RedisExecErr, "expire time overflow"};
+    }
+    value *= 1000;
+  }
+  if (value > kMaxHashFieldExpireAtMs) {
+    return {Status::RedisExecErr, "expire time overflow"};
+  }
+  if (relative) {
+    if (value > kMaxHashFieldExpireAtMs - now_ms) {
+      return {Status::RedisExecErr, "expire time overflow"};
+    }
+    value += now_ms;
+  }
+
+  *expire_at_ms = value;
+  return Status::OK();
+}
+
+std::optional<HashFieldExpireCondition> 
ParseHashExpireCondition(std::string_view token) {
+  if (util::EqualICase(token, "NX")) return HashFieldExpireCondition::kNX;
+  if (util::EqualICase(token, "XX")) return HashFieldExpireCondition::kXX;
+  if (util::EqualICase(token, "GT")) return HashFieldExpireCondition::kGT;
+  if (util::EqualICase(token, "LT")) return HashFieldExpireCondition::kLT;
+  return std::nullopt;
+}
+
 template <typename Parser>
 Status ParseHashFieldListTail(Parser &parser, std::vector<std::string> 
*fields) {
   if (!parser.Good()) {
@@ -54,6 +132,86 @@ Status ParseHashFieldListTail(Parser &parser, 
std::vector<std::string> *fields)
   return Status::OK();
 }
 
+Status ParseHashFixedFields(const std::vector<std::string> &args, 
std::vector<std::string> *fields) {
+  CommandParser parser(args, 2);
+  if (!parser.EatEqICase("FIELDS")) {
+    return {Status::RedisParseErr, errInvalidSyntax};
+  }
+  return ParseHashFieldListTail(parser, fields);
+}
+
+Status ParseHashExpireFields(const std::vector<std::string> &args, size_t 
start,
+                             HashFieldExpireCondition *condition_out, 
std::vector<std::string> *fields) {
+  *condition_out = HashFieldExpireCondition::kNone;
+  fields->clear();
+  bool fields_seen = false;
+
+  for (size_t i = start; i < args.size();) {
+    if (util::EqualICase(args[i], "FIELDS")) {
+      if (fields_seen) {
+        return {Status::RedisParseErr, errInvalidSyntax};
+      }
+      fields_seen = true;
+      if (i + 1 >= args.size()) {
+        return {Status::RedisParseErr, errWrongNumOfArguments};
+      }
+
+      auto num_fields = ParseInt<int64_t>(args[i + 1], 10);
+      if (!num_fields || *num_fields < 1) {
+        return {Status::RedisParseErr, errValueNotInteger};
+      }
+
+      size_t first_field = i + 2;
+      auto field_count = static_cast<size_t>(*num_fields);
+      if (field_count > args.size() - first_field) {
+        return {Status::RedisParseErr, errWrongNumOfArguments};
+      }
+
+      fields->clear();
+      fields->reserve(field_count);
+      for (size_t j = 0; j < field_count; j++) {
+        fields->emplace_back(args[first_field + j]);
+      }
+      i = first_field + field_count;
+      continue;
+    }
+
+    auto condition = ParseHashExpireCondition(args[i]);
+    if (!condition) {
+      return {Status::RedisParseErr, errInvalidSyntax};
+    }
+    if (*condition_out != HashFieldExpireCondition::kNone && *condition_out != 
*condition) {
+      return {Status::RedisParseErr, errInvalidSyntax};
+    }
+    *condition_out = *condition;
+    i++;
+  }
+
+  if (!fields_seen) {
+    return {Status::RedisParseErr, errInvalidSyntax};
+  }
+  return Status::OK();
+}
+
+int64_t FormatHashFieldExpireResult(int64_t expire_at, uint64_t now, 
HashFieldExpireTimeMode time_mode) {
+  if (expire_at < 0) {
+    return expire_at;
+  }
+
+  auto expire = static_cast<uint64_t>(expire_at);
+  switch (time_mode) {
+    case HashFieldExpireTimeMode::kRelativeSeconds:
+      return static_cast<int64_t>(CeilDiv1000(expire > now ? expire - now : 
0));
+    case HashFieldExpireTimeMode::kRelativeMilliseconds:
+      return static_cast<int64_t>(expire > now ? expire - now : 0);
+    case HashFieldExpireTimeMode::kAbsoluteSeconds:
+      return static_cast<int64_t>(CeilDiv1000(expire));
+    case HashFieldExpireTimeMode::kAbsoluteMilliseconds:
+      return expire_at;
+  }
+  return -2;
+}
+
 uint64_t GenerateHLenFlags(uint64_t flags, const std::vector<std::string> 
&args, const Config &config) {
   bool needs_repair = false;
   if (args.size() == 2) {
@@ -546,87 +704,35 @@ class CommandHRandField : public Commander {
   bool no_parameters_ = true;
 };
 
-class CommandHExpire : public Commander {
+template <HashFieldExpireTimeMode kTimeMode>
+class CommandHExpireGeneric : public Commander {
  public:
   Status Parse(const std::vector<std::string> &args) override {
-    CommandParser parser(args, 2);
-
-    auto seconds = parser.TakeInt<int64_t>();
-    if (!seconds) {
-      return {Status::RedisParseErr, errValueNotInteger};
-    }
-    if (*seconds < 0) {
-      return {Status::RedisParseErr, "invalid expire time, must be >= 0"};
-    }
-    seconds_ = *seconds;
-    condition_ = HashFieldExpireCondition::kNone;
-
-    while (parser.Good()) {
-      if (parser.EatEqICase("FIELDS")) {
-        GET_OR_RET(ParseHashFieldListTail(parser, &fields_));
-        return Commander::Parse(args);
-      }
+    GET_OR_RET(ParseHashFieldExpireArgument(args[2], &expire_arg_));
 
-      HashFieldExpireCondition parsed_condition = 
HashFieldExpireCondition::kNone;
-      if (parser.EatEqICase("NX")) {
-        parsed_condition = HashFieldExpireCondition::kNX;
-      } else if (parser.EatEqICase("XX")) {
-        parsed_condition = HashFieldExpireCondition::kXX;
-      } else if (parser.EatEqICase("GT")) {
-        parsed_condition = HashFieldExpireCondition::kGT;
-      } else if (parser.EatEqICase("LT")) {
-        parsed_condition = HashFieldExpireCondition::kLT;
-      } else {
-        return {Status::RedisParseErr, errInvalidSyntax};
-      }
-      if (condition_ != HashFieldExpireCondition::kNone) {
-        return {Status::RedisParseErr, errInvalidSyntax};
-      }
-      condition_ = parsed_condition;
-    }
-    return {Status::RedisParseErr, errInvalidSyntax};
+    GET_OR_RET(ParseHashExpireFields(args, 3, &condition_, &fields_));
+    return Commander::Parse(args);
   }
 
   Status Execute(engine::Context &ctx, Server *srv, Connection *conn, 
std::string *output) override {
+    uint64_t now = util::GetTimeStampMS();
     uint64_t expire_at = 0;
-    if (seconds_ > 0) {
-      auto seconds = static_cast<uint64_t>(seconds_);
-      if (seconds > std::numeric_limits<uint64_t>::max() / 1000) {
-        return {Status::RedisExecErr, "expire time overflow"};
-      }
-      uint64_t ttl_ms = seconds * 1000;
-      uint64_t now = util::GetTimeStampMS();
-      if (ttl_ms > std::numeric_limits<uint64_t>::max() - now) {
-        return {Status::RedisExecErr, "expire time overflow"};
-      }
-      expire_at = now + ttl_ms;
-    } else {
-      expire_at = util::GetTimeStampMS();
-    }
+    GET_OR_RET(ConvertHashFieldExpireAtMs(expire_arg_, kTimeMode, now, 
&expire_at));
 
     std::vector<int64_t> results;
-    std::vector<Slice> fields;
-    fields.reserve(fields_.size());
-    for (const auto &field : fields_) {
-      fields.emplace_back(field);
-    }
+    std::vector<Slice> fields = ToSlices(fields_);
     redis::Hash hash_db(srv->storage, conn->GetNamespace());
-    auto s = hash_db.ExpireFields(ctx, args_[1], fields, expire_at, 
condition_, &results);
+    auto s = hash_db.ExpireFields(ctx, args_[1], fields, expire_at, 
condition_, &results, now);
     if (!s.ok()) {
       return {Status::RedisExecErr, s.ToString()};
     }
 
-    std::vector<std::string> entries;
-    entries.reserve(results.size());
-    for (auto result : results) {
-      entries.emplace_back(redis::Integer(result));
-    }
-    *output = redis::Array(entries);
+    *output = IntegerArray(results);
     return Status::OK();
   }
 
  private:
-  int64_t seconds_ = 0;
+  int64_t expire_arg_ = 0;
   HashFieldExpireCondition condition_ = HashFieldExpireCondition::kNone;
   std::vector<std::string> fields_;
 };
@@ -634,33 +740,49 @@ class CommandHExpire : public Commander {
 class CommandHPersist : public Commander {
  public:
   Status Parse(const std::vector<std::string> &args) override {
-    CommandParser parser(args, 2);
-    if (!parser.EatEqICase("FIELDS")) {
-      return {Status::RedisParseErr, errInvalidSyntax};
-    }
-    GET_OR_RET(ParseHashFieldListTail(parser, &fields_));
+    GET_OR_RET(ParseHashFixedFields(args, &fields_));
     return Commander::Parse(args);
   }
 
   Status Execute(engine::Context &ctx, Server *srv, Connection *conn, 
std::string *output) override {
     std::vector<int64_t> results;
-    std::vector<Slice> fields;
-    fields.reserve(fields_.size());
-    for (const auto &field : fields_) {
-      fields.emplace_back(field);
-    }
+    std::vector<Slice> fields = ToSlices(fields_);
     redis::Hash hash_db(srv->storage, conn->GetNamespace());
     auto s = hash_db.PersistFields(ctx, args_[1], fields, &results);
     if (!s.ok()) {
       return {Status::RedisExecErr, s.ToString()};
     }
 
-    std::vector<std::string> entries;
-    entries.reserve(results.size());
-    for (auto result : results) {
-      entries.emplace_back(redis::Integer(result));
+    *output = IntegerArray(results);
+    return Status::OK();
+  }
+
+ private:
+  std::vector<std::string> fields_;
+};
+
+template <HashFieldExpireTimeMode kTimeMode>
+class CommandHExpireInfo : public Commander {
+ public:
+  Status Parse(const std::vector<std::string> &args) override {
+    GET_OR_RET(ParseHashFixedFields(args, &fields_));
+    return Commander::Parse(args);
+  }
+
+  Status Execute(engine::Context &ctx, Server *srv, Connection *conn, 
std::string *output) override {
+    uint64_t now = util::GetTimeStampMS();
+    std::vector<int64_t> results;
+    std::vector<Slice> fields = ToSlices(fields_);
+    redis::Hash hash_db(srv->storage, conn->GetNamespace());
+    auto s = hash_db.GetFieldsExpireTime(ctx, args_[1], fields, &results, now);
+    if (!s.ok()) {
+      return {Status::RedisExecErr, s.ToString()};
+    }
+
+    for (auto &result : results) {
+      result = FormatHashFieldExpireResult(result, now, kTimeMode);
     }
-    *output = redis::Array(entries);
+    *output = IntegerArray(results);
     return Status::OK();
   }
 
@@ -668,25 +790,36 @@ class CommandHPersist : public Commander {
   std::vector<std::string> fields_;
 };
 
-REDIS_REGISTER_COMMANDS(Hash, MakeCmdAttr<CommandHGet>("hget", 3, "read-only", 
1, 1, 1),
-                        MakeCmdAttr<CommandHIncrBy>("hincrby", 4, "write", 1, 
1, 1),
-                        MakeCmdAttr<CommandHIncrByFloat>("hincrbyfloat", 4, 
"write", 1, 1, 1),
-                        MakeCmdAttr<CommandHMSet>("hset", -4, "write", 1, 1, 
1),
-                        MakeCmdAttr<CommandHSetExpire>("hsetexpire", -5, 
"write", 1, 1, 1),
-                        MakeCmdAttr<CommandHSetNX>("hsetnx", -4, "write", 1, 
1, 1),
-                        MakeCmdAttr<CommandHDel>("hdel", -3, "write 
no-dbsize-check", 1, 1, 1),
-                        MakeCmdAttr<CommandHStrlen>("hstrlen", 3, "read-only", 
1, 1, 1),
-                        MakeCmdAttr<CommandHExists>("hexists", 3, "read-only", 
1, 1, 1),
-                        MakeCmdAttr<CommandHLen>("hlen", -2, "read-only", 1, 
1, 1, GenerateHLenFlags),
-                        MakeCmdAttr<CommandHMGet>("hmget", -3, "read-only", 1, 
1, 1),
-                        MakeCmdAttr<CommandHMSet>("hmset", -4, "write", 1, 1, 
1),
-                        MakeCmdAttr<CommandHKeys>("hkeys", 2, "read-only 
slow", 1, 1, 1),
-                        MakeCmdAttr<CommandHVals>("hvals", 2, "read-only 
slow", 1, 1, 1),
-                        MakeCmdAttr<CommandHGetAll>("hgetall", 2, "read-only 
slow", 1, 1, 1),
-                        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<CommandHExpire>("hexpire", -6, "write", 1, 
1, 1),
-                        MakeCmdAttr<CommandHPersist>("hpersist", -5, "write", 
1, 1, 1), )
+REDIS_REGISTER_COMMANDS(
+    Hash, MakeCmdAttr<CommandHGet>("hget", 3, "read-only", 1, 1, 1),
+    MakeCmdAttr<CommandHIncrBy>("hincrby", 4, "write", 1, 1, 1),
+    MakeCmdAttr<CommandHIncrByFloat>("hincrbyfloat", 4, "write", 1, 1, 1),
+    MakeCmdAttr<CommandHMSet>("hset", -4, "write", 1, 1, 1),
+    MakeCmdAttr<CommandHSetExpire>("hsetexpire", -5, "write", 1, 1, 1),
+    MakeCmdAttr<CommandHSetNX>("hsetnx", -4, "write", 1, 1, 1),
+    MakeCmdAttr<CommandHDel>("hdel", -3, "write no-dbsize-check", 1, 1, 1),
+    MakeCmdAttr<CommandHStrlen>("hstrlen", 3, "read-only", 1, 1, 1),
+    MakeCmdAttr<CommandHExists>("hexists", 3, "read-only", 1, 1, 1),
+    MakeCmdAttr<CommandHLen>("hlen", -2, "read-only", 1, 1, 1, 
GenerateHLenFlags),
+    MakeCmdAttr<CommandHMGet>("hmget", -3, "read-only", 1, 1, 1),
+    MakeCmdAttr<CommandHMSet>("hmset", -4, "write", 1, 1, 1),
+    MakeCmdAttr<CommandHKeys>("hkeys", 2, "read-only slow", 1, 1, 1),
+    MakeCmdAttr<CommandHVals>("hvals", 2, "read-only slow", 1, 1, 1),
+    MakeCmdAttr<CommandHGetAll>("hgetall", 2, "read-only slow", 1, 1, 1),
+    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<CommandHExpireGeneric<HashFieldExpireTimeMode::kRelativeSeconds>>("hexpire",
 -6, "write", 1, 1, 1),
+    
MakeCmdAttr<CommandHExpireGeneric<HashFieldExpireTimeMode::kRelativeMilliseconds>>("hpexpire",
 -6, "write", 1, 1,
+                                                                               
        1),
+    
MakeCmdAttr<CommandHExpireGeneric<HashFieldExpireTimeMode::kAbsoluteSeconds>>("hexpireat",
 -6, "write", 1, 1, 1),
+    
MakeCmdAttr<CommandHExpireGeneric<HashFieldExpireTimeMode::kAbsoluteMilliseconds>>("hpexpireat",
 -6, "write", 1, 1,
+                                                                               
        1),
+    MakeCmdAttr<CommandHPersist>("hpersist", -5, "write", 1, 1, 1),
+    
MakeCmdAttr<CommandHExpireInfo<HashFieldExpireTimeMode::kRelativeSeconds>>("httl",
 -5, "read-only", 1, 1, 1),
+    
MakeCmdAttr<CommandHExpireInfo<HashFieldExpireTimeMode::kRelativeMilliseconds>>("hpttl",
 -5, "read-only", 1, 1, 1),
+    
MakeCmdAttr<CommandHExpireInfo<HashFieldExpireTimeMode::kAbsoluteSeconds>>("hexpiretime",
 -5, "read-only", 1, 1, 1),
+    
MakeCmdAttr<CommandHExpireInfo<HashFieldExpireTimeMode::kAbsoluteMilliseconds>>("hpexpiretime",
 -5, "read-only", 1,
+                                                                               
     1, 1), )
 
 }  // namespace redis
diff --git a/src/types/redis_hash.cc b/src/types/redis_hash.cc
index 2bf8ff5e4..1ec515b8d 100644
--- a/src/types/redis_hash.cc
+++ b/src/types/redis_hash.cc
@@ -25,6 +25,7 @@
 #include <algorithm>
 #include <cctype>
 #include <cmath>
+#include <optional>
 #include <random>
 #include <unordered_map>
 #include <unordered_set>
@@ -859,9 +860,52 @@ rocksdb::Status Hash::RandField(engine::Context &ctx, 
const Slice &user_key, int
   return rocksdb::Status::OK();
 }
 
+rocksdb::Status Hash::GetFieldsExpireTime(engine::Context &ctx, const Slice 
&user_key, const std::vector<Slice> &fields,
+                                          std::vector<int64_t> *results, 
std::optional<uint64_t> now_ms) {
+  results->clear();
+  results->resize(fields.size(), -2);
+
+  std::string ns_key = AppendNamespacePrefix(user_key);
+  HashMetadata metadata(false);
+  rocksdb::Status s = getMetadata(ctx, ns_key, &metadata);
+  if (s.IsNotFound()) {
+    return rocksdb::Status::OK();
+  }
+  if (!s.ok()) return s;
+  if (!metadata.IsFieldExpirationEncoding()) {
+    return rocksdb::Status::InvalidArgument("hash field expiration is not 
supported by legacy hash encoding");
+  }
+
+  uint64_t now = now_ms.value_or(util::GetTimeStampMS());
+  std::unordered_map<std::string, int64_t> result_cache;
+  for (size_t i = 0; i < fields.size(); i++) {
+    std::string field = fields[i].ToString();
+    auto cache_iter = result_cache.find(field);
+    if (cache_iter != result_cache.end()) {
+      (*results)[i] = cache_iter->second;
+      continue;
+    }
+
+    std::string sub_key = InternalKey(ns_key, field, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
+    HashFieldState state;
+    s = LoadFieldState(storage_, ctx, metadata, sub_key, now, &state);
+    if (!s.ok()) return s;
+
+    int64_t result = -2;
+    if (state.kind == HashFieldStateKind::kPersistent) {
+      result = -1;
+    } else if (state.kind == HashFieldStateKind::kLiveTTL) {
+      result = static_cast<int64_t>(state.expire);
+    }
+    result_cache.emplace(std::move(field), result);
+    (*results)[i] = result;
+  }
+  return rocksdb::Status::OK();
+}
+
 rocksdb::Status Hash::ExpireFields(engine::Context &ctx, const Slice 
&user_key, const std::vector<Slice> &fields,
                                    uint64_t expire_at_ms, 
HashFieldExpireCondition condition,
-                                   std::vector<int64_t> *results) {
+                                   std::vector<int64_t> *results, 
std::optional<uint64_t> now_ms) {
   results->clear();
   results->resize(fields.size(), -2);
 
@@ -882,7 +926,7 @@ rocksdb::Status Hash::ExpireFields(engine::Context &ctx, 
const Slice &user_key,
   if (!s.ok()) return s;
 
   bool metadata_changed = false;
-  uint64_t now = util::GetTimeStampMS();
+  uint64_t now = now_ms.value_or(util::GetTimeStampMS());
   bool immediate = IsImmediateExpire(expire_at_ms, now);
   std::unordered_map<std::string, HashFieldState> state_cache;
 
diff --git a/src/types/redis_hash.h b/src/types/redis_hash.h
index ad6c94e2c..0728fa10f 100644
--- a/src/types/redis_hash.h
+++ b/src/types/redis_hash.h
@@ -22,6 +22,7 @@
 
 #include <rocksdb/status.h>
 
+#include <optional>
 #include <string>
 #include <vector>
 
@@ -77,9 +78,11 @@ class Hash : public SubKeyScanner {
                        std::vector<std::string> *values = nullptr);
   rocksdb::Status RandField(engine::Context &ctx, const Slice &user_key, 
int64_t command_count,
                             std::vector<FieldValue> *field_values, 
HashFetchType type = HashFetchType::kOnlyKey);
+  rocksdb::Status GetFieldsExpireTime(engine::Context &ctx, const Slice 
&user_key, const std::vector<Slice> &fields,
+                                      std::vector<int64_t> *results, 
std::optional<uint64_t> now_ms = std::nullopt);
   rocksdb::Status ExpireFields(engine::Context &ctx, const Slice &user_key, 
const std::vector<Slice> &fields,
-                               uint64_t expire_at_ms, HashFieldExpireCondition 
condition,
-                               std::vector<int64_t> *results);
+                               uint64_t expire_at_ms, HashFieldExpireCondition 
condition, std::vector<int64_t> *results,
+                               std::optional<uint64_t> now_ms = std::nullopt);
   rocksdb::Status PersistFields(engine::Context &ctx, const Slice &user_key, 
const std::vector<Slice> &fields,
                                 std::vector<int64_t> *results);
 
diff --git a/tests/cppunit/types/hash_test.cc b/tests/cppunit/types/hash_test.cc
index ddfd684e9..e678aaf5c 100644
--- a/tests/cppunit/types/hash_test.cc
+++ b/tests/cppunit/types/hash_test.cc
@@ -611,6 +611,103 @@ TEST_F(RedisHashFieldExpirationEncodingTest, 
CompactionGhostDoesNotDecrementMeta
   EXPECT_EQ(metadata.upper, before.upper);
 }
 
+TEST_F(RedisHashFieldExpirationEncodingTest, 
GetFieldsExpireTimeReturnsMissingForMissingKey) {
+  std::vector<int64_t> results;
+  auto s = hash_->GetFieldsExpireTime(*ctx_, "hfe-expire-info-missing-key", 
{"a", "b"}, &results);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(results, (std::vector<int64_t>{-2, -2}));
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
GetFieldsExpireTimeCoversPersistentLiveExpiredMissingAndDuplicates) {
+  const Slice key = "hfe-expire-info-states";
+  uint64_t ret = 0;
+  auto s = hash_->MSet(*ctx_, key, {{"persist", "1"}, {"live", "2"}, 
{"expired", "3"}}, false, &ret);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(ret, 3);
+
+  std::vector<int64_t> expire_results;
+  uint64_t now = util::GetTimeStampMS();
+  uint64_t live_expire = now + 60'000;
+  uint64_t expired_rewrite_expire = now + 120'000;
+  s = hash_->ExpireFields(*ctx_, key, {"live"}, live_expire, 
HashFieldExpireCondition::kNone, &expire_results, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  s = hash_->ExpireFields(*ctx_, key, {"expired"}, expired_rewrite_expire, 
HashFieldExpireCondition::kNone,
+                          &expire_results, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+
+  HashMetadata metadata = hashMetadata(key.ToString());
+  ASSERT_EQ(metadata.size, 3);
+  ASSERT_EQ(metadata.persist, 1);
+  uint64_t expired_at = now - 1;
+  s = putRawHashValue(key.ToString(), "expired", expired_at, "3");
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  metadata.lower = expired_at;
+  s = putHashMetadata(key.ToString(), metadata);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  HashMetadata before = hashMetadata(key.ToString());
+
+  std::vector<int64_t> results;
+  s = hash_->GetFieldsExpireTime(*ctx_, key, {"persist", "live", "expired", 
"missing", "live"}, &results, now);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(results,
+            (std::vector<int64_t>{-1, static_cast<int64_t>(live_expire), -2, 
-2, static_cast<int64_t>(live_expire)}));
+
+  HashMetadata after = hashMetadata(key.ToString());
+  EXPECT_EQ(after.size, before.size);
+  EXPECT_EQ(after.persist, before.persist);
+  EXPECT_EQ(after.lower, before.lower);
+  EXPECT_EQ(after.upper, before.upper);
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
GetFieldsExpireTimeReturnsAbsoluteMilliseconds) {
+  const Slice key = "hfe-expire-info-format";
+  uint64_t ret = 0;
+  auto s = hash_->MSet(*ctx_, key, {{"field", "1"}}, false, &ret);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(ret, 1);
+
+  uint64_t expire_at = util::GetTimeStampMS() + 60'123;
+  std::vector<int64_t> expire_results;
+  s = hash_->ExpireFields(*ctx_, key, {"field"}, expire_at, 
HashFieldExpireCondition::kNone, &expire_results);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+
+  std::vector<int64_t> results;
+  s = hash_->GetFieldsExpireTime(*ctx_, key, {"field"}, &results);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(results, (std::vector<int64_t>{static_cast<int64_t>(expire_at)}));
+}
+
+TEST_F(RedisHashFieldExpirationEncodingTest, 
GetFieldsExpireTimeDoesNotRepairCompactionGhost) {
+  const Slice key = "hfe-expire-info-ghost";
+  uint64_t ret = 0;
+  auto s = hash_->MSet(*ctx_, key, {{"ghost", "1"}}, false, &ret);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(ret, 1);
+
+  std::vector<int64_t> expire_results;
+  uint64_t future = util::GetTimeStampMS() + 60'000;
+  s = hash_->ExpireFields(*ctx_, key, {"ghost"}, future, 
HashFieldExpireCondition::kNone, &expire_results);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(expire_results, std::vector<int64_t>({1}));
+
+  HashMetadata before = hashMetadata(key.ToString());
+  ASSERT_EQ(before.size, 1);
+  ASSERT_EQ(before.persist, 0);
+  s = deleteRawHashValue(key.ToString(), "ghost");
+  ASSERT_TRUE(s.ok()) << s.ToString();
+
+  std::vector<int64_t> results;
+  s = hash_->GetFieldsExpireTime(*ctx_, key, {"ghost"}, &results);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(results, (std::vector<int64_t>{-2}));
+
+  HashMetadata after = hashMetadata(key.ToString());
+  EXPECT_EQ(after.size, before.size);
+  EXPECT_EQ(after.persist, before.persist);
+  EXPECT_EQ(after.lower, before.lower);
+  EXPECT_EQ(after.upper, before.upper);
+}
+
 TEST_F(RedisHashFieldExpirationEncodingTest, 
SizeRepairsExpiredPhysicalAndGhostMetadata) {
   const Slice key = "hfe-size-repair";
   uint64_t ret = 0;
diff --git a/tests/gocase/unit/type/hash/hash_hfe_test.go 
b/tests/gocase/unit/type/hash/hash_hfe_test.go
index 7484e2cce..cc12d30a6 100644
--- a/tests/gocase/unit/type/hash/hash_hfe_test.go
+++ b/tests/gocase/unit/type/hash/hash_hfe_test.go
@@ -41,6 +41,8 @@ const (
        hfeLiveTTLSeconds  = 300
 )
 
+const hfeMaxAbsTimeMs = int64(1<<46 - 1)
+
 func runWithFieldExpirationHash(t *testing.T, fn func(t *testing.T, rdb 
*redis.Client, ctx context.Context)) {
        t.Helper()
 
@@ -95,6 +97,25 @@ func requireHLenCommandInfoFlags(t *testing.T, rdb 
*redis.Client, ctx context.Co
        require.Equal(t, want, hlenInfo[2])
 }
 
+func requireCommandInfo(t *testing.T, rdb *redis.Client, ctx context.Context, 
command string, arity int64, readonly bool) {
+       t.Helper()
+
+       info, err := rdb.Do(ctx, "command", "info", command).Slice()
+       require.NoError(t, err)
+       require.Len(t, info, 1)
+       commandInfo := info[0].([]interface{})
+       require.Len(t, commandInfo, 6)
+       require.Equal(t, command, commandInfo[0])
+       require.Equal(t, arity, commandInfo[1])
+
+       flags := commandInfo[2].([]interface{})
+       if readonly {
+               require.Contains(t, flags, "readonly")
+       } else {
+               require.NotContains(t, flags, "readonly")
+       }
+}
+
 func waitHashFieldExpired(t *testing.T, rdb *redis.Client, ctx 
context.Context, key, field string) {
        t.Helper()
 
@@ -148,6 +169,30 @@ func requireHashValues(t *testing.T, rdb *redis.Client, 
ctx context.Context, key
        }
 }
 
+func futureUnixTimes(after time.Duration) (int64, int64) {
+       expireAt := time.Now().Add(after)
+       return expireAt.Unix(), expireAt.UnixMilli()
+}
+
+func expireCommandArgs(command, key string, ttl time.Duration, extra 
...interface{}) []interface{} {
+       args := []interface{}{command, key}
+       switch command {
+       case "hexpire":
+               args = append(args, int64(ttl/time.Second))
+       case "hpexpire":
+               args = append(args, int64(ttl/time.Millisecond))
+       case "hexpireat":
+               sec, _ := futureUnixTimes(ttl)
+               args = append(args, sec)
+       case "hpexpireat":
+               _, ms := futureUnixTimes(ttl)
+               args = append(args, ms)
+       default:
+               panic("unknown HFE expire command")
+       }
+       return append(args, extra...)
+}
+
 func TestHashFieldExpirationMetadataLifecycle(t *testing.T) {
        runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
                key := "hfe-lifecycle"
@@ -572,6 +617,169 @@ func 
TestHashFieldExpirationExpireAndPersistAcrossFieldStates(t *testing.T) {
        })
 }
 
+func TestHashFieldExpirationExpireCommandFamilyAcrossFieldStates(t *testing.T) 
{
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               for _, command := range []string{"hexpire", "hpexpire", 
"hexpireat", "hpexpireat"} {
+                       t.Run(command, func(t *testing.T) {
+                               key := "hfe-expire-family-" + command
+                               createHashFieldStates(t, rdb, ctx, key)
+
+                               requireIntArray(t, rdb.Do(ctx, 
expireCommandArgs(command, key, 10*time.Minute, "FIELDS", 4,
+                                       hfePersistentField, hfeLiveField, 
hfeExpiredField, hfeMissingField)...).Val(), []int64{1, 1, -2, -2})
+                               requireHashMetadata(t, util.GetKMetadata(t, 
rdb, ctx, key), 3, 1)
+                               require.Equal(t, map[string]string{
+                                       hfePersistentField: "10",
+                                       hfeLiveField:       "20",
+                                       hfeKeeperField:     "40",
+                               }, rdb.HGetAll(ctx, key).Val())
+                       })
+               }
+       })
+}
+
+func TestHashFieldExpirationExpireCommandFamilyConditions(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               for _, command := range []string{"hexpire", "hpexpire", 
"hexpireat", "hpexpireat"} {
+                       t.Run(command, func(t *testing.T) {
+                               key := "hfe-expire-family-conditions-" + command
+                               createHashFieldStates(t, rdb, ctx, key)
+
+                               requireIntArray(t, rdb.Do(ctx, 
expireCommandArgs(command, key, 10*time.Minute, "NX", "FIELDS", 4,
+                                       hfePersistentField, hfeLiveField, 
hfeExpiredField, hfeMissingField)...).Val(), []int64{1, 0, -2, -2})
+                               requireIntArray(t, rdb.Do(ctx, 
expireCommandArgs(command, key, 12*time.Minute, "XX", "FIELDS", 2,
+                                       hfePersistentField, 
hfeLiveField)...).Val(), []int64{1, 1})
+
+                               requireIntArray(t, rdb.Do(ctx, 
expireCommandArgs(command, key, time.Minute, "GT", "FIELDS", 2,
+                                       hfePersistentField, 
hfeLiveField)...).Val(), []int64{0, 0})
+                               requireIntArray(t, rdb.Do(ctx, 
expireCommandArgs(command, key, 30*time.Minute, "GT", "FIELDS", 1,
+                                       hfeLiveField)...).Val(), []int64{1})
+
+                               requireIntArray(t, rdb.Do(ctx, 
expireCommandArgs(command, key, 40*time.Minute, "LT", "FIELDS", 2,
+                                       hfePersistentField, 
hfeLiveField)...).Val(), []int64{0, 0})
+                               requireIntArray(t, rdb.Do(ctx, 
expireCommandArgs(command, key, 20*time.Minute, "LT", "FIELDS", 2,
+                                       hfePersistentField, 
hfeLiveField)...).Val(), []int64{0, 1})
+                               requireHashMetadata(t, util.GetKMetadata(t, 
rdb, ctx, key), 3, 1)
+                       })
+               }
+       })
+}
+
+func TestHashFieldExpirationExpireCommandFamilyImmediateDelete(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               for _, test := range []struct {
+                       name string
+                       args []interface{}
+               }{
+                       {name: "hexpire", args: []interface{}{"hexpire", "KEY", 
0, "FIELDS", 3, "a", "b", "missing"}},
+                       {name: "hpexpire", args: []interface{}{"hpexpire", 
"KEY", 0, "FIELDS", 3, "a", "b", "missing"}},
+                       {name: "hexpireat", args: []interface{}{"hexpireat", 
"KEY", time.Now().Add(-time.Minute).Unix(), "FIELDS", 3, "a", "b", "missing"}},
+                       {name: "hpexpireat", args: []interface{}{"hpexpireat", 
"KEY", time.Now().Add(-time.Minute).UnixMilli(), "FIELDS", 3, "a", "b", 
"missing"}},
+               } {
+                       t.Run(test.name, func(t *testing.T) {
+                               key := "hfe-expire-family-immediate-" + 
test.name
+                               require.Equal(t, int64(3), rdb.HSet(ctx, key, 
"a", "1", "b", "2", "keeper", "3").Val())
+                               args := append([]interface{}{}, test.args...)
+                               args[1] = key
+
+                               requireIntArray(t, rdb.Do(ctx, args...).Val(), 
[]int64{2, 2, -2})
+                               requireHashMetadata(t, util.GetKMetadata(t, 
rdb, ctx, key), 1, 1)
+                               require.Equal(t, map[string]string{"keeper": 
"3"}, rdb.HGetAll(ctx, key).Val())
+                       })
+               }
+       })
+}
+
+func TestHashFieldExpirationTTLReadCommandsAcrossFieldStates(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               key := "hfe-ttl-read-states"
+               require.Equal(t, int64(3), rdb.HSet(ctx, key, "persist", "1", 
"live", "2", "expired", "3").Val())
+               expireAtMs := time.Now().Add(2*time.Minute + 
123*time.Millisecond).UnixMilli()
+               requireIntArray(t, rdb.Do(ctx, "hpexpireat", key, expireAtMs, 
"FIELDS", 1, "live").Val(), []int64{1})
+               requireIntArray(t, rdb.Do(ctx, "hexpire", key, 1, "FIELDS", 1, 
"expired").Val(), []int64{1})
+               waitHashFieldExpired(t, rdb, ctx, key, "expired")
+               before := util.GetKMetadata(t, rdb, ctx, key)
+
+               httl := rdb.Do(ctx, "httl", key, "FIELDS", 4, "persist", 
"live", "expired", "missing").Val()
+               httlValues := httl.([]interface{})
+               require.Equal(t, int64(-1), httlValues[0])
+               require.Greater(t, httlValues[1].(int64), int64(0))
+               require.Equal(t, int64(-2), httlValues[2])
+               require.Equal(t, int64(-2), httlValues[3])
+
+               hpttl := rdb.Do(ctx, "hpttl", key, "FIELDS", 4, "persist", 
"live", "expired", "missing").Val()
+               hpttlValues := hpttl.([]interface{})
+               require.Equal(t, int64(-1), hpttlValues[0])
+               require.Greater(t, hpttlValues[1].(int64), int64(0))
+               require.LessOrEqual(t, hpttlValues[1].(int64), (2*time.Minute + 
123*time.Millisecond).Milliseconds())
+               require.Equal(t, int64(-2), hpttlValues[2])
+               require.Equal(t, int64(-2), hpttlValues[3])
+
+               requireIntArray(t, rdb.Do(ctx, "hpexpiretime", key, "FIELDS", 
4, "persist", "live", "expired", "missing").Val(),
+                       []int64{-1, expireAtMs, -2, -2})
+               expireAtSec := expireAtMs / 1000
+               if expireAtMs%1000 != 0 {
+                       expireAtSec++
+               }
+               requireIntArray(t, rdb.Do(ctx, "hexpiretime", key, "FIELDS", 4, 
"persist", "live", "expired", "missing").Val(),
+                       []int64{-1, expireAtSec, -2, -2})
+               require.Equal(t, before, util.GetKMetadata(t, rdb, ctx, key))
+       })
+}
+
+func TestHashFieldExpirationTTLReadCommandRounding(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               key := "hfe-ttl-read-rounding"
+               require.Equal(t, int64(1), rdb.HSet(ctx, key, "field", 
"1").Val())
+               expireAtMs := time.Now().Add(60*time.Second + 
123*time.Millisecond).UnixMilli()
+               if expireAtMs%1000 == 0 {
+                       expireAtMs++
+               }
+               requireIntArray(t, rdb.Do(ctx, "hpexpireat", key, expireAtMs, 
"FIELDS", 1, "field").Val(), []int64{1})
+
+               requireIntArray(t, rdb.Do(ctx, "hpexpiretime", key, "FIELDS", 
1, "field").Val(), []int64{expireAtMs})
+               requireIntArray(t, rdb.Do(ctx, "hexpiretime", key, "FIELDS", 1, 
"field").Val(), []int64{expireAtMs/1000 + 1})
+
+               httl := rdb.Do(ctx, "httl", key, "FIELDS", 1, 
"field").Val().([]interface{})[0].(int64)
+               require.GreaterOrEqual(t, httl, int64(1))
+               require.LessOrEqual(t, httl, int64(61))
+               hpttl := rdb.Do(ctx, "hpttl", key, "FIELDS", 1, 
"field").Val().([]interface{})[0].(int64)
+               require.Greater(t, hpttl, int64(0))
+               require.LessOrEqual(t, hpttl, (60*time.Second + 
123*time.Millisecond).Milliseconds())
+       })
+}
+
+func TestHashFieldExpirationCommandFamilyMetadataSequence(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               key := "hfe-command-family-metadata"
+               require.Equal(t, int64(4), rdb.HSet(ctx, key, "p", "1", "a", 
"2", "b", "3", "c", "4").Val())
+               requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, key), 4, 
4)
+
+               requireIntArray(t, rdb.Do(ctx, "hpexpire", key, 60_000, 
"FIELDS", 1, "a").Val(), []int64{1})
+               afterHExpire := util.GetKMetadata(t, rdb, ctx, key)
+               requireHashMetadata(t, afterHExpire, 4, 3)
+
+               futureSec, _ := futureUnixTimes(2 * time.Minute)
+               requireIntArray(t, rdb.Do(ctx, "hexpireat", key, futureSec, 
"FIELDS", 1, "b").Val(), []int64{1})
+               afterHExpireAt := util.GetKMetadata(t, rdb, ctx, key)
+               requireHashMetadata(t, afterHExpireAt, 4, 2)
+               require.LessOrEqual(t, afterHExpireAt.Lower, afterHExpire.Lower)
+               require.GreaterOrEqual(t, afterHExpireAt.Upper, 
afterHExpire.Upper)
+
+               pastMs := time.Now().Add(-time.Minute).UnixMilli()
+               requireIntArray(t, rdb.Do(ctx, "hpexpireat", key, pastMs, 
"FIELDS", 1, "c").Val(), []int64{2})
+               afterImmediate := util.GetKMetadata(t, rdb, ctx, key)
+               requireHashMetadata(t, afterImmediate, 3, 1)
+
+               for _, command := range []string{"httl", "hpttl", 
"hexpiretime", "hpexpiretime"} {
+                       _ = rdb.Do(ctx, command, key, "FIELDS", 4, "p", "a", 
"b", "c").Val()
+                       require.Equal(t, afterImmediate, util.GetKMetadata(t, 
rdb, ctx, key))
+               }
+
+               requireIntArray(t, rdb.Do(ctx, "hpersist", key, "FIELDS", 1, 
"a").Val(), []int64{1})
+               requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, key), 3, 
2)
+       })
+}
+
 func TestHashFieldExpirationOptionsAndDuplicates(t *testing.T) {
        runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
                key := "hfe-options"
@@ -857,8 +1065,12 @@ func 
TestHashFieldExpirationLegacyRejectsFieldTTLCommands(t *testing.T) {
 
        key := "hfe-legacy"
        require.Equal(t, int64(1), rdb.HSet(ctx, key, "a", "1").Val())
-       require.Error(t, rdb.Do(ctx, "hexpire", key, 10, "FIELDS", 1, 
"a").Err())
-       require.Error(t, rdb.Do(ctx, "hpersist", key, "FIELDS", 1, "a").Err())
+       for _, command := range []string{"hexpire", "hpexpire", "hexpireat", 
"hpexpireat"} {
+               require.ErrorContains(t, rdb.Do(ctx, command, key, 10, 
"FIELDS", 1, "a").Err(), "hash field expiration")
+       }
+       for _, command := range []string{"hpersist", "httl", "hpttl", 
"hexpiretime", "hpexpiretime"} {
+               require.ErrorContains(t, rdb.Do(ctx, command, key, "FIELDS", 1, 
"a").Err(), "hash field expiration")
+       }
        require.Equal(t, "1", rdb.HGet(ctx, key, "a").Val())
 }
 
@@ -867,149 +1079,169 @@ func TestHashFieldExpirationParseErrors(t *testing.T) {
                key := "hfe-parse"
                require.Equal(t, int64(1), rdb.HSet(ctx, key, "a", "1").Val())
 
+               for _, command := range []string{"hexpire", "hpexpire", 
"hexpireat", "hpexpireat"} {
+                       t.Run(command, func(t *testing.T) {
+                               for _, test := range []struct {
+                                       name        string
+                                       args        []interface{}
+                                       errContains string
+                               }{
+                                       {name: "missing fields clause", args: 
[]interface{}{command, key, 10}, errContains: "wrong number"},
+                                       {name: "missing fields clause after 
option", args: []interface{}{command, key, 10, "NX"}, errContains: "wrong 
number"},
+                                       {name: "missing numfields", args: 
[]interface{}{command, key, 10, "FIELDS"}, errContains: "wrong number"},
+                                       {name: "numfields is zero", args: 
[]interface{}{command, key, 10, "FIELDS", 0, "a"}, errContains: "integer"},
+                                       {name: "numfields is negative", args: 
[]interface{}{command, key, 10, "FIELDS", -1, "a"}, errContains: "integer"},
+                                       {name: "numfields is not an integer", 
args: []interface{}{command, key, 10, "FIELDS", "not-int", "a"}, errContains: 
"integer"},
+                                       {name: "numfields is out of range", 
args: []interface{}{command, key, 10, "FIELDS", "9223372036854775808", "a"}, 
errContains: "integer"},
+                                       {name: "has too few fields", args: 
[]interface{}{command, key, 10, "FIELDS", 2, "a"}, errContains: "wrong number"},
+                                       {name: "has extra unknown token after 
fields", args: []interface{}{command, key, 10, "FIELDS", 1, "a", "BAD"}, 
errContains: "syntax"},
+                                       {name: "unknown option", args: 
[]interface{}{command, key, 10, "UNKNOWN", "FIELDS", 1, "a"}, errContains: 
"syntax"},
+                                       {name: "mutually exclusive options", 
args: []interface{}{command, key, 10, "NX", "XX", "FIELDS", 1, "a"}, 
errContains: "syntax"},
+                                       {name: "mutually exclusive options 
after fields", args: []interface{}{command, key, 10, "FIELDS", 1, "a", "NX", 
"XX"}, errContains: "syntax"},
+                                       {name: "ttl is not an integer", args: 
[]interface{}{command, key, "not-int", "FIELDS", 1, "a"}, errContains: 
"integer"},
+                                       {name: "ttl is negative", args: 
[]interface{}{command, key, -1, "FIELDS", 1, "a"}, errContains: "invalid expire 
time"},
+                                       {name: "ttl has trailing characters", 
args: []interface{}{command, key, "10ms", "FIELDS", 1, "a"}, errContains: 
"integer"},
+                                       {name: "ttl is out of int64 range", 
args: []interface{}{command, key, "9223372036854775808", "FIELDS", 1, "a"}, 
errContains: "integer"},
+                               } {
+                                       t.Run(test.name, func(t *testing.T) {
+                                               require.ErrorContains(t, 
rdb.Do(ctx, test.args...).Err(), test.errContains)
+                                       })
+                               }
+                       })
+               }
+
+               for _, command := range []string{"hpersist", "httl", "hpttl", 
"hexpiretime", "hpexpiretime"} {
+                       t.Run(command, func(t *testing.T) {
+                               for _, test := range []struct {
+                                       name        string
+                                       args        []interface{}
+                                       errContains string
+                               }{
+                                       {name: "missing fields clause", args: 
[]interface{}{command, key}, errContains: "wrong number"},
+                                       {name: "wrong fields keyword", args: 
[]interface{}{command, key, "FIELD", 1, "a"}, errContains: "syntax"},
+                                       {name: "missing numfields", args: 
[]interface{}{command, key, "FIELDS"}, errContains: "wrong number"},
+                                       {name: "numfields is zero", args: 
[]interface{}{command, key, "FIELDS", 0, "a"}, errContains: "integer"},
+                                       {name: "numfields is negative", args: 
[]interface{}{command, key, "FIELDS", -1, "a"}, errContains: "integer"},
+                                       {name: "numfields is not an integer", 
args: []interface{}{command, key, "FIELDS", "not-int", "a"}, errContains: 
"integer"},
+                                       {name: "numfields is out of range", 
args: []interface{}{command, key, "FIELDS", "9223372036854775808", "a"}, 
errContains: "integer"},
+                                       {name: "has too few fields", args: 
[]interface{}{command, key, "FIELDS", 2, "a"}, errContains: "wrong number"},
+                                       {name: "has too many fields", args: 
[]interface{}{command, key, "FIELDS", 1, "a", "b"}, errContains: "wrong 
number"},
+                               } {
+                                       t.Run(test.name, func(t *testing.T) {
+                                               require.ErrorContains(t, 
rdb.Do(ctx, test.args...).Err(), test.errContains)
+                                       })
+                               }
+                       })
+               }
+       })
+}
+
+func TestHashFieldExpirationExpireCommandFamilyTimeBoundaries(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               nowMs := time.Now().UnixMilli()
+               nowSec := nowMs / 1000
+
                for _, test := range []struct {
-                       name        string
-                       args        []interface{}
-                       errContains string
+                       name    string
+                       command string
+                       valid   int64
+                       invalid int64
                }{
                        {
-                               name:        "hexpire missing fields clause",
-                               args:        []interface{}{"hexpire", key, 10},
-                               errContains: "wrong number of arguments",
-                       },
-                       {
-                               name:        "hexpire missing fields clause 
after option",
-                               args:        []interface{}{"hexpire", key, 10, 
"NX"},
-                               errContains: "wrong number of arguments",
-                       },
-                       {
-                               name:        "hexpire missing numfields",
-                               args:        []interface{}{"hexpire", key, 10, 
"FIELDS"},
-                               errContains: "wrong number of arguments",
-                       },
-                       {
-                               name:        "hexpire numfields is zero",
-                               args:        []interface{}{"hexpire", key, 10, 
"FIELDS", 0, "a"},
-                               errContains: "integer",
-                       },
-                       {
-                               name:        "hexpire numfields is negative",
-                               args:        []interface{}{"hexpire", key, 10, 
"FIELDS", -1, "a"},
-                               errContains: "integer",
-                       },
-                       {
-                               name:        "hexpire numfields is not an 
integer",
-                               args:        []interface{}{"hexpire", key, 10, 
"FIELDS", "not-int", "a"},
-                               errContains: "integer",
-                       },
-                       {
-                               name:        "hexpire numfields is out of 
range",
-                               args:        []interface{}{"hexpire", key, 10, 
"FIELDS", "9223372036854775808", "a"},
-                               errContains: "integer",
-                       },
-                       {
-                               name:        "hexpire has too few fields",
-                               args:        []interface{}{"hexpire", key, 10, 
"FIELDS", 2, "a"},
-                               errContains: "wrong number of arguments",
+                               name:    "hexpire",
+                               command: "hexpire",
+                               valid:   (hfeMaxAbsTimeMs - nowMs - 10_000) / 
1000,
+                               invalid: (hfeMaxAbsTimeMs - nowMs + 10_000) / 
1000,
                        },
                        {
-                               name:        "hexpire has too many fields",
-                               args:        []interface{}{"hexpire", key, 10, 
"FIELDS", 1, "a", "b"},
-                               errContains: "wrong number of arguments",
+                               name:    "hpexpire",
+                               command: "hpexpire",
+                               valid:   hfeMaxAbsTimeMs - nowMs - 10_000,
+                               invalid: hfeMaxAbsTimeMs - nowMs + 10_000,
                        },
                        {
-                               name:        "hexpire option after fields",
-                               args:        []interface{}{"hexpire", key, 10, 
"FIELDS", 1, "a", "NX"},
-                               errContains: "wrong number of arguments",
+                               name:    "hexpireat",
+                               command: "hexpireat",
+                               valid:   hfeMaxAbsTimeMs/1000 - 1,
+                               invalid: hfeMaxAbsTimeMs/1000 + nowSec,
                        },
                        {
-                               name:        "hexpire unknown option",
-                               args:        []interface{}{"hexpire", key, 10, 
"UNKNOWN", "FIELDS", 1, "a"},
-                               errContains: "syntax",
-                       },
-                       {
-                               name:        "hexpire duplicate option",
-                               args:        []interface{}{"hexpire", key, 10, 
"NX", "NX", "FIELDS", 1, "a"},
-                               errContains: "syntax",
-                       },
-                       {
-                               name:        "hexpire mutually exclusive 
options",
-                               args:        []interface{}{"hexpire", key, 10, 
"NX", "XX", "FIELDS", 1, "a"},
-                               errContains: "syntax",
-                       },
-                       {
-                               name:        "hexpire ttl is not an integer",
-                               args:        []interface{}{"hexpire", key, 
"not-int", "FIELDS", 1, "a"},
-                               errContains: "integer",
-                       },
-                       {
-                               name:        "hexpire ttl is negative",
-                               args:        []interface{}{"hexpire", key, -1, 
"FIELDS", 1, "a"},
-                               errContains: "invalid expire time",
-                       },
-                       {
-                               name:        "hexpire ttl has trailing 
characters",
-                               args:        []interface{}{"hexpire", key, 
"10ms", "FIELDS", 1, "a"},
-                               errContains: "integer",
-                       },
-                       {
-                               name:        "hexpire ttl is out of int64 
range",
-                               args:        []interface{}{"hexpire", key, 
"9223372036854775808", "FIELDS", 1, "a"},
-                               errContains: "integer",
-                       },
-                       {
-                               name:        "hpersist missing fields clause",
-                               args:        []interface{}{"hpersist", key},
-                               errContains: "wrong number of arguments",
-                       },
-                       {
-                               name:        "hpersist wrong fields keyword",
-                               args:        []interface{}{"hpersist", key, 
"FIELD", 1, "a"},
-                               errContains: "syntax",
-                       },
-                       {
-                               name:        "hpersist missing numfields",
-                               args:        []interface{}{"hpersist", key, 
"FIELDS"},
-                               errContains: "wrong number of arguments",
-                       },
-                       {
-                               name:        "hpersist numfields is zero",
-                               args:        []interface{}{"hpersist", key, 
"FIELDS", 0, "a"},
-                               errContains: "integer",
-                       },
-                       {
-                               name:        "hpersist numfields is negative",
-                               args:        []interface{}{"hpersist", key, 
"FIELDS", -1, "a"},
-                               errContains: "integer",
-                       },
-                       {
-                               name:        "hpersist numfields is not an 
integer",
-                               args:        []interface{}{"hpersist", key, 
"FIELDS", "not-int", "a"},
-                               errContains: "integer",
-                       },
-                       {
-                               name:        "hpersist numfields is out of 
range",
-                               args:        []interface{}{"hpersist", key, 
"FIELDS", "9223372036854775808", "a"},
-                               errContains: "integer",
-                       },
-                       {
-                               name:        "hpersist has too few fields",
-                               args:        []interface{}{"hpersist", key, 
"FIELDS", 2, "a"},
-                               errContains: "wrong number of arguments",
-                       },
-                       {
-                               name:        "hpersist has too many fields",
-                               args:        []interface{}{"hpersist", key, 
"FIELDS", 1, "a", "b"},
-                               errContains: "wrong number of arguments",
+                               name:    "hpexpireat",
+                               command: "hpexpireat",
+                               valid:   hfeMaxAbsTimeMs - 1,
+                               invalid: hfeMaxAbsTimeMs + nowMs,
                        },
                } {
                        t.Run(test.name, func(t *testing.T) {
-                               require.ErrorContains(t, rdb.Do(ctx, 
test.args...).Err(), test.errContains)
+                               require.Positive(t, test.valid)
+                               require.Greater(t, test.invalid, test.valid)
+
+                               key := "hfe-time-boundary-" + test.name
+                               require.Equal(t, int64(1), rdb.HSet(ctx, key, 
"field", "value").Val())
+                               before := util.GetKMetadata(t, rdb, ctx, key)
+
+                               requireIntArray(t, rdb.Do(ctx, test.command, 
key, test.valid, "FIELDS", 1, "field").Val(), []int64{1})
+                               require.ErrorContains(t, rdb.Do(ctx, 
test.command, key, test.invalid, "FIELDS", 1, "field").Err(), "expire time")
+                               require.Equal(t, "value", rdb.HGet(ctx, key, 
"field").Val())
+                               require.NotEqual(t, before, 
util.GetKMetadata(t, rdb, ctx, key))
                        })
                }
        })
 }
 
+func TestHashFieldExpirationExpireCommandParserRedisCompatibleSuccess(t 
*testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               for _, command := range []string{"hexpire", "hpexpire", 
"hexpireat", "hpexpireat"} {
+                       t.Run(command, func(t *testing.T) {
+                               key := "hfe-expire-parser-" + command
+                               require.Equal(t, int64(2), rdb.HSet(ctx, key, 
"a", "1", "b", "2").Val())
+
+                               requireIntArray(t, rdb.Do(ctx, 
expireCommandArgs(command, key, time.Minute, "FIELDS", 1, "a", "NX")...).Val(),
+                                       []int64{1})
+                               requireIntArray(t, rdb.Do(ctx, 
expireCommandArgs(command, key, time.Minute, "NX", "NX", "FIELDS", 1, 
"b")...).Val(),
+                                       []int64{1})
+                               requireHashMetadata(t, util.GetKMetadata(t, 
rdb, ctx, key), 2, 0)
+                       })
+               }
+       })
+}
+
+func TestHashFieldExpirationKeywordLikeFieldNames(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               key := "hfe-keyword-like-fields"
+               require.Equal(t, int64(5), rdb.HSet(ctx, key,
+                       "EX", "1",
+                       "PX", "2",
+                       "FIELDS", "3",
+                       "NX", "4",
+                       "60", "5").Val())
+
+               requireIntArray(t, rdb.Do(ctx, "hexpire", key, 120, "FIELDS", 
5, "EX", "PX", "FIELDS", "NX", "60").Val(),
+                       []int64{1, 1, 1, 1, 1})
+               requireHashMetadata(t, util.GetKMetadata(t, rdb, ctx, key), 5, 
0)
+
+               ttl := rdb.Do(ctx, "httl", key, "FIELDS", 5, "EX", "PX", 
"FIELDS", "NX", "60").Val().([]interface{})
+               for _, result := range ttl {
+                       require.Greater(t, result.(int64), int64(0))
+                       require.LessOrEqual(t, result.(int64), int64(120))
+               }
+       })
+}
+
+func TestHashFieldExpirationCommandInfoForCommandFamily(t *testing.T) {
+       runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
+               for _, command := range []string{"hexpire", "hpexpire", 
"hexpireat", "hpexpireat"} {
+                       requireCommandInfo(t, rdb, ctx, command, -6, false)
+               }
+               for _, command := range []string{"hpersist"} {
+                       requireCommandInfo(t, rdb, ctx, command, -5, false)
+               }
+               for _, command := range []string{"httl", "hpttl", 
"hexpiretime", "hpexpiretime"} {
+                       requireCommandInfo(t, rdb, ctx, command, -5, true)
+               }
+       })
+}
+
 func TestHashFieldExpirationInputCornerCases(t *testing.T) {
        runWithFieldExpirationHash(t, func(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
                t.Run("hexpire zero ttl deletes immediately", func(t 
*testing.T) {
@@ -1028,6 +1260,13 @@ func TestHashFieldExpirationInputCornerCases(t 
*testing.T) {
                        require.Equal(t, int64(0), rdb.Exists(ctx, key).Val())
                        requireIntArray(t, rdb.Do(ctx, "hexpire", key, 0, 
"FIELDS", 2, "a", "b").Val(), []int64{-2, -2})
                        requireIntArray(t, rdb.Do(ctx, "hpersist", key, 
"FIELDS", 2, "a", "b").Val(), []int64{-2, -2})
+                       for _, command := range []string{"hpexpire", 
"hexpireat", "hpexpireat"} {
+                               requireIntArray(t, rdb.Do(ctx, 
expireCommandArgs(command, key, time.Minute, "FIELDS", 2, "a", "b")...).Val(),
+                                       []int64{-2, -2})
+                       }
+                       for _, command := range []string{"httl", "hpttl", 
"hexpiretime", "hpexpiretime"} {
+                               requireIntArray(t, rdb.Do(ctx, command, key, 
"FIELDS", 2, "a", "b").Val(), []int64{-2, -2})
+                       }
                        require.Equal(t, int64(0), rdb.Exists(ctx, key).Val())
                })
 
@@ -1046,6 +1285,13 @@ func TestHashFieldExpirationInputCornerCases(t 
*testing.T) {
                        require.NoError(t, rdb.Set(ctx, key, "value", 0).Err())
                        require.ErrorContains(t, rdb.Do(ctx, "hexpire", key, 
10, "FIELDS", 1, "a").Err(), "WRONGTYPE")
                        require.ErrorContains(t, rdb.Do(ctx, "hpersist", key, 
"FIELDS", 1, "a").Err(), "WRONGTYPE")
+                       require.ErrorContains(t, rdb.Do(ctx, "hpexpire", key, 
10, "FIELDS", 1, "a").Err(), "WRONGTYPE")
+                       require.ErrorContains(t, rdb.Do(ctx, "hexpireat", key, 
10, "FIELDS", 1, "a").Err(), "WRONGTYPE")
+                       require.ErrorContains(t, rdb.Do(ctx, "hpexpireat", key, 
10, "FIELDS", 1, "a").Err(), "WRONGTYPE")
+                       require.ErrorContains(t, rdb.Do(ctx, "httl", key, 
"FIELDS", 1, "a").Err(), "WRONGTYPE")
+                       require.ErrorContains(t, rdb.Do(ctx, "hpttl", key, 
"FIELDS", 1, "a").Err(), "WRONGTYPE")
+                       require.ErrorContains(t, rdb.Do(ctx, "hexpiretime", 
key, "FIELDS", 1, "a").Err(), "WRONGTYPE")
+                       require.ErrorContains(t, rdb.Do(ctx, "hpexpiretime", 
key, "FIELDS", 1, "a").Err(), "WRONGTYPE")
                        require.Equal(t, "value", rdb.Get(ctx, key).Val())
                })
 

Reply via email to