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 b29d8c4e1 test(cluster): cover HFE replication and slot migration 
(#3632)
b29d8c4e1 is described below

commit b29d8c4e161b0449d579ccad973aed2519834997
Author: Twice <[email protected]>
AuthorDate: Tue Sep 22 16:01:41 2026 +0800

    test(cluster): cover HFE replication and slot migration (#3632)
    
    Adds HFE coverage for full/incremental replication and snapshot/WAL slot
    migration, including mixed hash encodings, metadata preservation, and
    field expiration after transfer.
    
    Refs #3436.
    
    Assisted-by: Codex
---
 .../hash_field_expiration_commands_test.go         | 111 +++++++++++
 .../slotmigrate/hash_field_expiration_test.go      | 207 +++++++++++++++++++++
 2 files changed, 318 insertions(+)

diff --git 
a/tests/gocase/integration/replication/hash_field_expiration_commands_test.go 
b/tests/gocase/integration/replication/hash_field_expiration_commands_test.go
index db3138268..46bf76dde 100644
--- 
a/tests/gocase/integration/replication/hash_field_expiration_commands_test.go
+++ 
b/tests/gocase/integration/replication/hash_field_expiration_commands_test.go
@@ -47,6 +47,117 @@ func waitForHashFieldToExpire(t *testing.T, rdb 
*redis.Client, ctx context.Conte
        }, 5*time.Second, 50*time.Millisecond)
 }
 
