This is an automated email from the ASF dual-hosted git repository. PragmaTwice pushed a commit to branch feat/hash-hfe-command-family in repository https://gitbox.apache.org/repos/asf/kvrocks.git
commit 24c01e3cdf3ec7d98c029a6da5288ab990884ec1 Author: PragmaTwice <[email protected]> AuthorDate: Sat May 30 13:02:41 2026 +0800 feat(hash): complete HFE command family --- 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()) })
