T applyHashMetadata(RedisInputStream in, int version) throws IOException{
SkipRdbParser skip = new SkipRdbParser(in);
diff --git a/src/main/java/com/moilioncircle/redis/replicator/rdb/skip/SkipRdbVisitor.java b/src/main/java/com/moilioncircle/redis/replicator/rdb/skip/SkipRdbVisitor.java
index 91866437..42227dc1 100644
--- a/src/main/java/com/moilioncircle/redis/replicator/rdb/skip/SkipRdbVisitor.java
+++ b/src/main/java/com/moilioncircle/redis/replicator/rdb/skip/SkipRdbVisitor.java
@@ -273,6 +273,14 @@ public Event applyListQuickList2(RedisInputStream in, int version, ContextKeyVal
return null;
}
+ @Override
+ public Event applyHash2(RedisInputStream in, int version, ContextKeyValuePair context) throws IOException {
+ SkipRdbParser parser = new SkipRdbParser(in);
+ parser.rdbLoadEncodedStringObject();
+ valueVisitor.applyHash2(in, version);
+ return null;
+ }
+
@Override
public Event applyHashMetadata(RedisInputStream in, int version, ContextKeyValuePair context) throws IOException{
SkipRdbParser parser = new SkipRdbParser(in);
diff --git a/src/test/java/com/moilioncircle/redis/replicator/cmd/parser/HExpireParserTest.java b/src/test/java/com/moilioncircle/redis/replicator/cmd/parser/HExpireParserTest.java
new file mode 100644
index 00000000..3832370f
--- /dev/null
+++ b/src/test/java/com/moilioncircle/redis/replicator/cmd/parser/HExpireParserTest.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2026 otheng03
+ *
+ * Licensed 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 com.moilioncircle.redis.replicator.cmd.parser;
+
+import org.junit.Test;
+
+import com.moilioncircle.redis.replicator.cmd.impl.CompareType;
+import com.moilioncircle.redis.replicator.cmd.impl.ExistType;
+import com.moilioncircle.redis.replicator.cmd.impl.HExpireAtCommand;
+import com.moilioncircle.redis.replicator.cmd.impl.HExpireCommand;
+import com.moilioncircle.redis.replicator.cmd.impl.HPExpireCommand;
+
+import junit.framework.TestCase;
+
+/**
+ * @author otheng03
+ * @since 3.12.0
+ */
+public class HExpireParserTest extends AbstractParserTest {
+
+ @Test
+ public void testHExpire() {
+ HExpireParser parser = new HExpireParser();
+
+ HExpireCommand cmd = parser.parse(toObjectArray("hexpire mykey 100 fields 2 f1 f2".split(" ")));
+ assertEquals("mykey", cmd.getKey());
+ assertEquals(100L, cmd.getEx());
+ assertEquals(2, cmd.getFields().length);
+ assertEquals("f1", cmd.getFields()[0]);
+ assertEquals("f2", cmd.getFields()[1]);
+ TestCase.assertEquals(ExistType.NONE, cmd.getExistType());
+ TestCase.assertEquals(CompareType.NONE, cmd.getCompareType());
+
+ cmd = parser.parse(toObjectArray("hexpire mykey 100 NX fields 1 f1".split(" ")));
+ assertEquals("mykey", cmd.getKey());
+ assertEquals(100L, cmd.getEx());
+ assertEquals(1, cmd.getFields().length);
+ assertEquals("f1", cmd.getFields()[0]);
+ TestCase.assertEquals(ExistType.NX, cmd.getExistType());
+ TestCase.assertEquals(CompareType.NONE, cmd.getCompareType());
+
+ cmd = parser.parse(toObjectArray("hexpire mykey 100 GT fields 1 f1".split(" ")));
+ assertEquals("mykey", cmd.getKey());
+ assertEquals(100L, cmd.getEx());
+ TestCase.assertEquals(ExistType.NONE, cmd.getExistType());
+ TestCase.assertEquals(CompareType.GT, cmd.getCompareType());
+
+ cmd = parser.parse(toObjectArray("hexpire mykey 100 XX LT fields 1 f1".split(" ")));
+ assertEquals("mykey", cmd.getKey());
+ assertEquals(100L, cmd.getEx());
+ TestCase.assertEquals(ExistType.XX, cmd.getExistType());
+ TestCase.assertEquals(CompareType.LT, cmd.getCompareType());
+ }
+
+ @Test
+ public void testHPExpire() {
+ HPExpireParser parser = new HPExpireParser();
+
+ HPExpireCommand cmd = parser.parse(toObjectArray("hpexpire mykey 100000 fields 2 f1 f2".split(" ")));
+ assertEquals("mykey", cmd.getKey());
+ assertEquals(100000L, cmd.getEx());
+ assertEquals(2, cmd.getFields().length);
+ assertEquals("f1", cmd.getFields()[0]);
+ assertEquals("f2", cmd.getFields()[1]);
+ TestCase.assertEquals(ExistType.NONE, cmd.getExistType());
+ TestCase.assertEquals(CompareType.NONE, cmd.getCompareType());
+
+ cmd = parser.parse(toObjectArray("hpexpire mykey 100000 NX fields 1 f1".split(" ")));
+ assertEquals("mykey", cmd.getKey());
+ assertEquals(100000L, cmd.getEx());
+ TestCase.assertEquals(ExistType.NX, cmd.getExistType());
+ TestCase.assertEquals(CompareType.NONE, cmd.getCompareType());
+ }
+
+ @Test
+ public void testHExpireAt() {
+ HExpireAtParser parser = new HExpireAtParser();
+
+ HExpireAtCommand cmd = parser.parse(toObjectArray("hexpireat mykey 1614139099 fields 2 f1 f2".split(" ")));
+ assertEquals("mykey", cmd.getKey());
+ assertEquals(1614139099L, cmd.getEx());
+ assertEquals(2, cmd.getFields().length);
+ assertEquals("f1", cmd.getFields()[0]);
+ assertEquals("f2", cmd.getFields()[1]);
+ TestCase.assertEquals(ExistType.NONE, cmd.getExistType());
+ TestCase.assertEquals(CompareType.NONE, cmd.getCompareType());
+
+ cmd = parser.parse(toObjectArray("hexpireat mykey 1614139099 GT fields 1 f1".split(" ")));
+ assertEquals("mykey", cmd.getKey());
+ assertEquals(1614139099L, cmd.getEx());
+ TestCase.assertEquals(ExistType.NONE, cmd.getExistType());
+ TestCase.assertEquals(CompareType.GT, cmd.getCompareType());
+ }
+}
diff --git a/src/test/java/com/moilioncircle/redis/replicator/online/EnabledIfValkey.java b/src/test/java/com/moilioncircle/redis/replicator/online/EnabledIfValkey.java
new file mode 100644
index 00000000..552c84ce
--- /dev/null
+++ b/src/test/java/com/moilioncircle/redis/replicator/online/EnabledIfValkey.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2026
+ *
+ * Licensed 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 com.moilioncircle.redis.replicator.online;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Skips a test class or method unless {@code -Dtest.flavor=valkey} is set.
+ * Enforced by {@link FlavorRule}, which is wired into {@link OnlineTestBase}.
+ */
+@Retention(RUNTIME)
+@Target({TYPE, METHOD})
+public @interface EnabledIfValkey {
+}
diff --git a/src/test/java/com/moilioncircle/redis/replicator/online/FlavorRule.java b/src/test/java/com/moilioncircle/redis/replicator/online/FlavorRule.java
new file mode 100644
index 00000000..55a1296c
--- /dev/null
+++ b/src/test/java/com/moilioncircle/redis/replicator/online/FlavorRule.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2026
+ *
+ * Licensed 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 com.moilioncircle.redis.replicator.online;
+
+import org.junit.Assume;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+/**
+ * Enforces flavor-gating annotations like {@link EnabledIfValkey}.
+ * If the test class or method is annotated and {@code -Dtest.flavor=valkey}
+ * is not set, the test is skipped via {@link Assume#assumeTrue}.
+ */
+public class FlavorRule implements TestRule {
+
+ @Override
+ public Statement apply(Statement base, Description description) {
+ return new Statement() {
+ @Override
+ public void evaluate() throws Throwable {
+ if (requiresValkey(description)) {
+ Assume.assumeTrue(
+ "skipped: requires -Dtest.flavor=valkey",
+ "valkey".equalsIgnoreCase(System.getProperty("test.flavor")));
+ }
+ base.evaluate();
+ }
+ };
+ }
+
+ private static boolean requiresValkey(Description description) {
+ if (description.getAnnotation(EnabledIfValkey.class) != null) return true;
+ Class> testClass = description.getTestClass();
+ return testClass != null && testClass.isAnnotationPresent(EnabledIfValkey.class);
+ }
+}
diff --git a/src/test/java/com/moilioncircle/redis/replicator/online/HashFieldExpireTest.java b/src/test/java/com/moilioncircle/redis/replicator/online/HashFieldExpireTest.java
new file mode 100644
index 00000000..435fc87e
--- /dev/null
+++ b/src/test/java/com/moilioncircle/redis/replicator/online/HashFieldExpireTest.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2026 otheng03
+ *
+ * Licensed 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 com.moilioncircle.redis.replicator.online;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.Test;
+
+import com.moilioncircle.redis.replicator.RedisReplicator;
+import com.moilioncircle.redis.replicator.Replicator;
+import com.moilioncircle.redis.replicator.client.RESP2Client;
+import com.moilioncircle.redis.replicator.cmd.impl.CompareType;
+import com.moilioncircle.redis.replicator.cmd.impl.ExistType;
+import com.moilioncircle.redis.replicator.cmd.impl.HPExpireAtCommand;
+import com.moilioncircle.redis.replicator.event.Event;
+import com.moilioncircle.redis.replicator.event.EventListener;
+import com.moilioncircle.redis.replicator.event.PostRdbSyncEvent;
+import com.moilioncircle.redis.replicator.util.Strings;
+
+/**
+ * Online tests for hash field expiry commands: HEXPIRE, HPEXPIRE, HEXPIREAT.
+ *
+ * Valkey always propagates hash field expiry commands to replicas as
+ * HPEXPIREAT (absolute millisecond timestamp), regardless of which variant
+ * was originally issued. These tests verify that the propagated HPEXPIREAT
+ * command is parsed correctly, with the original option flags (NX/XX/GT/LT)
+ * preserved.
+ *
+ *
Requires a running Valkey 9+ server at {@code 127.0.0.1:6379}.
+ * Run with {@code -Dtest.flavor=valkey} to target a Valkey server.
+ *
+ * @author otheng03
+ * @since 3.12.0
+ */
+@EnabledIfValkey
+public class HashFieldExpireTest extends OnlineTestBase {
+
+ /**
+ * HEXPIRE (relative seconds, NX) is propagated as HPEXPIREAT with ExistType.NX.
+ */
+ @Test
+ public void testHExpire() throws Exception {
+ final AtomicReference ref = new AtomicReference<>(null);
+ Replicator replicator = new RedisReplicator(HOST, PORT, config());
+ replicator.addEventListener(new EventListener() {
+ @Override
+ public void onEvent(Replicator replicator, Event event) {
+ if (event instanceof PostRdbSyncEvent) {
+ try (RESP2Client client = new RESP2Client(HOST, PORT, config())) {
+ RESP2Client.Command cmd = client.newCommand();
+ cmd.invoke("DEL", "hexpire_test");
+ cmd.invoke("HSET", "hexpire_test", "f1", "v1", "f2", "v2");
+ cmd.invoke("HEXPIRE", "hexpire_test", "300", "NX", "FIELDS", "1", "f1");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ if (event instanceof HPExpireAtCommand) {
+ HPExpireAtCommand cmd = (HPExpireAtCommand) event;
+ if (!"hexpire_test".equals(Strings.toString(cmd.getKey()))) return;
+ ref.compareAndSet(null, cmd);
+ try {
+ replicator.close();
+ } catch (IOException ignored) {
+ }
+ }
+ }
+ });
+ replicator.open();
+
+ HPExpireAtCommand cmd = ref.get();
+ assertNotNull(cmd);
+ assertEquals("hexpire_test", Strings.toString(cmd.getKey()));
+ assertTrue(cmd.getEx() > System.currentTimeMillis());
+ assertEquals(1, cmd.getFields().length);
+ assertEquals("f1", Strings.toString(cmd.getFields()[0]));
+ assertEquals(ExistType.NX, cmd.getExistType());
+ assertEquals(CompareType.NONE, cmd.getCompareType());
+ }
+
+ /**
+ * HPEXPIRE (relative milliseconds, GT) is propagated as HPEXPIREAT with CompareType.GT.
+ *
+ * GT requires an existing expiry to compare against, so we first set one with NX.
+ * Both commands arrive as HPEXPIREAT; we filter for the GT one.
+ */
+ @Test
+ public void testHPExpire() throws Exception {
+ final AtomicReference ref = new AtomicReference<>(null);
+ Replicator replicator = new RedisReplicator(HOST, PORT, config());
+ replicator.addEventListener(new EventListener() {
+ @Override
+ public void onEvent(Replicator replicator, Event event) {
+ if (event instanceof PostRdbSyncEvent) {
+ try (RESP2Client client = new RESP2Client(HOST, PORT, config())) {
+ RESP2Client.Command cmd = client.newCommand();
+ cmd.invoke("DEL", "hpexpire_test");
+ cmd.invoke("HSET", "hpexpire_test", "f1", "v1");
+ // NX sets an initial expiry; GT then succeeds because 300000 > 100000
+ cmd.invoke("HPEXPIRE", "hpexpire_test", "100000", "NX", "FIELDS", "1", "f1");
+ cmd.invoke("HPEXPIRE", "hpexpire_test", "300000", "GT", "FIELDS", "1", "f1");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ if (event instanceof HPExpireAtCommand) {
+ HPExpireAtCommand cmd = (HPExpireAtCommand) event;
+ if (!"hpexpire_test".equals(Strings.toString(cmd.getKey()))) return;
+ if (cmd.getCompareType() != CompareType.GT) return;
+ ref.compareAndSet(null, cmd);
+ try {
+ replicator.close();
+ } catch (IOException ignored) {
+ }
+ }
+ }
+ });
+ replicator.open();
+
+ HPExpireAtCommand cmd = ref.get();
+ assertNotNull(cmd);
+ assertEquals("hpexpire_test", Strings.toString(cmd.getKey()));
+ assertTrue(cmd.getEx() > System.currentTimeMillis());
+ assertEquals(1, cmd.getFields().length);
+ assertEquals("f1", Strings.toString(cmd.getFields()[0]));
+ assertEquals(ExistType.NONE, cmd.getExistType());
+ assertEquals(CompareType.GT, cmd.getCompareType());
+ }
+
+ /**
+ * HEXPIREAT (absolute seconds, LT) is propagated as HPEXPIREAT with CompareType.LT.
+ */
+ @Test
+ public void testHExpireAt() throws Exception {
+ final AtomicReference ref = new AtomicReference<>(null);
+ Replicator replicator = new RedisReplicator(HOST, PORT, config());
+ replicator.addEventListener(new EventListener() {
+ @Override
+ public void onEvent(Replicator replicator, Event event) {
+ if (event instanceof PostRdbSyncEvent) {
+ try (RESP2Client client = new RESP2Client(HOST, PORT, config())) {
+ long futureSeconds = System.currentTimeMillis() / 1000 + 3600;
+ RESP2Client.Command cmd = client.newCommand();
+ cmd.invoke("DEL", "hexpireat_test");
+ cmd.invoke("HSET", "hexpireat_test", "f1", "v1", "f2", "v2");
+ cmd.invoke("HEXPIREAT", "hexpireat_test", String.valueOf(futureSeconds),
+ "LT", "FIELDS", "2", "f1", "f2");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ if (event instanceof HPExpireAtCommand) {
+ HPExpireAtCommand cmd = (HPExpireAtCommand) event;
+ if (!"hexpireat_test".equals(Strings.toString(cmd.getKey()))) return;
+ ref.compareAndSet(null, cmd);
+ try {
+ replicator.close();
+ } catch (IOException ignored) {
+ }
+ }
+ }
+ });
+ replicator.open();
+
+ HPExpireAtCommand cmd = ref.get();
+ assertNotNull(cmd);
+ assertEquals("hexpireat_test", Strings.toString(cmd.getKey()));
+ assertTrue(cmd.getEx() > System.currentTimeMillis());
+ assertEquals(2, cmd.getFields().length);
+ assertEquals(ExistType.NONE, cmd.getExistType());
+ assertEquals(CompareType.LT, cmd.getCompareType());
+ }
+}
diff --git a/src/test/java/com/moilioncircle/redis/replicator/online/OnlineTestBase.java b/src/test/java/com/moilioncircle/redis/replicator/online/OnlineTestBase.java
index 260d6482..39ab4325 100644
--- a/src/test/java/com/moilioncircle/redis/replicator/online/OnlineTestBase.java
+++ b/src/test/java/com/moilioncircle/redis/replicator/online/OnlineTestBase.java
@@ -16,6 +16,8 @@
package com.moilioncircle.redis.replicator.online;
+import org.junit.Rule;
+
import com.moilioncircle.redis.replicator.Configuration;
import com.moilioncircle.redis.replicator.Flavor;
@@ -35,6 +37,9 @@ public abstract class OnlineTestBase {
protected static final Flavor FLAVOR = "valkey".equalsIgnoreCase(System.getProperty("test.flavor"))
? Flavor.VALKEY : null;
+ @Rule
+ public final FlavorRule flavorRule = new FlavorRule();
+
protected Configuration config() {
Configuration config = Configuration.defaultSetting().setRetries(0);
if (FLAVOR != null) config.setFlavor(FLAVOR);
diff --git a/src/test/java/com/moilioncircle/redis/replicator/online/RESP2ClientTest.java b/src/test/java/com/moilioncircle/redis/replicator/online/RESP2ClientTest.java
index 063e1874..55b7324a 100644
--- a/src/test/java/com/moilioncircle/redis/replicator/online/RESP2ClientTest.java
+++ b/src/test/java/com/moilioncircle/redis/replicator/online/RESP2ClientTest.java
@@ -22,7 +22,6 @@
import org.junit.Test;
-import com.moilioncircle.redis.replicator.Configuration;
import com.moilioncircle.redis.replicator.client.RESP2;
import com.moilioncircle.redis.replicator.client.RESP2Client;
diff --git a/src/test/java/com/moilioncircle/redis/replicator/rdb/dump/DumpRdbVisitorTest.java b/src/test/java/com/moilioncircle/redis/replicator/rdb/dump/DumpRdbVisitorTest.java
index 8cc5c187..9c5cef0f 100644
--- a/src/test/java/com/moilioncircle/redis/replicator/rdb/dump/DumpRdbVisitorTest.java
+++ b/src/test/java/com/moilioncircle/redis/replicator/rdb/dump/DumpRdbVisitorTest.java
@@ -17,6 +17,7 @@
package com.moilioncircle.redis.replicator.rdb.dump;
import static com.moilioncircle.redis.replicator.Constants.RDB_TYPE_HASH;
+import static com.moilioncircle.redis.replicator.Constants.RDB_TYPE_HASH_2;
import static com.moilioncircle.redis.replicator.Constants.RDB_TYPE_HASH_LISTPACK;
import static com.moilioncircle.redis.replicator.Constants.RDB_TYPE_HASH_LISTPACK_EX;
import static com.moilioncircle.redis.replicator.Constants.RDB_TYPE_HASH_METADATA;
@@ -42,6 +43,7 @@
import com.moilioncircle.redis.replicator.Configuration;
import com.moilioncircle.redis.replicator.FileType;
+import com.moilioncircle.redis.replicator.Flavor;
import com.moilioncircle.redis.replicator.RedisReplicator;
import com.moilioncircle.redis.replicator.Replicator;
import com.moilioncircle.redis.replicator.event.Event;
@@ -1106,4 +1108,130 @@ public void onEvent(Replicator replicator, Event event) {
assertArrayEquals(e1.getValue().getValue(), e2.getValue());
}
}
+
+ // dump-hash2.rdb was generated against Valkey 9 (RDB v80) with:
+ // CONFIG SET hash-max-listpack-entries 0 # force hashtable encoding so the type byte is RDB_TYPE_HASH_2 (0x16),
+ // # not RDB_TYPE_HASH_LISTPACK_EX
+ // HSET ttlhash2 field1 value1 ... field5 value5
+ // HEXPIRE ttlhash2 FIELDS 1 fieldN # one HEXPIRE per field, distinct TTLs
+ // SAVE
+ // then copy /dump.rdb to src/test/resources/dump-hash2.rdb.
+ @Test
+ @SuppressWarnings("resource")
+ public void testHash2Passthrough() throws IOException {
+ Replicator replicator = new RedisReplicator(DumpRdbVisitorTest.class.
+ getClassLoader().getResourceAsStream("dump-hash2.rdb")
+ , FileType.RDB, Configuration.defaultSetting().setFlavor(Flavor.VALKEY));
+ List> expected = new ArrayList<>();
+ replicator.addEventListener(new EventListener() {
+ @Override
+ public void onEvent(Replicator replicator, Event event) {
+ if (event instanceof KeyStringValueTTLHash) {
+ KeyStringValueTTLHash kv = (KeyStringValueTTLHash) event;
+ String key = new String(kv.getKey());
+ if (key.equals("ttlhash2") && kv.getValueRdbType() == RDB_TYPE_HASH_2) {
+ for (Map.Entry entry: kv.getValue().entrySet()) {
+ expected.add(entry);
+ }
+ }
+ }
+ }
+ });
+ replicator.open();
+
+ replicator = new RedisReplicator(DumpRdbVisitorTest.class.
+ getClassLoader().getResourceAsStream("dump-hash2.rdb")
+ , FileType.RDB, Configuration.defaultSetting().setFlavor(Flavor.VALKEY));
+ List> actual = new ArrayList<>();
+ replicator.setRdbVisitor(new DumpRdbVisitor(replicator));
+ replicator.addEventListener(new EventListener() {
+ @Override
+ public void onEvent(Replicator replicator, Event event) {
+ if (!(event instanceof DumpKeyValuePair)) return;
+ DumpKeyValuePair kv = (DumpKeyValuePair) event;
+ String key = new String(kv.getKey());
+ if (key.equals("ttlhash2") && kv.getValueRdbType() == RDB_TYPE_HASH_2) {
+ DumpValueParser parser = new DefaultDumpValueParser(replicator);
+ parser.parse(kv, new EventListener() {
+ @Override
+ public void onEvent(Replicator replicator, Event event) {
+ KeyStringValueTTLHash kv = (KeyStringValueTTLHash) event;
+ for (Map.Entry entry: kv.getValue().entrySet()) {
+ actual.add(entry);
+ }
+ }
+ });
+ }
+ }
+ });
+ replicator.open();
+
+ assertEquals(expected.size(), actual.size());
+ for (int i = 0; i < expected.size(); i++) {
+ Map.Entry e1 = expected.get(i);
+ Map.Entry e2 = actual.get(i);
+ assertArrayEquals(e1.getKey(), e2.getKey());
+ assertArrayEquals(e1.getValue().getValue(), e2.getValue().getValue());
+ assertEquals(e1.getValue().getExpires(), e2.getValue().getExpires());
+ }
+ }
+
+ @Test
+ @SuppressWarnings("resource")
+ public void testHash2DowngradeToHash() throws IOException {
+ Replicator replicator = new RedisReplicator(DumpRdbVisitorTest.class.
+ getClassLoader().getResourceAsStream("dump-hash2.rdb")
+ , FileType.RDB, Configuration.defaultSetting().setFlavor(Flavor.VALKEY));
+ List> expected = new ArrayList<>();
+ replicator.addEventListener(new EventListener() {
+ @Override
+ public void onEvent(Replicator replicator, Event event) {
+ if (event instanceof KeyStringValueTTLHash) {
+ KeyStringValueTTLHash kv = (KeyStringValueTTLHash) event;
+ String key = new String(kv.getKey());
+ if (key.equals("ttlhash2") && kv.getValueRdbType() == RDB_TYPE_HASH_2) {
+ for (Map.Entry entry: kv.getValue().entrySet()) {
+ expected.add(entry);
+ }
+ }
+ }
+ }
+ });
+ replicator.open();
+
+ replicator = new RedisReplicator(DumpRdbVisitorTest.class.
+ getClassLoader().getResourceAsStream("dump-hash2.rdb")
+ , FileType.RDB, Configuration.defaultSetting().setFlavor(Flavor.VALKEY));
+ List> actual = new ArrayList<>();
+ replicator.setRdbVisitor(new DumpRdbVisitor(replicator, 79));
+ replicator.addEventListener(new EventListener() {
+ @Override
+ public void onEvent(Replicator replicator, Event event) {
+ if (!(event instanceof DumpKeyValuePair)) return;
+ DumpKeyValuePair kv = (DumpKeyValuePair) event;
+ String key = new String(kv.getKey());
+ if (key.equals("ttlhash2") && kv.getValueRdbType() == RDB_TYPE_HASH) {
+ DumpValueParser parser = new DefaultDumpValueParser(replicator);
+ parser.parse(kv, new EventListener() {
+ @Override
+ public void onEvent(Replicator replicator, Event event) {
+ KeyStringValueHash kv = (KeyStringValueHash) event;
+ for (Map.Entry entry: kv.getValue().entrySet()) {
+ actual.add(entry);
+ }
+ }
+ });
+ }
+ }
+ });
+ replicator.open();
+
+ assertEquals(expected.size(), actual.size());
+ for (int i = 0; i < expected.size(); i++) {
+ Map.Entry e1 = expected.get(i);
+ Map.Entry e2 = actual.get(i);
+ assertArrayEquals(e1.getKey(), e2.getKey());
+ assertArrayEquals(e1.getValue().getValue(), e2.getValue());
+ }
+ }
}
\ No newline at end of file
diff --git a/src/test/resources/dump-hash2.rdb b/src/test/resources/dump-hash2.rdb
new file mode 100644
index 00000000..27733250
Binary files /dev/null and b/src/test/resources/dump-hash2.rdb differ