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

jihuayu 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 2aa3efff2 feat(hash): support HFE in WAL-to-RESP conversion (#3634)
2aa3efff2 is described below

commit 2aa3efff250b61f1fe7d6a72fc6fb84368ddcd92
Author: Twice <[email protected]>
AuthorDate: Tue Sep 22 18:57:50 2026 +0800

    feat(hash): support HFE in WAL-to-RESP conversion (#3634)
    
    Adds hash encoding context to WAL and preserves field expiration when
    converting hash writes and COPY/RENAME to RESP.
    
    Legacy hash LogData remains `2`; HFE hash writes now use `2 13 1`
    (`kRedisHash`, `kRedisCmdHSet`, `kFieldExpiration`) to follow the
    existing `<type> <operation> <args...>` convention and record the hash's
    actual encoding even when a batch contains no metadata update.
    
    Refs #3436; migration batching and kvrocks2redis full export are left
    for follow-up PRs.
    
    Assisted-by: Codex / GPT6
    
    ---------
    
    Co-authored-by: 纪华裕 <[email protected]>
---
 src/storage/batch_extractor.cc                    |  20 ++-
 src/storage/redis_db.cc                           |  32 +++-
 src/storage/redis_db.h                            |   4 +
 src/storage/redis_metadata.h                      |   1 +
 src/types/redis_hash.cc                           |  33 ++--
 tests/cppunit/batch_extractor_test.cc             | 101 ++++++++++++
 tests/gocase/unit/server/poll_updates_hfe_test.go | 179 ++++++++++++++++++++++
 7 files changed, 348 insertions(+), 22 deletions(-)

diff --git a/src/storage/batch_extractor.cc b/src/storage/batch_extractor.cc
index e545b4258..e51fa224c 100644
--- a/src/storage/batch_extractor.cc
+++ b/src/storage/batch_extractor.cc
@@ -133,9 +133,23 @@ rocksdb::Status WriteBatchExtractor::PutCF(uint32_t 
column_family_id, const Slic
     ns = ikey.GetNamespace().ToString();
 
     switch (log_data_.GetRedisType()) {
-      case kRedisHash:
-        command_args = {"HSET", user_key, sub_key, value.ToString()};
-        break;
+      case kRedisHash: {
+        auto mode = log_data_.GetHashSubkeyEncodingMode();
+        if (!mode) return rocksdb::Status::InvalidArgument(mode.Msg());
+        HashMetadata metadata(false, *mode);
+        Slice field_value = value;
+        uint64_t expire = 0;
+        auto s = metadata.DecodeSubkeyValue(&field_value, &expire);
+        if (!s.ok()) return s;
+
+        resp_commands_[ns].emplace_back(redis::ArrayOfBulkStrings({"HSET", 
user_key, sub_key, field_value.ToString()}));
+        // Replay expired writes too: the destination may still contain an 
older field value.
+        if (expire > 0) {
+          resp_commands_[ns].emplace_back(
+              redis::ArrayOfBulkStrings({"HPEXPIREAT", user_key, 
std::to_string(expire), "FIELDS", "1", sub_key}));
+        }
+        return rocksdb::Status::OK();
+      }
       case kRedisList: {
         auto args = log_data_.GetArguments();
         if (args->empty()) {
diff --git a/src/storage/redis_db.cc b/src/storage/redis_db.cc
index 118468cd9..b8483878d 100644
--- a/src/storage/redis_db.cc
+++ b/src/storage/redis_db.cc
@@ -613,10 +613,30 @@ rocksdb::Status SubKeyScanner::Scan(engine::Context &ctx, 
RedisType type, const
   return iter->status();
 }
 
+WriteBatchLogData::WriteBatchLogData(HashSubkeyEncodingMode mode) : 
type_(kRedisHash) {
+  // Keep legacy logs unchanged. A subkey-only update must describe its own 
encoding.
+  if (mode != HashSubkeyEncodingMode::kLegacy) {
+    args_ = {std::to_string(kRedisCmdHSet), 
std::to_string(static_cast<uint8_t>(mode))};
+  }
+}
+
 RedisType WriteBatchLogData::GetRedisType() const { return type_; }
 
 std::vector<std::string> *WriteBatchLogData::GetArguments() { return &args_; }
 
+StatusOr<HashSubkeyEncodingMode> 
WriteBatchLogData::GetHashSubkeyEncodingMode() const {
+  if (type_ != kRedisHash) return {Status::NotOK, "not a hash log"};
+  if (args_.empty()) return HashSubkeyEncodingMode::kLegacy;
+  if (args_.size() != 2 || args_[0] != std::to_string(kRedisCmdHSet)) {
+    return {Status::NotOK, "invalid hash encoding log"};
+  }
+  auto mode = ParseInt<uint8_t>(args_[1], 10);
+  if (!mode || *mode > 
static_cast<uint8_t>(HashSubkeyEncodingMode::kFieldExpiration)) {
+    return {Status::NotOK, "invalid hash subkey encoding mode in log"};
+  }
+  return static_cast<HashSubkeyEncodingMode>(*mode);
+}
+
 std::string WriteBatchLogData::Encode() const {
   std::string ret = std::to_string(type_);
   for (const auto &arg : args_) {
@@ -695,16 +715,22 @@ rocksdb::Status Database::Copy(engine::Context &ctx, 
const std::string &key, con
 
   if (key == new_key) return rocksdb::Status::OK();
 
+  engine::DBIterator iter(ctx, ctx.GetReadOptions());
+  iter.Seek(key);
+
   auto batch = storage_->GetWriteBatchBase();
   WriteBatchLogData log_data(type);
+  if (type == kRedisHash) {
+    HashMetadata metadata(false);
+    s = metadata.Decode(iter.Value());
+    if (!s.ok()) return s;
+    log_data = WriteBatchLogData(metadata.mode);
+  }
   s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) {
     return s;
   }
 
-  engine::DBIterator iter(ctx, ctx.GetReadOptions());
-  iter.Seek(key);
-
   if (delete_old) {
     s = batch->Delete(metadata_cf_handle_, key);
     if (!s.ok()) {
diff --git a/src/storage/redis_db.h b/src/storage/redis_db.h
index 506dc250b..d24547bf9 100644
--- a/src/storage/redis_db.h
+++ b/src/storage/redis_db.h
@@ -189,9 +189,13 @@ class WriteBatchLogData {
   WriteBatchLogData() = default;
   explicit WriteBatchLogData(RedisType type) : type_(type) {}
   explicit WriteBatchLogData(RedisType type, std::vector<std::string> &&args) 
: type_(type), args_(std::move(args)) {}
+  // Non-legacy hashes record HSET and the encoding mode. Unmarked logs retain 
legacy semantics;
+  // HFE WAL written before this marker cannot be replayed without a new full 
synchronization.
+  explicit WriteBatchLogData(HashSubkeyEncodingMode mode);
 
   RedisType GetRedisType() const;
   std::vector<std::string> *GetArguments();
+  StatusOr<HashSubkeyEncodingMode> GetHashSubkeyEncodingMode() const;
   std::string Encode() const;
   Status Decode(const rocksdb::Slice &blob);
 
diff --git a/src/storage/redis_metadata.h b/src/storage/redis_metadata.h
index 5ad5677a1..3f9a793cb 100644
--- a/src/storage/redis_metadata.h
+++ b/src/storage/redis_metadata.h
@@ -99,6 +99,7 @@ enum RedisCommand {
   kRedisCmdBitOp,
   kRedisCmdBitfield,
   kRedisCmdLMove,
+  kRedisCmdHSet,
 };
 
 constexpr const char *kErrMsgWrongType = "WRONGTYPE Operation against a key 
holding the wrong kind of value";
diff --git a/src/types/redis_hash.cc b/src/types/redis_hash.cc
index 5ba98b1f8..5020ecca7 100644
--- a/src/types/redis_hash.cc
+++ b/src/types/redis_hash.cc
@@ -221,7 +221,7 @@ bool HashMetadataEqual(const HashMetadata &lhs, const 
HashMetadata &rhs) {
 
 class HashBatchWriter {
  public:
-  explicit HashBatchWriter(rocksdb::WriteBatchBase *batch) : batch_(batch) {}
+  HashBatchWriter(rocksdb::WriteBatchBase *batch, HashSubkeyEncodingMode mode) 
: batch_(batch), mode_(mode) {}
 
   rocksdb::Status Put(const Slice &key, const Slice &value) {
     auto s = ensureLogData();
@@ -260,13 +260,14 @@ class HashBatchWriter {
  private:
   rocksdb::Status ensureLogData() {
     if (has_log_data_) return rocksdb::Status::OK();
-    WriteBatchLogData log_data(kRedisHash);
+    WriteBatchLogData log_data(mode_);
     auto s = batch_->PutLogData(log_data.Encode());
     if (s.ok()) has_log_data_ = true;
     return s;
   }
 
   rocksdb::WriteBatchBase *batch_;
+  HashSubkeyEncodingMode mode_;
   bool has_log_data_ = false;
   bool storage_mutated_ = false;
 };
@@ -381,7 +382,7 @@ rocksdb::Status Hash::Size(engine::Context &ctx, const 
Slice &user_key, uint64_t
   }
   if (metadata.upper != 0 && now > metadata.upper && metadata.persist == 0) {
     auto batch = storage_->GetWriteBatchBase();
-    WriteBatchLogData log_data(kRedisHash);
+    WriteBatchLogData log_data(metadata.mode);
     s = batch->PutLogData(log_data.Encode());
     if (!s.ok()) return s;
     s = batch->Delete(metadata_cf_handle_, ns_key);
@@ -409,7 +410,7 @@ rocksdb::Status Hash::scanAndRepair(engine::Context &ctx, 
const Slice &ns_key, H
   repaired_metadata.upper = 0;
 
   auto batch = storage_->GetWriteBatchBase();
-  WriteBatchLogData log_data(kRedisHash);
+  WriteBatchLogData log_data(metadata->mode);
   auto s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) return s;
 
@@ -522,7 +523,7 @@ rocksdb::Status Hash::IncrBy(engine::Context &ctx, const 
Slice &user_key, const
 
   *new_value = old_value + increment;
   auto batch = storage_->GetWriteBatchBase();
-  WriteBatchLogData log_data(kRedisHash);
+  WriteBatchLogData log_data(metadata.mode);
   s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) return s;
   std::string encoded_value = 
metadata.EncodeSubkeyValue(std::to_string(*new_value), keep_expire);
@@ -582,7 +583,7 @@ rocksdb::Status Hash::IncrByFloat(engine::Context &ctx, 
const Slice &user_key, c
 
   *new_value = n;
   auto batch = storage_->GetWriteBatchBase();
-  WriteBatchLogData log_data(kRedisHash);
+  WriteBatchLogData log_data(metadata.mode);
   s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) return s;
   std::string encoded_value = 
metadata.EncodeSubkeyValue(util::Float2String(*new_value), keep_expire);
@@ -715,7 +716,7 @@ rocksdb::Status Hash::SetFieldsWithExpire(engine::Context 
&ctx, const Slice &use
   }
 
   auto batch = storage_->GetWriteBatchBase();
-  HashBatchWriter writer(batch.Get());
+  HashBatchWriter writer(batch.Get(), metadata.mode);
   for (const auto &sub_key : expired_cleanup_keys) {
     s = writer.Delete(sub_key);
     if (!s.ok()) return s;
@@ -860,7 +861,7 @@ rocksdb::Status Hash::GetFieldsWithExpire(engine::Context 
&ctx, const Slice &use
   if (!s.ok()) return s;
 
   auto batch = storage_->GetWriteBatchBase();
-  HashBatchWriter writer(batch.Get());
+  HashBatchWriter writer(batch.Get(), metadata.mode);
   auto make_sub_key = [&](const std::string &field) {
     return InternalKey(ns_key, field, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
   };
@@ -957,14 +958,14 @@ rocksdb::Status Hash::Delete(engine::Context &ctx, const 
Slice &user_key, const
   std::string ns_key = AppendNamespacePrefix(user_key);
 
   HashMetadata metadata(false);
+  auto s = getMetadata(ctx, ns_key, &metadata);
+  if (!s.ok()) return s.IsNotFound() ? rocksdb::Status::OK() : s;
+
   auto batch = storage_->GetWriteBatchBase();
-  WriteBatchLogData log_data(kRedisHash);
-  auto s = batch->PutLogData(log_data.Encode());
+  WriteBatchLogData log_data(metadata.mode);
+  s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) return s;
 
-  s = getMetadata(ctx, ns_key, &metadata);
-  if (!s.ok()) return s.IsNotFound() ? rocksdb::Status::OK() : s;
-
   uint64_t physical_removed = 0;
   uint64_t persistent_removed = 0;
   std::unordered_set<std::string_view> field_set;
@@ -1032,7 +1033,7 @@ rocksdb::Status Hash::MSet(engine::Context &ctx, const 
Slice &user_key, const st
   int added = 0;
   bool metadata_changed = ttl_updated;
   auto batch = storage_->GetWriteBatchBase();
-  WriteBatchLogData log_data(kRedisHash);
+  WriteBatchLogData log_data(metadata.mode);
   s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) return s;
   std::unordered_set<std::string_view> field_set;
@@ -1371,7 +1372,7 @@ rocksdb::Status Hash::ExpireFields(engine::Context &ctx, 
const Slice &user_key,
   }
 
   auto batch = storage_->GetWriteBatchBase();
-  WriteBatchLogData log_data(kRedisHash);
+  WriteBatchLogData log_data(metadata.mode);
   s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) return s;
 
@@ -1465,7 +1466,7 @@ rocksdb::Status Hash::PersistFields(engine::Context &ctx, 
const Slice &user_key,
   }
 
   auto batch = storage_->GetWriteBatchBase();
-  WriteBatchLogData log_data(kRedisHash);
+  WriteBatchLogData log_data(metadata.mode);
   s = batch->PutLogData(log_data.Encode());
   if (!s.ok()) return s;
 
diff --git a/tests/cppunit/batch_extractor_test.cc 
b/tests/cppunit/batch_extractor_test.cc
new file mode 100644
index 000000000..06b121e87
--- /dev/null
+++ b/tests/cppunit/batch_extractor_test.cc
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "storage/batch_extractor.h"
+
+#include <gtest/gtest.h>
+
+#include "server/redis_reply.h"
+
+TEST(WriteBatchLogData, HashEncoding) {
+  redis::WriteBatchLogData legacy(HashSubkeyEncodingMode::kLegacy);
+  redis::WriteBatchLogData hfe(HashSubkeyEncodingMode::kFieldExpiration);
+  EXPECT_EQ(legacy.Encode(), "2");
+  EXPECT_EQ(hfe.Encode(), "2 13 1");
+
+  redis::WriteBatchLogData decoded;
+  for (const auto &log : {hfe, legacy}) {
+    ASSERT_TRUE(decoded.Decode(log.Encode()).IsOK());
+    auto mode = decoded.GetHashSubkeyEncodingMode();
+    ASSERT_TRUE(mode.IsOK());
+    EXPECT_EQ(*mode, *log.GetHashSubkeyEncodingMode());
+  }
+}
+
+class HashBatchExtractorTest : public ::testing::TestWithParam<bool> {};
+
+TEST_P(HashBatchExtractorTest, MixedEncodingsWithoutMetadata) {
+  bool slot_encoded = GetParam();
+  HashMetadata hfe(false, HashSubkeyEncodingMode::kFieldExpiration);
+  auto ns_key = ComposeNamespaceKey("ns", "hash", slot_encoded);
+  auto key = InternalKey(ns_key, "field", 1, slot_encoded).Encode();
+  const std::string binary_value("\0value\xff", 7);
+  const auto legacy_value = hfe.EncodeSubkeyValue(binary_value, 123);
+  rocksdb::WriteBatch batch;
+  std::vector<std::string> expected;
+
+  // Include an already-expired timestamp: replay must remove any older 
destination value.
+  for (uint64_t expire : {0ULL, 1ULL, 4000000000000ULL}) {
+    
ASSERT_TRUE(batch.PutLogData(redis::WriteBatchLogData(hfe.mode).Encode()).ok());
+    ASSERT_TRUE(batch.Put(key, hfe.EncodeSubkeyValue(binary_value, 
expire)).ok());
+    expected.emplace_back(redis::ArrayOfBulkStrings({"HSET", "hash", "field", 
binary_value}));
+    if (expire != 0) {
+      expected.emplace_back(
+          redis::ArrayOfBulkStrings({"HPEXPIREAT", "hash", 
std::to_string(expire), "FIELDS", "1", "field"}));
+    }
+
+    // An old-format log must reset the mode, even if its value looks like HFE 
data.
+    
ASSERT_TRUE(batch.PutLogData(redis::WriteBatchLogData(kRedisHash).Encode()).ok());
+    ASSERT_TRUE(batch.Put(key, legacy_value).ok());
+    expected.emplace_back(redis::ArrayOfBulkStrings({"HSET", "hash", "field", 
legacy_value}));
+  }
+  
ASSERT_TRUE(batch.PutLogData(redis::WriteBatchLogData(hfe.mode).Encode()).ok());
+  ASSERT_TRUE(batch.Put(key, hfe.EncodeSubkeyValue("")).ok());
+  expected.emplace_back(redis::ArrayOfBulkStrings({"HSET", "hash", "field", 
""}));
+  ASSERT_TRUE(batch.Delete(key).ok());
+  expected.emplace_back(redis::ArrayOfBulkStrings({"HDEL", "hash", "field"}));
+
+  WriteBatchExtractor extractor(slot_encoded, -1, true);
+  ASSERT_TRUE(batch.Iterate(&extractor).ok());
+  ASSERT_EQ(extractor.GetRESPCommands()->size(), 1);
+  EXPECT_EQ(extractor.GetRESPCommands()->at("ns"), expected);
+}
+
+TEST_P(HashBatchExtractorTest, InvalidHashEncoding) {
+  auto ns_key = ComposeNamespaceKey("ns", "hash", GetParam());
+  auto key = InternalKey(ns_key, "field", 1, GetParam()).Encode();
+  for (const auto &log : {"2 13", "2 13 2", "2 13 -1", "2 13 invalid", "2 13 1 
extra"}) {
+    SCOPED_TRACE(log);
+    rocksdb::WriteBatch batch;
+    ASSERT_TRUE(batch.PutLogData(log).ok());
+    ASSERT_TRUE(batch.Put(key, std::string(8, '\0')).ok());
+    WriteBatchExtractor extractor(GetParam(), -1, true);
+    EXPECT_TRUE(batch.Iterate(&extractor).IsInvalidArgument());
+    EXPECT_TRUE(extractor.GetRESPCommands()->empty());
+  }
+
+  rocksdb::WriteBatch batch;
+  
ASSERT_TRUE(batch.PutLogData(redis::WriteBatchLogData(HashSubkeyEncodingMode::kFieldExpiration).Encode()).ok());
+  ASSERT_TRUE(batch.Put(key, std::string(7, '\0')).ok());
+  WriteBatchExtractor extractor(GetParam(), -1, true);
+  EXPECT_TRUE(batch.Iterate(&extractor).IsInvalidArgument());
+  EXPECT_TRUE(extractor.GetRESPCommands()->empty());
+}
+
+INSTANTIATE_TEST_SUITE_P(SlotEncoding, HashBatchExtractorTest, 
::testing::Bool());
diff --git a/tests/gocase/unit/server/poll_updates_hfe_test.go 
b/tests/gocase/unit/server/poll_updates_hfe_test.go
new file mode 100644
index 000000000..79d4c4a2f
--- /dev/null
+++ b/tests/gocase/unit/server/poll_updates_hfe_test.go
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package server
+
+import (
+       "context"
+       "strconv"
+       "testing"
+       "time"
+
+       "github.com/redis/go-redis/v9"
+       "github.com/stretchr/testify/require"
+
+       "github.com/apache/kvrocks/tests/gocase/util"
+)
+
+func TestPollUpdatesHashFieldExpiration(t *testing.T) {
+       for _, txn := range []string{"no", "yes"} {
+               ctx := context.Background()
+               source := util.StartServer(t, 
map[string]string{"txn-context-enabled": txn, "hash-encoding-mode": 
"field-expiration"})
+               defer source.Close()
+               destination := util.StartServer(t, 
map[string]string{"hash-encoding-mode": "field-expiration"})
+               defer destination.Close()
+               src, dst := source.NewClient(), destination.NewClient()
+               defer func() { require.NoError(t, src.Close()) }()
+               defer func() { require.NoError(t, dst.Close()) }()
+               t.Run("txn-context="+txn, func(t *testing.T) {
+                       var sequence int64
+                       replay := func(t *testing.T, expected ...[]string) {
+                               t.Helper()
+                               result, err := src.Do(ctx, "POLLUPDATES", 
sequence, "MAX", 1000, "FORMAT", "RESP").Result()
+                               require.NoError(t, err)
+                               updates := parsePollUpdatesResult(t, 
result.(map[any]any), true)
+                               require.Equal(t, updates.LatestSeq, 
updates.NextSeq)
+                               require.Equal(t, []any{RESPFormat{Namespace: 
"default", Commands: expected}}, updates.Updates)
+                               sequence = updates.NextSeq
+                               for _, command := range 
updates.Updates[0].(RESPFormat).Commands {
+                                       args := make([]any, len(command))
+                                       for i, arg := range command {
+                                               args[i] = arg
+                                       }
+                                       require.NoError(t, dst.Do(ctx, 
args...).Err())
+                               }
+                       }
+                       set := func(key, field, value string) []string { return 
[]string{"HSET", key, field, value} }
+                       expire := func(key, field string, at int64) []string {
+                               return []string{"HPEXPIREAT", key, 
strconv.FormatInt(at, 10), "FIELDS", "1", field}
+                       }
+                       checkField := func(t *testing.T, key, field, value 
string, at int64) {
+                               t.Helper()
+                               gotValue, err := dst.HGet(ctx, key, 
field).Result()
+                               require.NoError(t, err)
+                               require.Equal(t, value, gotValue)
+                               got, err := dst.Do(ctx, "HPEXPIRETIME", key, 
"FIELDS", 1, field).Int64Slice()
+                               require.NoError(t, err)
+                               require.Equal(t, []int64{at}, got)
+                       }
+
+                       t.Run("mixed encodings in a transaction", func(t 
*testing.T) {
+                               binary := 
"\x00\x00\x00\x00\x00\x00\x00\x01payload\x00\xff"
+                               require.NoError(t, src.ConfigSet(ctx, 
"hash-encoding-mode", "legacy").Err())
+                               require.NoError(t, src.HSet(ctx, "legacy", "f", 
binary).Err())
+                               require.NoError(t, src.ConfigSet(ctx, 
"hash-encoding-mode", "field-expiration").Err())
+                               require.NoError(t, src.HSet(ctx, "hfe", "f", 
"initial").Err())
+                               replay(t, set("legacy", "f", binary), 
set("hfe", "f", "initial"))
+                               require.NoError(t, src.ConfigSet(ctx, 
"hash-encoding-mode", "legacy").Err())
+                               _, err := src.TxPipelined(ctx, func(pipe 
redis.Pipeliner) error {
+                                       pipe.HSet(ctx, "hfe", "f", "updated")
+                                       pipe.HSet(ctx, "legacy", "f", 
binary+"updated")
+                                       return nil
+                               })
+                               require.NoError(t, err)
+                               replay(t, set("hfe", "f", "updated"), 
set("legacy", "f", binary+"updated"))
+                               checkField(t, "hfe", "f", "updated", -1)
+                               checkField(t, "legacy", "f", binary+"updated", 
-1)
+                               require.NoError(t, src.ConfigSet(ctx, 
"hash-encoding-mode", "field-expiration").Err())
+                       })
+
+                       t.Run("field TTL transitions and subkey-only updates", 
func(t *testing.T) {
+                               at := time.Now().Add(time.Minute).UnixMilli()
+                               for _, step := range []struct {
+                                       args  []any
+                                       value string
+                                       ttl   int64
+                               }{
+                                       {[]any{"HSET", "counter", "f", "10"}, 
"10", -1},
+                                       {[]any{"HPEXPIREAT", "counter", at, 
"FIELDS", 1, "f"}, "10", at},
+                                       {[]any{"HINCRBY", "counter", "f", 1}, 
"11", at},
+                                       {[]any{"HINCRBYFLOAT", "counter", "f", 
0.5}, "11.5", at},
+                                       {[]any{"HSETEX", "counter", "KEEPTTL", 
"FIELDS", 1, "f", "12"}, "12", at},
+                                       {[]any{"HGETEX", "counter", "PXAT", at 
+ 1000, "FIELDS", 1, "f"}, "12", at + 1000},
+                                       {[]any{"HPERSIST", "counter", "FIELDS", 
1, "f"}, "12", -1},
+                                       {[]any{"HSETEX", "counter", "PXAT", at, 
"FIELDS", 1, "f", "13"}, "13", at},
+                                       {[]any{"HGETEX", "counter", "PERSIST", 
"FIELDS", 1, "f"}, "13", -1},
+                                       {[]any{"HPEXPIREAT", "counter", at, 
"FIELDS", 1, "f"}, "13", at},
+                                       {[]any{"HSET", "counter", "f", "14"}, 
"14", -1},
+                               } {
+                                       require.NoError(t, src.Do(ctx, 
step.args...).Err())
+                                       commands := [][]string{set("counter", 
"f", step.value)}
+                                       if step.ttl > 0 {
+                                               commands = append(commands, 
expire("counter", "f", step.ttl))
+                                       }
+                                       replay(t, commands...)
+                                       checkField(t, "counter", "f", 
step.value, step.ttl)
+                               }
+                               require.NoError(t, src.HDel(ctx, "counter", 
"f").Err())
+                               replay(t, []string{"HDEL", "counter", "f"})
+                               require.ErrorIs(t, dst.HGet(ctx, "counter", 
"f").Err(), redis.Nil)
+                       })
+
+                       t.Run("key TTL and hash log arguments coexist", func(t 
*testing.T) {
+                               // Legacy metadata stores key expiration with 
second precision.
+                               at := 
time.Now().Add(time.Minute).Truncate(time.Second).UnixMilli()
+                               require.NoError(t, src.HSet(ctx, "key-ttl", 
"f", "v").Err())
+                               require.NoError(t, src.Do(ctx, "PEXPIREAT", 
"key-ttl", at).Err())
+                               require.NoError(t, src.HSet(ctx, "key-ttl", 
"g", "w").Err())
+                               replay(t, set("key-ttl", "f", "v"), 
[]string{"PEXPIREAT", "key-ttl", strconv.FormatInt(at, 10)}, set("key-ttl", 
"g", "w"))
+                               require.Equal(t, at, dst.Do(ctx, "PEXPIRETIME", 
"key-ttl").Val())
+                               checkField(t, "key-ttl", "g", "w", -1)
+                       })
+
+                       t.Run("copy and rename preserve field TTL", func(t 
*testing.T) {
+                               at := time.Now().Add(time.Minute).UnixMilli()
+                               require.NoError(t, src.Do(ctx, "HSETEX", 
"original", "PXAT", at, "FIELDS", 1, "f", "value").Err())
+                               replay(t, set("original", "f", "value"), 
expire("original", "f", at))
+                               require.NoError(t, src.ConfigSet(ctx, 
"hash-encoding-mode", "legacy").Err())
+                               require.EqualValues(t, 1, src.Copy(ctx, 
"original", "copied", 0, false).Val())
+                               replay(t, set("copied", "f", "value"), 
expire("copied", "f", at))
+                               checkField(t, "copied", "f", "value", at)
+                               require.NoError(t, src.Rename(ctx, "copied", 
"renamed").Err())
+                               replay(t, []string{"DEL", "copied"}, 
set("renamed", "f", "value"), expire("renamed", "f", at))
+                               checkField(t, "renamed", "f", "value", at)
+                               exists, err := dst.Exists(ctx, 
"copied").Result()
+                               require.NoError(t, err)
+                               require.Zero(t, exists)
+                               require.NoError(t, src.ConfigSet(ctx, 
"hash-encoding-mode", "field-expiration").Err())
+                       })
+
+                       t.Run("natural expiration and delayed replay", func(t 
*testing.T) {
+                               at := time.Now().Add(3 * 
time.Second).UnixMilli()
+                               require.NoError(t, src.Do(ctx, "HSETEX", 
"expiry", "PXAT", at, "FIELDS", 2, "live", "v", "persist", "p").Err())
+                               replay(t, set("expiry", "live", "v"), 
expire("expiry", "live", at), set("expiry", "persist", "p"), expire("expiry", 
"persist", at))
+                               checkField(t, "expiry", "live", "v", at)
+                               checkField(t, "expiry", "persist", "p", at)
+                               require.NoError(t, src.Do(ctx, "HPERSIST", 
"expiry", "FIELDS", 1, "persist").Err())
+                               replay(t, set("expiry", "persist", "p"))
+                               checkField(t, "expiry", "persist", "p", -1)
+
+                               // Leave this source write unread until its 
deadline has passed.
+                               require.NoError(t, src.Do(ctx, "HSETEX", 
"expiry", "PXAT", at, "FIELDS", 1, "delayed", "new").Err())
+                               require.NoError(t, dst.HSet(ctx, "expiry", 
"delayed", "old").Err())
+                               checkField(t, "expiry", "delayed", "old", -1)
+                               require.Eventually(t, func() bool {
+                                       return time.Now().UnixMilli() > at && 
dst.HGet(ctx, "expiry", "live").Err() == redis.Nil
+                               }, 10*time.Second, 10*time.Millisecond)
+                               checkField(t, "expiry", "persist", "p", -1)
+                               replay(t, set("expiry", "delayed", "new"), 
expire("expiry", "delayed", at))
+                               require.ErrorIs(t, dst.HGet(ctx, "expiry", 
"delayed").Err(), redis.Nil)
+                       })
+               })
+       }
+}

Reply via email to