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())
})