+func TestHashFieldExpirationReplication(t *testing.T) {
+       ctx := context.Background()
+       master := util.StartServer(t, util.KvrocksServerConfigs{
+               "hash-encoding-mode":               "legacy",
+               "rocksdb.disable_auto_compactions": "yes",
+               "use-rsid-psync":                   "yes",
+       })
+       defer master.Close()
+       masterClient := master.NewClient()
+       defer func() { require.NoError(t, masterClient.Close()) }()
+
+       replica := util.StartServer(t, util.KvrocksServerConfigs{
+               "hash-encoding-mode":               "legacy",
+               "rocksdb.disable_auto_compactions": "yes",
+               "use-rsid-psync":                   "yes",
+       })
+       defer replica.Close()
+       replicaClient := replica.NewClient()
+       defer func() { require.NoError(t, replicaClient.Close()) }()
+
+       legacyKey := "legacy-hash"
+       binaryValue := "\x00\x00\x00\x00\x00\x00\x00\x00value\xff"
+       require.NoError(t, masterClient.HSet(ctx, legacyKey, "field", 
binaryValue).Err())
+       require.NoError(t, masterClient.ConfigSet(ctx, "hash-encoding-mode", 
"field-expiration").Err())
+       key := "hfe-hash"
+       require.NoError(t, masterClient.HSet(ctx, key, "persistent", 
binaryValue, "live", "10", "expired", "gone").Err())
+       expireAt := time.Now().Add(10 * time.Minute).UnixMilli()
+       require.NoError(t, masterClient.Do(ctx, "hpexpireat", key, expireAt, 
"FIELDS", 1, "live").Err())
+       expiredValue, err := masterClient.HGet(ctx, key, "expired").Result()
+       require.NoError(t, err)
+       require.Equal(t, "gone", expiredValue)
+       expiredResult, err := masterClient.Do(ctx, "hpexpire", key, 1000, 
"FIELDS", 1, "expired").Result()
+       require.NoError(t, err)
+       requireHFEArray(t, expiredResult, int64(1))
+       require.NoError(t, masterClient.PExpireAt(ctx, key, 
time.Now().Add(20*time.Minute)).Err())
+       // This field must already be expired on the source before the 
checkpoint is taken.
+       waitForHashFieldToExpire(t, masterClient, ctx, key, "expired")
+
+       metadata := util.GetKMetadata(t, masterClient, ctx, key)
+       require.Equal(t, "field-expiration", metadata.Mode)
+       require.Equal(t, int64(3), metadata.Size)
+       require.Equal(t, int64(1), metadata.Persist)
+
+       requireReplicatedHash := func(t *testing.T, values map[string]string, 
expires ...interface{}) {
+               t.Helper()
+               util.WaitForOffsetSync(t, masterClient, replicaClient, 
5*time.Second)
+               got, err := replicaClient.HGetAll(ctx, key).Result()
+               require.NoError(t, err)
+               require.Equal(t, values, got)
+               fieldExpires, err := replicaClient.Do(ctx, "hpexpiretime", key, 
"FIELDS", 3, "persistent", "live", "expired").Result()
+               require.NoError(t, err)
+               requireHFEArray(t, fieldExpires, expires...)
+               require.Equal(t, util.GetKMetadata(t, masterClient, ctx, key), 
util.GetKMetadata(t, replicaClient, ctx, key))
+       }
+
+       expiringKey := "hfe-full-sync-expiry"
+       require.NoError(t, masterClient.HSet(ctx, expiringKey, "field", 
"value", "keeper", "persistent").Err())
+       fieldExpireAt := time.Now().Add(3 * time.Second).UnixMilli()
+       require.NoError(t, masterClient.Do(ctx, "hpexpireat", expiringKey, 
fieldExpireAt, "FIELDS", 1, "field").Err())
+
+       // Distinct replication histories force a checkpoint transfer even 
while the source WAL is retained.
+       util.SlaveOf(t, replicaClient, master)
+       util.WaitForSync(t, replicaClient)
+       require.Equal(t, "1", util.FindInfoEntry(masterClient, "sync_full"))
+       requireReplicatedHash(t, map[string]string{"persistent": binaryValue, 
"live": "10"}, int64(-1), expireAt, int64(-2))
+       require.Equal(t, metadata, util.GetKMetadata(t, replicaClient, ctx, 
key))
+       got, err := replicaClient.HGet(ctx, legacyKey, "field").Result()
+       require.NoError(t, err)
+       require.Equal(t, binaryValue, got)
+       require.Equal(t, "legacy", util.GetKMetadata(t, replicaClient, ctx, 
legacyKey).Mode)
+
+       got, err = replicaClient.HGet(ctx, expiringKey, "field").Result()
+       require.NoError(t, err)
+       require.Equal(t, "value", got)
+       fieldExpires, err := replicaClient.Do(ctx, "hpexpiretime", expiringKey, 
"FIELDS", 1, "field").Result()
+       require.NoError(t, err)
+       requireHFEArray(t, fieldExpires, fieldExpireAt)
+       waitForHashFieldToExpire(t, replicaClient, ctx, expiringKey, "field")
+       require.GreaterOrEqual(t, time.Now().UnixMilli(), fieldExpireAt)
+       fields, err := replicaClient.HGetAll(ctx, expiringKey).Result()
+       require.NoError(t, err)
+       require.Equal(t, map[string]string{"keeper": "persistent"}, fields)
+
+       // Subsequent updates must use WAL replication without another full 
sync.
+       require.NoError(t, masterClient.HIncrBy(ctx, key, "live", 5).Err())
+       requireReplicatedHash(t, map[string]string{"persistent": binaryValue, 
"live": "15"}, int64(-1), expireAt, int64(-2))
+       require.Equal(t, metadata, util.GetKMetadata(t, replicaClient, ctx, 
key))
+
+       require.NoError(t, masterClient.Do(ctx, "hpexpireat", key, expireAt, 
"FIELDS", 1, "persistent").Err())
+       requireReplicatedHash(t, map[string]string{"persistent": binaryValue, 
"live": "15"}, expireAt, expireAt, int64(-2))
+       require.NoError(t, masterClient.Do(ctx, "hpersist", key, "FIELDS", 1, 
"live").Err())
+       requireReplicatedHash(t, map[string]string{"persistent": binaryValue, 
"live": "15"}, expireAt, int64(-1), int64(-2))
+       require.NoError(t, masterClient.HSet(ctx, key, "persistent", 
"updated").Err())
+       requireReplicatedHash(t, map[string]string{"persistent": "updated", 
"live": "15"}, int64(-1), int64(-1), int64(-2))
+
+       require.NoError(t, masterClient.HSet(ctx, legacyKey, "field", 
binaryValue+"updated").Err())
+       util.WaitForOffsetSync(t, masterClient, replicaClient, 5*time.Second)
+       got, err = replicaClient.HGet(ctx, legacyKey, "field").Result()
+       require.NoError(t, err)
+       require.Equal(t, binaryValue+"updated", got)
+       require.Equal(t, "legacy", util.GetKMetadata(t, replicaClient, ctx, 
legacyKey).Mode)
+
+       incrementalExpireAt := time.Now().Add(3 * time.Second).UnixMilli()
+       require.NoError(t, masterClient.Do(ctx, "hpexpireat", key, 
incrementalExpireAt, "FIELDS", 1, "live").Err())
+       requireReplicatedHash(t, map[string]string{"persistent": "updated", 
"live": "15"}, int64(-1), incrementalExpireAt, int64(-2))
+       waitForHashFieldToExpire(t, replicaClient, ctx, key, "live")
+       require.GreaterOrEqual(t, time.Now().UnixMilli(), incrementalExpireAt)
+       requireReplicatedHash(t, map[string]string{"persistent": "updated"}, 
int64(-1), int64(-2), int64(-2))
+       require.Equal(t, "1", util.FindInfoEntry(masterClient, "sync_full"))
+}
+
 func TestHashFieldExpirationHSetExHGetExReplication(t *testing.T) {
        configs := util.KvrocksServerConfigs{
                "hash-encoding-mode":               "field-expiration",
diff --git a/tests/gocase/integration/slotmigrate/hash_field_expiration_test.go 
b/tests/gocase/integration/slotmigrate/hash_field_expiration_test.go
new file mode 100644
index 000000000..1cf489ae3
--- /dev/null
+++ b/tests/gocase/integration/slotmigrate/hash_field_expiration_test.go
@@ -0,0 +1,207 @@
+/*
+ * 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 slotmigrate
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/apache/kvrocks/tests/gocase/util"
+       "github.com/redis/go-redis/v9"
+       "github.com/stretchr/testify/require"
+)
+
+func TestSlotMigrateHashFieldExpiration(t *testing.T) {
+       ctx := context.Background()
+       source := util.StartServer(t, util.KvrocksServerConfigs{
+               "cluster-enabled":                  "yes",
+               "hash-encoding-mode":               "legacy",
+               "rocksdb.disable_auto_compactions": "yes",
+               "migrate-batch-size-kb":            "1",
+       })
+       defer source.Close()
+       sourceClient := source.NewClient()
+       defer func() { require.NoError(t, sourceClient.Close()) }()
+       sourceID := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx00"
+       require.NoError(t, sourceClient.Do(ctx, "clusterx", "SETNODEID", 
sourceID).Err())
+
+       destination := util.StartServer(t, util.KvrocksServerConfigs{
+               "cluster-enabled":                  "yes",
+               "hash-encoding-mode":               "legacy",
+               "rocksdb.disable_auto_compactions": "yes",
+       })
+       defer destination.Close()
+       destinationClient := destination.NewClient()
+       defer func() { require.NoError(t, destinationClient.Close()) }()
+       destinationID := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx01"
+       require.NoError(t, destinationClient.Do(ctx, "clusterx", "SETNODEID", 
destinationID).Err())
+       nodes := fmt.Sprintf("%s %s %d master - 0-10000\n%s %s %d master - 
10001-16383",
+               sourceID, source.Host(), source.Port(), destinationID, 
destination.Host(), destination.Port())
+       require.NoError(t, sourceClient.Do(ctx, "clusterx", "SETNODES", nodes, 
"1").Err())
+       require.NoError(t, destinationClient.Do(ctx, "clusterx", "SETNODES", 
nodes, "1").Err())
+
+       for slot, phase := range []string{"snapshot", "wal"} {
+               t.Run(phase, func(t *testing.T) {
+                       key := fmt.Sprintf("hfe_{%s}", util.SlotTable[slot])
+                       legacyKey := fmt.Sprintf("legacy_{%s}", 
util.SlotTable[slot])
+                       binaryValue := strings.Repeat("\x00", 8) + 
strings.Repeat("value\xff", 512)
+                       require.NoError(t, sourceClient.ConfigSet(ctx, 
"hash-encoding-mode", "legacy").Err())
+                       require.NoError(t, sourceClient.HSet(ctx, legacyKey, 
"field", binaryValue).Err())
+                       require.NoError(t, sourceClient.ConfigSet(ctx, 
"hash-encoding-mode", "field-expiration").Err())
+                       require.NoError(t, sourceClient.HSet(ctx, key, 
"persistent", binaryValue, "live", "10",
+                               "persist", "20", "overwrite", "old", "deleted", 
"gone", "expired", "gone").Err())
+                       expireAt := time.Now().Add(10 * time.Minute).UnixMilli()
+                       require.NoError(t, sourceClient.Do(ctx, "hpexpireat", 
key, expireAt, "FIELDS", 4,
+                               "live", "persist", "overwrite", 
"deleted").Err())
+                       expiredValue, err := sourceClient.HGet(ctx, key, 
"expired").Result()
+                       require.NoError(t, err)
+                       require.Equal(t, "gone", expiredValue)
+                       expiredResult, err := sourceClient.Do(ctx, "hpexpire", 
key, 1000, "FIELDS", 1, "expired").Result()
+                       require.NoError(t, err)
+                       require.Equal(t, []interface{}{int64(1)}, expiredResult)
+                       require.NoError(t, sourceClient.PExpireAt(ctx, key, 
time.Now().Add(20*time.Minute)).Err())
+                       // This field must already be expired on the source 
before migration starts.
+                       require.Eventually(t, func() bool {
+                               return errors.Is(sourceClient.HGet(ctx, key, 
"expired").Err(), redis.Nil)
+                       }, 5*time.Second, 10*time.Millisecond)
+
+                       values := map[string]string{
+                               "persistent": binaryValue, "live": "10", 
"persist": "20", "overwrite": "old", "deleted": "gone",
+                       }
+                       expires := []interface{}{int64(-1), expireAt, expireAt, 
expireAt, expireAt, int64(-2)}
+                       var fieldExpireAt int64
+                       expiringKey, expiringField, expiringValue := key, 
"live", "11"
+                       if phase == "wal" {
+                               // Keep the snapshot transfer running while 
subsequent hash changes enter the WAL.
+                               require.NoError(t, sourceClient.ConfigSet(ctx, 
"migrate-batch-rate-limit-mb", "1").Err())
+                               fillerKey := fmt.Sprintf("filler_{%s}", 
util.SlotTable[slot])
+                               pipe := sourceClient.Pipeline()
+                               for i := 0; i < 1024; i++ {
+                                       pipe.RPush(ctx, fillerKey, 
strings.Repeat("x", 4096))
+                               }
+                               _, err := pipe.Exec(ctx)
+                               require.NoError(t, err)
+                               require.NoError(t, sourceClient.Do(ctx, 
"clusterx", "migrate", slot, destinationID).Err())
+                               // Import starts only after the source has 
acquired its snapshot.
+                               waitForImportState(t, destinationClient, slot, 
"start")
+                               requireMigrateState(t, sourceClient, slot, 
SlotMigrationStateStarted)
+
+                               require.NoError(t, sourceClient.HIncrBy(ctx, 
key, "live", 5).Err())
+                               require.NoError(t, sourceClient.Do(ctx, 
"hpexpireat", key, expireAt+60000, "FIELDS", 1, "persistent").Err())
+                               require.NoError(t, sourceClient.Do(ctx, 
"hpersist", key, "FIELDS", 1, "persist").Err())
+                               require.NoError(t, sourceClient.HSet(ctx, key, 
"overwrite", "new").Err())
+                               require.NoError(t, sourceClient.HDel(ctx, key, 
"deleted").Err())
+                               require.NoError(t, sourceClient.HSet(ctx, 
legacyKey, "field", binaryValue+"updated").Err())
+                               require.NoError(t, sourceClient.Do(ctx, 
"hsetex", key+"_new", "PXAT", expireAt,
+                                       "FIELDS", 2, "field", 
"created-during-migration", "short-lived", "temporary").Err())
+                               // The new key can only arrive through WAL; 
leave time for the rate-limited snapshot transfer.
+                               fieldExpireAt = time.Now().Add(8 * 
time.Second).UnixMilli()
+                               require.NoError(t, sourceClient.Do(ctx, 
"hpexpireat", key+"_new", fieldExpireAt, "FIELDS", 1, "short-lived").Err())
+                               expiringKey, expiringField, expiringValue = 
key+"_new", "short-lived", "temporary"
+                               values["live"] = "15"
+                               values["overwrite"] = "new"
+                               delete(values, "deleted")
+                               expires = []interface{}{expireAt + 60000, 
expireAt, int64(-1), int64(-1), int64(-2), int64(-2)}
+                       } else {
+                               fieldExpireAt = time.Now().Add(3 * 
time.Second).UnixMilli()
+                               require.NoError(t, sourceClient.Do(ctx, 
"hpexpireat", key, fieldExpireAt, "FIELDS", 1, "live").Err())
+                               expires[1] = fieldExpireAt
+                       }
+
+                       metadata := util.GetKMetadata(t, sourceClient, ctx, key)
+                       require.Equal(t, "field-expiration", metadata.Mode)
+                       legacyMetadata := util.GetKMetadata(t, sourceClient, 
ctx, legacyKey)
+                       require.Equal(t, "legacy", legacyMetadata.Mode)
+                       if phase == "snapshot" {
+                               require.Equal(t, int64(6), metadata.Size)
+                               require.Equal(t, int64(1), metadata.Persist)
+                               require.NoError(t, sourceClient.Do(ctx, 
"clusterx", "migrate", slot, destinationID).Err())
+                       }
+                       waitForMigrateStateInDuration(t, sourceClient, slot, 
SlotMigrationStateSuccess, time.Minute)
+                       waitForImportState(t, destinationClient, slot, 
SlotImportStateSuccess)
+
+                       got, err := destinationClient.HGetAll(ctx, key).Result()
+                       require.NoError(t, err)
+                       require.Equal(t, values, got)
+                       fieldExpires, err := destinationClient.Do(ctx, 
"hpexpiretime", key, "FIELDS", 6,
+                               "persistent", "live", "persist", "overwrite", 
"deleted", "expired").Result()
+                       require.NoError(t, err)
+                       require.Equal(t, expires, fieldExpires)
+                       require.Equal(t, metadata, util.GetKMetadata(t, 
destinationClient, ctx, key))
+                       require.Equal(t, legacyMetadata, util.GetKMetadata(t, 
destinationClient, ctx, legacyKey))
+                       legacyValue, err := destinationClient.HGet(ctx, 
legacyKey, "field").Result()
+                       require.NoError(t, err)
+                       if phase == "wal" {
+                               require.Equal(t, binaryValue+"updated", 
legacyValue)
+                               newValue, err := destinationClient.HGetAll(ctx, 
key+"_new").Result()
+                               require.NoError(t, err)
+                               require.Equal(t, map[string]string{"field": 
"created-during-migration", "short-lived": "temporary"}, newValue)
+                               newExpiry, err := destinationClient.Do(ctx, 
"hpexpiretime", key+"_new", "FIELDS", 2, "field", "short-lived").Result()
+                               require.NoError(t, err)
+                               require.Equal(t, []interface{}{expireAt, 
fieldExpireAt}, newExpiry)
+                       } else {
+                               require.Equal(t, binaryValue, legacyValue)
+                       }
+
+                       // Writes must use the migrated mode even though the 
destination defaults to legacy encoding.
+                       wantLive := int64(11)
+                       if phase == "wal" {
+                               wantLive = 16
+                       }
+                       newLive, err := destinationClient.HIncrBy(ctx, key, 
"live", 1).Result()
+                       require.NoError(t, err)
+                       require.Equal(t, wantLive, newLive)
+                       values["live"] = fmt.Sprint(wantLive)
+                       liveValue, err := destinationClient.HGet(ctx, key, 
"live").Result()
+                       require.NoError(t, err)
+                       require.Equal(t, values["live"], liveValue)
+                       fieldExpires, err = destinationClient.Do(ctx, 
"hpexpiretime", key, "FIELDS", 1, "live").Result()
+                       require.NoError(t, err)
+                       require.Equal(t, []interface{}{expires[1]}, 
fieldExpires)
+
+                       expiringValueOnDestination, err := 
destinationClient.HGet(ctx, expiringKey, expiringField).Result()
+                       require.NoError(t, err)
+                       require.Equal(t, expiringValue, 
expiringValueOnDestination)
+                       fieldExpires, err = destinationClient.Do(ctx, 
"hpexpiretime", expiringKey, "FIELDS", 1, expiringField).Result()
+                       require.NoError(t, err)
+                       require.Equal(t, []interface{}{fieldExpireAt}, 
fieldExpires)
+                       require.Eventually(t, func() bool {
+                               return errors.Is(destinationClient.HGet(ctx, 
expiringKey, expiringField).Err(), redis.Nil)
+                       }, 10*time.Second, 10*time.Millisecond)
+                       require.GreaterOrEqual(t, time.Now().UnixMilli(), 
fieldExpireAt)
+                       if phase == "snapshot" {
+                               delete(values, "live")
+                       }
+                       got, err = destinationClient.HGetAll(ctx, key).Result()
+                       require.NoError(t, err)
+                       require.Equal(t, values, got)
+                       if phase == "wal" {
+                               require.ErrorIs(t, destinationClient.HGet(ctx, 
key+"_new", "short-lived").Err(), redis.Nil)
+                               newValue, err := destinationClient.HGetAll(ctx, 
key+"_new").Result()
+                               require.NoError(t, err)
+                               require.Equal(t, map[string]string{"field": 
"created-during-migration"}, newValue)
+                       }
+               })
+       }
+}

Reply via email to