CLUSTER_GROUPS = EnumSet
+ .complementOf(EnumSet.of(CommandInterfaces.SENTINEL, CommandInterfaces.TRANSACTIONAL));
+
+ @Test
+ void standaloneAggregatesCoverAllGroups() {
+
+ SoftAssertions softly = new SoftAssertions();
+
+ for (CommandInterfaces group : STANDALONE_GROUPS) {
+ assertExtends(softly, RedisCommands.class, group.sync());
+ assertExtends(softly, RedisAsyncCommands.class, group.async());
+ assertExtends(softly, RedisReactiveCommands.class, group.reactive());
+ assertExtends(softly, io.lettuce.core.api.coroutines.RedisCoroutinesCommands.class, group.coroutines());
+ }
+
+ softly.assertAll();
+ }
+
+ @Test
+ void clusterAggregatesCoverAllClusterGroups() {
+
+ SoftAssertions softly = new SoftAssertions();
+
+ for (CommandInterfaces group : CLUSTER_GROUPS) {
+ assertExtends(softly, RedisClusterCommands.class, group.sync());
+ assertExtends(softly, RedisClusterAsyncCommands.class, group.async());
+ assertExtends(softly, RedisClusterReactiveCommands.class, group.reactive());
+ assertExtends(softly, RedisClusterCoroutinesCommands.class, group.coroutines());
+ }
+
+ softly.assertAll();
+ }
+
+ @Test
+ void nodeSelectionAggregatesCoverAllNodeSelectionGroups() {
+
+ SoftAssertions softly = new SoftAssertions();
+
+ for (CommandInterfaces group : CLUSTER_GROUPS) {
+ if (!group.hasNodeSelection()) {
+ continue;
+ }
+ assertExtends(softly, NodeSelectionCommands.class, group.nodeSelectionSync());
+ assertExtends(softly, NodeSelectionAsyncCommands.class, group.nodeSelectionAsync());
+ }
+
+ softly.assertAll();
+ }
+
+ private static void assertExtends(SoftAssertions softly, Class> aggregate, Class> groupInterface) {
+ softly.assertThat(groupInterface.isAssignableFrom(aggregate))
+ .as("%s must extend %s", aggregate.getSimpleName(), groupInterface.getSimpleName()).isTrue();
+ }
+
+ /**
+ * The sync/async/reactive triples of the aggregate interfaces that declare command methods of their own.
+ */
+ private static final Class>[][] AGGREGATE_FLAVORS = {
+ { RedisCommands.class, RedisAsyncCommands.class, RedisReactiveCommands.class },
+ { RedisClusterCommands.class, RedisClusterAsyncCommands.class, RedisClusterReactiveCommands.class },
+ { RedisAdvancedClusterCommands.class, RedisAdvancedClusterAsyncCommands.class,
+ RedisAdvancedClusterReactiveCommands.class } };
+
+ @Test
+ void aggregateDeclaredMethodsExistOnAsyncAndReactiveAggregates() {
+
+ SoftAssertions softly = new SoftAssertions();
+
+ for (Class>[] flavors : AGGREGATE_FLAVORS) {
+ Class> sync = flavors[0];
+
+ for (Method syncMethod : TypeSignatures.apiMethods(sync)) {
+
+ assertCounterpart(softly, sync, syncMethod, flavors[1],
+ TypeSignatures.expectedAsyncReturnType(syncMethod, sync));
+
+ if (!KnownApiDeviations.contains(KnownApiDeviations.NOT_ON_REACTIVE_AGGREGATE, syncMethod, sync)) {
+ assertCounterpart(softly, sync, syncMethod, flavors[2],
+ TypeSignatures.expectedReactiveReturnType(syncMethod, sync));
+ }
+ }
+ }
+
+ softly.assertAll();
+ }
+
+ @Test
+ void asyncAndReactiveAggregateDeclaredMethodsExistOnSyncAggregate() {
+
+ SoftAssertions softly = new SoftAssertions();
+
+ for (Class>[] flavors : AGGREGATE_FLAVORS) {
+ Class> sync = flavors[0];
+
+ for (Class> flavor : new Class>[] { flavors[1], flavors[2] }) {
+ for (Method method : TypeSignatures.apiMethods(flavor)) {
+ if (KnownApiDeviations.contains(KnownApiDeviations.NOT_ON_SYNC_API, method, sync)
+ || KnownApiDeviations.contains(KnownApiDeviations.REACTIVE_ONLY, method, sync)) {
+ continue;
+ }
+ if (TypeSignatures.findCounterpart(sync, method) == null) {
+ softly.fail("%s is missing on %s", TypeSignatures.describe(flavor, method), sync.getSimpleName());
+ }
+ }
+ }
+ }
+
+ softly.assertAll();
+ }
+
+ private void assertCounterpart(SoftAssertions softly, Class> sync, Method syncMethod, Class> target,
+ String expectedReturnType) {
+
+ Method counterpart = TypeSignatures.findCounterpart(target, syncMethod);
+ if (counterpart == null) {
+ softly.fail("%s is missing on %s", TypeSignatures.describe(sync, syncMethod), target.getSimpleName());
+ return;
+ }
+
+ softly.assertThat(TypeSignatures.parameterSignature(counterpart))
+ .as("parameter types of %s", TypeSignatures.describe(target, counterpart))
+ .isEqualTo(TypeSignatures.parameterSignature(syncMethod));
+
+ softly.assertThat(counterpart.isAnnotationPresent(Deprecated.class))
+ .as("@Deprecated parity of %s", TypeSignatures.describe(target, counterpart))
+ .isEqualTo(syncMethod.isAnnotationPresent(Deprecated.class));
+
+ if (KnownApiDeviations.contains(KnownApiDeviations.AGGREGATE_FLAVOR_SPECIFIC_RETURN, syncMethod, sync)) {
+ return;
+ }
+
+ softly.assertThat(TypeSignatures.normalize(counterpart.getGenericReturnType()))
+ .as("return type of %s", TypeSignatures.describe(target, counterpart)).isEqualTo(expectedReturnType);
+ }
+
+}
diff --git a/src/test/java/io/lettuce/core/api/consistency/CommandBuilderCoverageUnitTests.java b/src/test/java/io/lettuce/core/api/consistency/CommandBuilderCoverageUnitTests.java
new file mode 100644
index 0000000000..0cea918674
--- /dev/null
+++ b/src/test/java/io/lettuce/core/api/consistency/CommandBuilderCoverageUnitTests.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2011-Present, Redis Ltd. and Contributors
+ * All rights reserved.
+ *
+ * Licensed under the MIT License.
+ */
+package io.lettuce.core.api.consistency;
+
+import static io.lettuce.TestTags.UNIT_TEST;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
+import io.lettuce.core.cluster.api.sync.RedisClusterCommands;
+
+/**
+ * Verify that the command interfaces and the command builders cover each other: every interface command has a builder method
+ * that constructs it (each command group maps to its builder, e.g. STRING → {@code RedisCommandBuilder}, JSON →
+ * {@code RedisJsonCommandBuilder}), and every command-producing builder method is reachable from an interface. This catches
+ * dead builder methods and interface methods without an implementation path.
+ *
+ * Matching is name-based because builder overloads legitimately differ in shape from the API overloads (e.g. streaming variants
+ * share a builder method). Renamed counterparts are recorded in {@link KnownApiDeviations#BUILDER_ALIASES};
+ * implementation-detail builder methods in {@link KnownApiDeviations#BUILDER_INTERNAL}.
+ */
+@Tag(UNIT_TEST)
+class CommandBuilderCoverageUnitTests {
+
+ @Test
+ void syncMethodsHaveBuilderCounterparts() throws Exception {
+
+ SoftAssertions softly = new SoftAssertions();
+
+ for (CommandInterfaces group : CommandInterfaces.values()) {
+
+ Set builderNames = commandMethodNames(Class.forName(group.commandBuilderClassName()));
+
+ Set missing = new HashSet<>();
+ for (Method syncMethod : TypeSignatures.apiMethods(group.sync())) {
+ if (KnownApiDeviations.contains(KnownApiDeviations.BUILDER_EXCLUDED, syncMethod, group.sync())) {
+ continue;
+ }
+ String builderName = KnownApiDeviations.BUILDER_ALIASES.getOrDefault(syncMethod.getName(),
+ syncMethod.getName());
+ if (!builderNames.contains(builderName)) {
+ missing.add(syncMethod.getName());
+ }
+ }
+
+ softly.assertThat(missing)
+ .as("%s methods missing on %s", group.sync().getSimpleName(), group.commandBuilderClassName()).isEmpty();
+ }
+
+ softly.assertAll();
+ }
+
+ @Test
+ void builderMethodsAreReachableFromCommandInterfaces() throws Exception {
+
+ Map> reachableNamesByBuilder = new HashMap<>();
+
+ for (CommandInterfaces group : CommandInterfaces.values()) {
+ Set names = reachableNamesByBuilder.computeIfAbsent(group.commandBuilderClassName(),
+ builder -> new HashSet<>());
+ collectMethodNames(names, group.sync());
+ collectMethodNames(names, group.async());
+ }
+
+ // cluster-only commands (CLUSTER *) are declared on the cluster interfaces and built by RedisCommandBuilder
+ Set redisBuilderNames = reachableNamesByBuilder.get(CommandInterfaces.STRING.commandBuilderClassName());
+ collectMethodNames(redisBuilderNames, RedisClusterCommands.class);
+ collectMethodNames(redisBuilderNames, RedisAdvancedClusterCommands.class);
+
+ // interface methods that map to a renamed builder method make that builder method reachable
+ reachableNamesByBuilder.values().forEach(names -> KnownApiDeviations.BUILDER_ALIASES.forEach((api, builder) -> {
+ if (names.contains(api)) {
+ names.add(builder);
+ }
+ }));
+
+ SoftAssertions softly = new SoftAssertions();
+
+ for (Map.Entry> entry : reachableNamesByBuilder.entrySet()) {
+
+ Class> builder = Class.forName(entry.getKey());
+ Set unreachable = new HashSet<>();
+
+ for (String name : commandMethodNames(builder)) {
+ if (!entry.getValue().contains(name) && !KnownApiDeviations.BUILDER_INTERNAL.contains(name)) {
+ unreachable.add(name);
+ }
+ }
+
+ softly.assertThat(unreachable).as("dead command methods on %s (no interface counterpart)", builder.getSimpleName())
+ .isEmpty();
+ }
+
+ softly.assertAll();
+ }
+
+ /**
+ * The command-producing methods of a builder: declared, non-private, non-static methods returning a
+ * {@code RedisCommand}/{@code Command}.
+ */
+ private static Set commandMethodNames(Class> builder) {
+
+ Set names = new HashSet<>();
+ for (Method method : builder.getDeclaredMethods()) {
+ if (method.isSynthetic() || Modifier.isStatic(method.getModifiers()) || Modifier.isPrivate(method.getModifiers())) {
+ continue;
+ }
+ if (io.lettuce.core.protocol.RedisCommand.class.isAssignableFrom(method.getReturnType())) {
+ names.add(method.getName());
+ }
+ }
+ return names;
+ }
+
+ private static void collectMethodNames(Set target, Class> type) {
+ for (Method method : type.getMethods()) {
+ target.add(method.getName());
+ }
+ }
+
+}
diff --git a/src/test/java/io/lettuce/core/api/consistency/CommandInterfaces.java b/src/test/java/io/lettuce/core/api/consistency/CommandInterfaces.java
new file mode 100644
index 0000000000..440f447e9a
--- /dev/null
+++ b/src/test/java/io/lettuce/core/api/consistency/CommandInterfaces.java
@@ -0,0 +1,312 @@
+/*
+ * Copyright 2011-Present, Redis Ltd. and Contributors
+ * All rights reserved.
+ *
+ * Licensed under the MIT License.
+ */
+package io.lettuce.core.api.consistency;
+
+import io.lettuce.core.api.async.BaseRedisAsyncCommands;
+import io.lettuce.core.api.async.RedisAclAsyncCommands;
+import io.lettuce.core.api.async.RedisArrayAsyncCommands;
+import io.lettuce.core.api.async.RedisBloomFilterAsyncCommands;
+import io.lettuce.core.api.async.RedisCuckooFilterAsyncCommands;
+import io.lettuce.core.api.async.RediSearchAsyncCommands;
+import io.lettuce.core.api.async.RedisFunctionAsyncCommands;
+import io.lettuce.core.api.async.RedisGeoAsyncCommands;
+import io.lettuce.core.api.async.RedisHLLAsyncCommands;
+import io.lettuce.core.api.async.RedisHashAsyncCommands;
+import io.lettuce.core.api.async.RedisJsonAsyncCommands;
+import io.lettuce.core.api.async.RedisKeyAsyncCommands;
+import io.lettuce.core.api.async.RedisListAsyncCommands;
+import io.lettuce.core.api.async.RedisScriptingAsyncCommands;
+import io.lettuce.core.api.async.RedisServerAsyncCommands;
+import io.lettuce.core.api.async.RedisSetAsyncCommands;
+import io.lettuce.core.api.async.RedisSortedSetAsyncCommands;
+import io.lettuce.core.api.async.RedisStreamAsyncCommands;
+import io.lettuce.core.api.async.RedisStringAsyncCommands;
+import io.lettuce.core.api.async.RedisTopKAsyncCommands;
+import io.lettuce.core.api.async.RedisTransactionalAsyncCommands;
+import io.lettuce.core.api.async.RedisVectorSetAsyncCommands;
+import io.lettuce.core.api.coroutines.BaseRedisCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisAclCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisArrayCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisBloomFilterCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisCuckooFilterCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RediSearchCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisFunctionCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisGeoCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisHLLCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisHashCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisJsonCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisKeyCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisListCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisScriptingCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisServerCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisSetCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisSortedSetCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisStreamCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisStringCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisTopKCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisTransactionalCoroutinesCommands;
+import io.lettuce.core.api.coroutines.RedisVectorSetCoroutinesCommands;
+import io.lettuce.core.api.reactive.BaseRedisReactiveCommands;
+import io.lettuce.core.api.reactive.RedisAclReactiveCommands;
+import io.lettuce.core.api.reactive.RedisArrayReactiveCommands;
+import io.lettuce.core.api.reactive.RedisBloomFilterReactiveCommands;
+import io.lettuce.core.api.reactive.RedisCuckooFilterReactiveCommands;
+import io.lettuce.core.api.reactive.RediSearchReactiveCommands;
+import io.lettuce.core.api.reactive.RedisFunctionReactiveCommands;
+import io.lettuce.core.api.reactive.RedisGeoReactiveCommands;
+import io.lettuce.core.api.reactive.RedisHLLReactiveCommands;
+import io.lettuce.core.api.reactive.RedisHashReactiveCommands;
+import io.lettuce.core.api.reactive.RedisJsonReactiveCommands;
+import io.lettuce.core.api.reactive.RedisKeyReactiveCommands;
+import io.lettuce.core.api.reactive.RedisListReactiveCommands;
+import io.lettuce.core.api.reactive.RedisScriptingReactiveCommands;
+import io.lettuce.core.api.reactive.RedisServerReactiveCommands;
+import io.lettuce.core.api.reactive.RedisSetReactiveCommands;
+import io.lettuce.core.api.reactive.RedisSortedSetReactiveCommands;
+import io.lettuce.core.api.reactive.RedisStreamReactiveCommands;
+import io.lettuce.core.api.reactive.RedisStringReactiveCommands;
+import io.lettuce.core.api.reactive.RedisTopKReactiveCommands;
+import io.lettuce.core.api.reactive.RedisTransactionalReactiveCommands;
+import io.lettuce.core.api.reactive.RedisVectorSetReactiveCommands;
+import io.lettuce.core.api.sync.BaseRedisCommands;
+import io.lettuce.core.api.sync.RedisAclCommands;
+import io.lettuce.core.api.sync.RedisArrayCommands;
+import io.lettuce.core.api.sync.RedisBloomFilterCommands;
+import io.lettuce.core.api.sync.RedisCuckooFilterCommands;
+import io.lettuce.core.api.sync.RediSearchCommands;
+import io.lettuce.core.api.sync.RedisFunctionCommands;
+import io.lettuce.core.api.sync.RedisGeoCommands;
+import io.lettuce.core.api.sync.RedisHLLCommands;
+import io.lettuce.core.api.sync.RedisHashCommands;
+import io.lettuce.core.api.sync.RedisJsonCommands;
+import io.lettuce.core.api.sync.RedisKeyCommands;
+import io.lettuce.core.api.sync.RedisListCommands;
+import io.lettuce.core.api.sync.RedisScriptingCommands;
+import io.lettuce.core.api.sync.RedisServerCommands;
+import io.lettuce.core.api.sync.RedisSetCommands;
+import io.lettuce.core.api.sync.RedisSortedSetCommands;
+import io.lettuce.core.api.sync.RedisStreamCommands;
+import io.lettuce.core.api.sync.RedisStringCommands;
+import io.lettuce.core.api.sync.RedisTopKCommands;
+import io.lettuce.core.api.sync.RedisTransactionalCommands;
+import io.lettuce.core.api.sync.RedisVectorSetCommands;
+import io.lettuce.core.cluster.api.async.BaseNodeSelectionAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionAclAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionArrayAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionBloomFilterAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionCuckooFilterAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionFunctionAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionGeoAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionHLLAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionHashAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionJsonAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionKeyAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionListAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionScriptingAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionSearchAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionServerAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionSetAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionSortedSetAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionStreamAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionStringAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionTopKAsyncCommands;
+import io.lettuce.core.cluster.api.async.NodeSelectionVectorSetAsyncCommands;
+import io.lettuce.core.cluster.api.sync.BaseNodeSelectionCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionAclCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionArrayCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionBloomFilterCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionCuckooFilterCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionFunctionCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionGeoCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionHLLCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionHashCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionJsonCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionKeyCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionListCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionScriptingCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionSearchCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionServerCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionSetCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionSortedSetCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionStreamCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionStringCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionTopKCommands;
+import io.lettuce.core.cluster.api.sync.NodeSelectionVectorSetCommands;
+import io.lettuce.core.sentinel.api.async.RedisSentinelAsyncCommands;
+import io.lettuce.core.sentinel.api.coroutines.RedisSentinelCoroutinesCommands;
+import io.lettuce.core.sentinel.api.reactive.RedisSentinelReactiveCommands;
+import io.lettuce.core.sentinel.api.sync.RedisSentinelCommands;
+
+/**
+ * Catalog of all Redis command interface groups and their per-flavor interfaces. Each entry represents one command group
+ * (formerly one template in {@code src/main/templates}) and lists the sync, async, reactive, Kotlin coroutine and cluster
+ * node-selection interfaces that must stay consistent with each other.
+ *
+ * This catalog is the single place where a new command group must be registered so that the API consistency test suite covers
+ * it.
+ */
+public enum CommandInterfaces {
+
+ BASE(BaseRedisCommands.class, BaseRedisAsyncCommands.class, BaseRedisReactiveCommands.class,
+ BaseRedisCoroutinesCommands.class, BaseNodeSelectionCommands.class, BaseNodeSelectionAsyncCommands.class),
+
+ ACL(RedisAclCommands.class, RedisAclAsyncCommands.class, RedisAclReactiveCommands.class, RedisAclCoroutinesCommands.class,
+ NodeSelectionAclCommands.class, NodeSelectionAclAsyncCommands.class),
+
+ ARRAY(RedisArrayCommands.class, RedisArrayAsyncCommands.class, RedisArrayReactiveCommands.class,
+ RedisArrayCoroutinesCommands.class, NodeSelectionArrayCommands.class, NodeSelectionArrayAsyncCommands.class),
+
+ BLOOM_FILTER(RedisBloomFilterCommands.class, RedisBloomFilterAsyncCommands.class, RedisBloomFilterReactiveCommands.class,
+ RedisBloomFilterCoroutinesCommands.class, NodeSelectionBloomFilterCommands.class,
+ NodeSelectionBloomFilterAsyncCommands.class),
+
+ CUCKOO_FILTER(RedisCuckooFilterCommands.class, RedisCuckooFilterAsyncCommands.class,
+ RedisCuckooFilterReactiveCommands.class, RedisCuckooFilterCoroutinesCommands.class,
+ NodeSelectionCuckooFilterCommands.class, NodeSelectionCuckooFilterAsyncCommands.class),
+
+ FUNCTION(RedisFunctionCommands.class, RedisFunctionAsyncCommands.class, RedisFunctionReactiveCommands.class,
+ RedisFunctionCoroutinesCommands.class, NodeSelectionFunctionCommands.class,
+ NodeSelectionFunctionAsyncCommands.class),
+
+ GEO(RedisGeoCommands.class, RedisGeoAsyncCommands.class, RedisGeoReactiveCommands.class, RedisGeoCoroutinesCommands.class,
+ NodeSelectionGeoCommands.class, NodeSelectionGeoAsyncCommands.class),
+
+ HASH(RedisHashCommands.class, RedisHashAsyncCommands.class, RedisHashReactiveCommands.class,
+ RedisHashCoroutinesCommands.class, NodeSelectionHashCommands.class, NodeSelectionHashAsyncCommands.class),
+
+ HLL(RedisHLLCommands.class, RedisHLLAsyncCommands.class, RedisHLLReactiveCommands.class, RedisHLLCoroutinesCommands.class,
+ NodeSelectionHLLCommands.class, NodeSelectionHLLAsyncCommands.class),
+
+ JSON(RedisJsonCommands.class, RedisJsonAsyncCommands.class, RedisJsonReactiveCommands.class,
+ RedisJsonCoroutinesCommands.class, NodeSelectionJsonCommands.class, NodeSelectionJsonAsyncCommands.class),
+
+ KEY(RedisKeyCommands.class, RedisKeyAsyncCommands.class, RedisKeyReactiveCommands.class, RedisKeyCoroutinesCommands.class,
+ NodeSelectionKeyCommands.class, NodeSelectionKeyAsyncCommands.class),
+
+ LIST(RedisListCommands.class, RedisListAsyncCommands.class, RedisListReactiveCommands.class,
+ RedisListCoroutinesCommands.class, NodeSelectionListCommands.class, NodeSelectionListAsyncCommands.class),
+
+ SCRIPTING(RedisScriptingCommands.class, RedisScriptingAsyncCommands.class, RedisScriptingReactiveCommands.class,
+ RedisScriptingCoroutinesCommands.class, NodeSelectionScriptingCommands.class,
+ NodeSelectionScriptingAsyncCommands.class),
+
+ SEARCH(RediSearchCommands.class, RediSearchAsyncCommands.class, RediSearchReactiveCommands.class,
+ RediSearchCoroutinesCommands.class, NodeSelectionSearchCommands.class, NodeSelectionSearchAsyncCommands.class),
+
+ SENTINEL(RedisSentinelCommands.class, RedisSentinelAsyncCommands.class, RedisSentinelReactiveCommands.class,
+ RedisSentinelCoroutinesCommands.class, null, null),
+
+ SERVER(RedisServerCommands.class, RedisServerAsyncCommands.class, RedisServerReactiveCommands.class,
+ RedisServerCoroutinesCommands.class, NodeSelectionServerCommands.class, NodeSelectionServerAsyncCommands.class),
+
+ SET(RedisSetCommands.class, RedisSetAsyncCommands.class, RedisSetReactiveCommands.class, RedisSetCoroutinesCommands.class,
+ NodeSelectionSetCommands.class, NodeSelectionSetAsyncCommands.class),
+
+ SORTED_SET(RedisSortedSetCommands.class, RedisSortedSetAsyncCommands.class, RedisSortedSetReactiveCommands.class,
+ RedisSortedSetCoroutinesCommands.class, NodeSelectionSortedSetCommands.class,
+ NodeSelectionSortedSetAsyncCommands.class),
+
+ STREAM(RedisStreamCommands.class, RedisStreamAsyncCommands.class, RedisStreamReactiveCommands.class,
+ RedisStreamCoroutinesCommands.class, NodeSelectionStreamCommands.class, NodeSelectionStreamAsyncCommands.class),
+
+ STRING(RedisStringCommands.class, RedisStringAsyncCommands.class, RedisStringReactiveCommands.class,
+ RedisStringCoroutinesCommands.class, NodeSelectionStringCommands.class, NodeSelectionStringAsyncCommands.class),
+
+ TOP_K(RedisTopKCommands.class, RedisTopKAsyncCommands.class, RedisTopKReactiveCommands.class,
+ RedisTopKCoroutinesCommands.class, NodeSelectionTopKCommands.class, NodeSelectionTopKAsyncCommands.class),
+
+ TRANSACTIONAL(RedisTransactionalCommands.class, RedisTransactionalAsyncCommands.class,
+ RedisTransactionalReactiveCommands.class, RedisTransactionalCoroutinesCommands.class, null, null),
+
+ VECTOR_SET(RedisVectorSetCommands.class, RedisVectorSetAsyncCommands.class, RedisVectorSetReactiveCommands.class,
+ RedisVectorSetCoroutinesCommands.class, NodeSelectionVectorSetCommands.class,
+ NodeSelectionVectorSetAsyncCommands.class);
+
+ private final Class> sync;
+
+ private final Class> async;
+
+ private final Class> reactive;
+
+ private final Class> coroutines;
+
+ private final Class> nodeSelectionSync;
+
+ private final Class> nodeSelectionAsync;
+
+ CommandInterfaces(Class> sync, Class> async, Class> reactive, Class> coroutines, Class> nodeSelectionSync,
+ Class> nodeSelectionAsync) {
+ this.sync = sync;
+ this.async = async;
+ this.reactive = reactive;
+ this.coroutines = coroutines;
+ this.nodeSelectionSync = nodeSelectionSync;
+ this.nodeSelectionAsync = nodeSelectionAsync;
+ }
+
+ public Class> sync() {
+ return sync;
+ }
+
+ public Class> async() {
+ return async;
+ }
+
+ public Class> reactive() {
+ return reactive;
+ }
+
+ public Class> coroutines() {
+ return coroutines;
+ }
+
+ /**
+ * @return the cluster node-selection sync interface, or {@code null} if the group has no node-selection flavor.
+ */
+ public Class> nodeSelectionSync() {
+ return nodeSelectionSync;
+ }
+
+ /**
+ * @return the cluster node-selection async interface, or {@code null} if the group has no node-selection flavor.
+ */
+ public Class> nodeSelectionAsync() {
+ return nodeSelectionAsync;
+ }
+
+ public boolean hasNodeSelection() {
+ return nodeSelectionSync != null;
+ }
+
+ /**
+ * @return the fully qualified name of the command builder that constructs this group's commands. The builder classes are
+ * package-private, hence the name instead of a {@code Class} literal.
+ */
+ public String commandBuilderClassName() {
+ switch (this) {
+ case ARRAY:
+ return "io.lettuce.core.RedisArrayCommandBuilder";
+ case BLOOM_FILTER:
+ return "io.lettuce.core.RedisBloomFilterCommandBuilder";
+ case CUCKOO_FILTER:
+ return "io.lettuce.core.RedisCuckooFilterCommandBuilder";
+ case JSON:
+ return "io.lettuce.core.RedisJsonCommandBuilder";
+ case SEARCH:
+ return "io.lettuce.core.RediSearchCommandBuilder";
+ case TOP_K:
+ return "io.lettuce.core.RedisTopKCommandBuilder";
+ case VECTOR_SET:
+ return "io.lettuce.core.RedisVectorSetCommandBuilder";
+ case SENTINEL:
+ return "io.lettuce.core.sentinel.SentinelCommandBuilder";
+ default:
+ return "io.lettuce.core.RedisCommandBuilder";
+ }
+ }
+
+}
diff --git a/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java b/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java
new file mode 100644
index 0000000000..6644f25257
--- /dev/null
+++ b/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java
@@ -0,0 +1,285 @@
+/*
+ * Copyright 2011-Present, Redis Ltd. and Contributors
+ * All rights reserved.
+ *
+ * Licensed under the MIT License.
+ */
+package io.lettuce.core.api.consistency;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringJoiner;
+
+/**
+ * Registry of all known, intentional deviations between the per-flavor Redis command interfaces. The API consistency test suite
+ * consults this registry; anything not listed here must follow the regular mapping rules (see
+ * {@code .agents/docs/api-consistency.md}).
+ *
+ * The tables were ported verbatim from the former {@code io.lettuce.apigenerator} generators
+ * ({@code CreateSyncApi#FILTER_METHODS}, {@code CreateAsyncApi#KEEP_METHOD_RESULT_TYPE},
+ * {@code CreateReactiveApi#KEEP_METHOD_RESULT_TYPE/FORCE_FLUX_RESULT/VALUE_WRAP/RESULT_SPEC},
+ * {@code Create*NodeSelectionClusterApi#FILTER_METHODS} and {@code KotlinCompilationUnitFactory}).
+ *
+ * Keys may take three forms, checked from most to least specific:
+ *
+ * - {@code methodName(ErasedParamSimpleName, ...)} — a specific overload, e.g. {@code aclCat(AclCategory)}
+ * - {@code SyncInterfaceSimpleName.methodName} — all overloads within one group, e.g. {@code BaseRedisCommands.reset}
+ * - {@code methodName} — all overloads in all groups
+ *
+ * A deviation entry must never be used to paper over a sync/async signature mismatch: the sync API is a runtime proxy over the
+ * async API, so such a mismatch fails at runtime.
+ */
+public final class KnownApiDeviations {
+
+ /**
+ * Methods that exist on the async, reactive and coroutine APIs but intentionally not on the sync API (flushing makes no
+ * sense for synchronously dispatched commands). From {@code CreateSyncApi#FILTER_METHODS}.
+ */
+ public static final Set NOT_ON_SYNC_API = setOf("setAutoFlushCommands", "flushCommands");
+
+ /**
+ * Async methods that keep the sync return type instead of wrapping it in {@code RedisFuture}. From
+ * {@code CreateAsyncApi#KEEP_METHOD_RESULT_TYPE}.
+ */
+ public static final Set KEEP_SYNC_RESULT_TYPE_ASYNC = setOf("shutdown", "debugOom", "debugSegfault", "digest",
+ "close", "isOpen", "getStatefulConnection", "setAutoFlushCommands", "flushCommands", "setTimeout", "getJsonParser");
+
+ /**
+ * Reactive methods that keep the sync return type instead of wrapping it in {@code Mono}/{@code Flux}. From
+ * {@code CreateReactiveApi#KEEP_METHOD_RESULT_TYPE}.
+ */
+ public static final Set KEEP_SYNC_RESULT_TYPE_REACTIVE = setOf("digest", "close", "isOpen", "getStatefulConnection",
+ "setAutoFlushCommands", "flushCommands", "setTimeout", "getJsonParser");
+
+ /**
+ * Aggregate-declared methods whose return type is flavor-specific by design (connection and node-selection accessors, e.g.
+ * {@code getConnection} returns {@code RedisClusterCommands} on the sync aggregate and {@code RedisClusterAsyncCommands} on
+ * the async one). Presence is still verified; the return-type check is skipped.
+ */
+ public static final Set AGGREGATE_FLAVOR_SPECIFIC_RETURN = setOf("getConnection", "getStatefulConnection",
+ "masters", "upstream", "slaves", "replicas", "all", "readonly", "nodes");
+
+ /**
+ * Aggregate-declared methods without a reactive counterpart: the node-selection API exists for the sync and async flavors
+ * only.
+ */
+ public static final Set NOT_ON_REACTIVE_AGGREGATE = setOf("masters", "upstream", "slaves", "replicas", "all",
+ "readonly", "nodes");
+
+ /**
+ * Methods that exist only on the reactive API (reactive-specific accessors used by the coroutine implementations).
+ */
+ public static final Set REACTIVE_ONLY = setOf("getJsonParser");
+
+ /**
+ * Methods whose reactive parameter generics intentionally differ from the sync flavor: {@code dispatch} declares
+ * {@code CommandOutput} instead of {@code CommandOutput} (the reactive result is emitted by the
+ * publisher, not by the output's type argument). From {@code CreateReactiveApi#methodMutator()}.
+ */
+ public static final Set REACTIVE_PARAMETER_FLAVOR_SPECIFIC = setOf("dispatch");
+
+ /**
+ * Reactive methods returning {@code Flux} although the sync return type is not a {@code List}/{@code Set}. From
+ * {@code CreateReactiveApi#FORCE_FLUX_RESULT}.
+ */
+ public static final Set FORCE_FLUX = setOf("eval", "evalsha", "evalReadOnly", "evalshaReadOnly", "fcall",
+ "fcallReadOnly", "dispatch");
+
+ /**
+ * Reactive methods whose element type is wrapped in {@code Value} because Redis may return null elements. From
+ * {@code CreateReactiveApi#VALUE_WRAP}.
+ */
+ public static final Set REACTIVE_VALUE_WRAP = setOf("geopos", "bitfield", "bfInsert", "bfMAdd", "cfInsertNx",
+ "topKAdd", "topKIncrBy",
+ // calibration 2026-07: RedisArray element reads return null for missing indexes
+ "argetrange", "armget");
+
+ /**
+ * Reactive methods with a fully overridden return type (normalized, package-less rendering). From
+ * {@code CreateReactiveApi#RESULT_SPEC}.
+ */
+ public static final Map REACTIVE_RESULT_OVERRIDES;
+
+ /**
+ * Methods deliberately absent from the cluster node-selection interfaces (connection-control and per-node-only commands).
+ * From {@code Create(Sync|Async)NodeSelectionClusterApi#FILTER_METHODS}.
+ */
+ public static final Set NODE_SELECTION_EXCLUDED = setOf("shutdown", "debugOom", "debugSegfault", "digest",
+ "readOnly", "readWrite", "setAutoFlushCommands", "flushCommands");
+
+ /**
+ * Methods absent from the node-selection sync flavor only: {@code dispatch} exists on
+ * {@code BaseNodeSelectionAsyncCommands} (including {@code Supplier}-based overloads without a sync counterpart) but not on
+ * the sync node-selection API.
+ */
+ public static final Set NOT_ON_NODE_SELECTION_SYNC = setOf("dispatch");
+
+ /**
+ * Methods deprecated on the async node-selection API although their sync counterpart is not: the {@code CommandOutput}-
+ * based {@code dispatch} overloads are deprecated since 6.2 in favor of the {@code Supplier}-based overloads because a
+ * single output instance cannot be reused across the responses of multiple nodes.
+ */
+ public static final Set NODE_SELECTION_EXTRA_DEPRECATED = setOf("dispatch");
+
+ /**
+ * Sync methods with no coroutine counterpart. From {@code KotlinCompilationUnitFactory#SKIP_METHODS}.
+ */
+ public static final Set COROUTINES_SKIP = setOf("getStatefulConnection");
+
+ /**
+ * Coroutine methods that are plain functions instead of {@code suspend} functions. From
+ * {@code KotlinCompilationUnitFactory#NON_SUSPENDABLE_METHODS}.
+ */
+ public static final Set COROUTINES_NON_SUSPENDABLE = setOf("isOpen", "flushCommands", "setAutoFlushCommands");
+
+ /**
+ * Coroutine methods returning {@code Flow} instead of a suspended scalar/collection. From
+ * {@code KotlinCompilationUnitFactory#FLOW_METHODS}.
+ */
+ public static final Set COROUTINES_FLOW = setOf("aclList", "aclLog", "dispatch", "geohash", "georadius",
+ "georadiusbymember", "geosearch", "hgetall", "hkeys", "hmget", "hvals", "keys", "mget", "sdiff", "sinter",
+ "smembers", "smismember", "sort", "sortReadOnly", "sunion", "xclaim", "xrange", "xread", "xreadgroup", "xrevrange",
+ "zdiff", "zdiffWithScores", "zinter", "zinterWithScores", "zrange", "zrangeWithScores", "zrangebylex",
+ "zrangebyscore", "zrangebyscoreWithScores", "zrevrange", "zrevrangeWithScores", "zrevrangebylex",
+ "zrevrangebyscore", "zrevrangebyscoreWithScores", "zunion", "zunionWithScores",
+ // calibration 2026-07: only the multi-element overloads stream; the single-element overloads suspend
+ "srandmember(K, long)", "zpopmax(K, long)", "zpopmin(K, long)", "xpending(K, K, Range, Limit)",
+ "xpending(K, Consumer, Range, Limit)", "xpending(K, XPendingArgs)",
+ // calibration 2026-07: multi-element commands added after the generators stopped being used
+ "hgetdel", "hgetex", "xackdel", "xdelex");
+
+ /**
+ * Deprecated sync methods that are still exposed on the coroutine API. From
+ * {@code KotlinCompilationUnitFactory#KEEP_DEPRECATED_METHODS}.
+ */
+ public static final Set COROUTINES_KEEP_DEPRECATED = setOf("flushallAsync", "flushdbAsync", "slaveof",
+ "slaveofNoOne", "slaves");
+
+ /**
+ * Coroutine methods with a fully overridden return type (normalized, package-less Kotlin rendering). From
+ * {@code KotlinCompilationUnitFactory#RESULT_SPEC}.
+ */
+ public static final Map COROUTINES_RESULT_OVERRIDES;
+
+ /**
+ * Interface methods that do not correspond to any command-builder method: connection control, locally computed values
+ * ({@code digest}), generic dispatch and transaction plumbing ({@code exec} builds its command in the transaction
+ * machinery, not in the builder).
+ */
+ public static final Set BUILDER_EXCLUDED = setOf("close", "isOpen", "getStatefulConnection", "setAutoFlushCommands",
+ "flushCommands", "digest", "dispatch", "exec");
+
+ /**
+ * Interface methods whose builder method has a different name (the builder variant takes a flag or a different argument
+ * shape).
+ */
+ public static final Map BUILDER_ALIASES;
+
+ /**
+ * Command-producing builder methods that are implementation details of the async/reactive/coroutine layers (streaming and
+ * {@code Value}/{@code KeyValue}-wrapping variants, protocol handshake commands) rather than a public command entry point.
+ */
+ public static final Set BUILDER_INTERNAL = setOf("bitfieldValue", "geoposValues", "hgetallKeyValue",
+ "hmgetKeyValue", "mgetKeyValue", "armgetValues", "argetrangeValues", "bfInsertValues", "bfMAddValues",
+ "cfInsertNxValues", "topKAddValues", "topKIncrByValues", "hscanStreaming", "hscanNoValuesStreaming",
+ "scanStreaming", "sscanStreaming", "zscanStreaming", "hello", "sync", "clusterAddslots", "clusterDelslots",
+ // connection-level commands exposed via StatefulRedisConnection, not via the command interfaces
+ "select", "swapdb");
+
+ static {
+ Map reactive = new HashMap<>();
+ reactive.put("geopos", "Flux>");
+ reactive.put("aclCat()", "Mono>");
+ reactive.put("aclCat(AclCategory)", "Mono>");
+ reactive.put("aclGetuser", "Mono>");
+ reactive.put("bitfield", "Flux>");
+ reactive.put("hgetall", "Flux>");
+ // Redis returns null for elements that were not found, so the result is a Mono of a nullable-element list
+ reactive.put("zmscore", "Mono>");
+ reactive.put("hgetall(KeyValueStreamingChannel, K)", "Mono");
+ // calibration 2026-07: entrenched aggregate-declared shapes; changing them to Flux would break the API
+ reactive.put("clusterLinks", "Mono>>");
+ reactive.put("clusterShards", "Mono>");
+ REACTIVE_RESULT_OVERRIDES = Collections.unmodifiableMap(reactive);
+
+ Map coroutines = new HashMap<>();
+ coroutines.put("hgetall", "Flow>");
+ coroutines.put("zmscore", "List");
+ COROUTINES_RESULT_OVERRIDES = Collections.unmodifiableMap(coroutines);
+
+ Map builderAliases = new HashMap<>();
+ builderAliases.put("waitForReplication", "wait");
+ builderAliases.put("getMasterAddrByName", "getMasterAddrByKey");
+ builderAliases.put("evalReadOnly", "eval");
+ builderAliases.put("evalshaReadOnly", "evalsha");
+ builderAliases.put("fcallReadOnly", "fcall");
+ builderAliases.put("zrevrangestore", "zrangestore");
+ builderAliases.put("zrevrangestorebylex", "zrangestorebylex");
+ builderAliases.put("zrevrangestorebyscore", "zrangestorebyscore");
+ builderAliases.put("flushallAsync", "flushall");
+ builderAliases.put("flushdbAsync", "flushdb");
+ builderAliases.put("vClearAttributes", "vsetattr");
+ BUILDER_ALIASES = Collections.unmodifiableMap(builderAliases);
+ }
+
+ private KnownApiDeviations() {
+ }
+
+ /**
+ * Check whether a method matches an entry of a deviation table, by overload signature, {@code Interface.method} or bare
+ * method name.
+ */
+ public static boolean contains(Set table, Method method, Class> declaringGroup) {
+ return table.contains(signatureKey(method)) || table.contains(qualifiedKey(method, declaringGroup))
+ || table.contains(method.getName());
+ }
+
+ /**
+ * Look up an override for a method, from most specific (overload signature) to least specific (bare method name) key.
+ *
+ * @return the override value or {@code null} if none applies.
+ */
+ public static String lookup(Map table, Method method, Class> declaringGroup) {
+ String bySignature = table.get(signatureKey(method));
+ if (bySignature != null) {
+ return bySignature;
+ }
+ String byQualifiedName = table.get(qualifiedKey(method, declaringGroup));
+ if (byQualifiedName != null) {
+ return byQualifiedName;
+ }
+ return table.get(method.getName());
+ }
+
+ /**
+ * Overload-specific key, e.g. {@code aclCat(AclCategory)} or {@code hgetall(KeyValueStreamingChannel, K)}. Generic
+ * parameters use their type-variable name, other parameters their erased simple class name.
+ */
+ public static String signatureKey(Method method) {
+ StringJoiner params = new StringJoiner(", ", method.getName() + "(", ")");
+ java.lang.reflect.Type[] genericTypes = method.getGenericParameterTypes();
+ Class>[] erased = method.getParameterTypes();
+ for (int i = 0; i < erased.length; i++) {
+ if (genericTypes[i] instanceof java.lang.reflect.TypeVariable) {
+ params.add(((java.lang.reflect.TypeVariable>) genericTypes[i]).getName());
+ } else {
+ params.add(erased[i].getSimpleName());
+ }
+ }
+ return params.toString();
+ }
+
+ private static String qualifiedKey(Method method, Class> declaringGroup) {
+ return declaringGroup.getSimpleName() + "." + method.getName();
+ }
+
+ private static Set setOf(String... entries) {
+ return Collections.unmodifiableSet(new HashSet<>(Arrays.asList(entries)));
+ }
+
+}
diff --git a/src/test/java/io/lettuce/core/api/consistency/NodeSelectionConsistencyUnitTests.java b/src/test/java/io/lettuce/core/api/consistency/NodeSelectionConsistencyUnitTests.java
new file mode 100644
index 0000000000..05212bcbc2
--- /dev/null
+++ b/src/test/java/io/lettuce/core/api/consistency/NodeSelectionConsistencyUnitTests.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2011-Present, Redis Ltd. and Contributors
+ * All rights reserved.
+ *
+ * Licensed under the MIT License.
+ */
+package io.lettuce.core.api.consistency;
+
+import static io.lettuce.TestTags.UNIT_TEST;
+
+import java.lang.reflect.Method;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/**
+ * Verify that the cluster node-selection interfaces of every eligible command group mirror the sync interface: every sync
+ * method (except the {@link KnownApiDeviations#NODE_SELECTION_EXCLUDED excluded} connection-control methods) must exist with
+ * its return type wrapped in {@code Executions} (sync flavor) or {@code AsyncExecutions} (async flavor), and node-selection
+ * interfaces must not declare methods unknown to the sync API.
+ */
+@Tag(UNIT_TEST)
+class NodeSelectionConsistencyUnitTests {
+
+ @ParameterizedTest
+ @EnumSource(CommandInterfaces.class)
+ void syncMethodsExistOnNodeSelectionApisWithWrappedReturnType(CommandInterfaces group) {
+
+ if (!group.hasNodeSelection()) {
+ return;
+ }
+
+ SoftAssertions softly = new SoftAssertions();
+ softly.assertThat(TypeSignatures.typeParameterNames(group.nodeSelectionSync()))
+ .as("type parameters of %s", group.nodeSelectionSync().getSimpleName())
+ .isEqualTo(TypeSignatures.typeParameterNames(group.sync()));
+
+ for (Method syncMethod : TypeSignatures.apiMethods(group.sync())) {
+
+ boolean excluded = KnownApiDeviations.contains(KnownApiDeviations.NODE_SELECTION_EXCLUDED, syncMethod,
+ group.sync());
+ boolean excludedOnSync = excluded
+ || KnownApiDeviations.contains(KnownApiDeviations.NOT_ON_NODE_SELECTION_SYNC, syncMethod, group.sync());
+
+ assertCounterpart(softly, group, group.nodeSelectionSync(), syncMethod, "Executions", excludedOnSync);
+ assertCounterpart(softly, group, group.nodeSelectionAsync(), syncMethod, "AsyncExecutions", excluded);
+ }
+
+ softly.assertAll();
+ }
+
+ private void assertCounterpart(SoftAssertions softly, CommandInterfaces group, Class> nodeSelection, Method syncMethod,
+ String wrapper, boolean excluded) {
+
+ Method counterpart = TypeSignatures.findCounterpart(nodeSelection, syncMethod);
+
+ if (excluded) {
+ softly.assertThat(counterpart).as("%s is excluded from node-selection APIs but present on %s",
+ TypeSignatures.describe(group.sync(), syncMethod), nodeSelection.getSimpleName()).isNull();
+ return;
+ }
+
+ if (counterpart == null) {
+ softly.fail("%s is missing on %s", TypeSignatures.describe(group.sync(), syncMethod),
+ nodeSelection.getSimpleName());
+ return;
+ }
+
+ softly.assertThat(TypeSignatures.normalize(counterpart.getGenericReturnType()))
+ .as("return type of %s", TypeSignatures.describe(nodeSelection, counterpart))
+ .isEqualTo(TypeSignatures.expectedNodeSelectionReturnType(syncMethod, wrapper));
+
+ softly.assertThat(TypeSignatures.parameterSignature(counterpart))
+ .as("parameter types of %s", TypeSignatures.describe(nodeSelection, counterpart))
+ .isEqualTo(TypeSignatures.parameterSignature(syncMethod));
+
+ boolean expectDeprecated = syncMethod.isAnnotationPresent(Deprecated.class)
+ || KnownApiDeviations.contains(KnownApiDeviations.NODE_SELECTION_EXTRA_DEPRECATED, syncMethod, group.sync());
+ softly.assertThat(counterpart.isAnnotationPresent(Deprecated.class))
+ .as("@Deprecated parity of %s", TypeSignatures.describe(nodeSelection, counterpart))
+ .isEqualTo(expectDeprecated);
+ }
+
+ @ParameterizedTest
+ @EnumSource(CommandInterfaces.class)
+ void nodeSelectionMethodsExistOnSyncApi(CommandInterfaces group) {
+
+ if (!group.hasNodeSelection()) {
+ return;
+ }
+
+ SoftAssertions softly = new SoftAssertions();
+
+ for (Class> nodeSelection : new Class>[] { group.nodeSelectionSync(), group.nodeSelectionAsync() }) {
+ for (Method method : TypeSignatures.apiMethods(nodeSelection)) {
+ if (nodeSelection == group.nodeSelectionAsync()
+ && KnownApiDeviations.contains(KnownApiDeviations.NOT_ON_NODE_SELECTION_SYNC, method, group.sync())) {
+ // dispatch exists on the async node-selection API only, incl. Supplier overloads without sync counterpart
+ continue;
+ }
+ if (TypeSignatures.findCounterpart(group.sync(), method) == null) {
+ softly.fail("%s is missing on %s", TypeSignatures.describe(nodeSelection, method),
+ group.sync().getSimpleName());
+ }
+ }
+ }
+
+ softly.assertAll();
+ }
+
+ /**
+ * Every entry of the exclusion list must still match at least one sync method — otherwise the entry is stale and should be
+ * removed.
+ */
+ @Test
+ void nodeSelectionExclusionsAreNotStale() {
+
+ Set matched = new HashSet<>();
+
+ for (CommandInterfaces group : CommandInterfaces.values()) {
+ if (!group.hasNodeSelection()) {
+ continue;
+ }
+ for (Method syncMethod : TypeSignatures.apiMethods(group.sync())) {
+ for (String key : KnownApiDeviations.NODE_SELECTION_EXCLUDED) {
+ if (KnownApiDeviations.contains(java.util.Collections.singleton(key), syncMethod, group.sync())) {
+ matched.add(key);
+ }
+ }
+ }
+ }
+
+ SoftAssertions softly = new SoftAssertions();
+ for (String key : KnownApiDeviations.NODE_SELECTION_EXCLUDED) {
+ // setAutoFlushCommands/flushCommands are excluded defensively although never present on the sync API
+ if (KnownApiDeviations.NOT_ON_SYNC_API.contains(key)) {
+ continue;
+ }
+ softly.assertThat(matched).as("stale NODE_SELECTION_EXCLUDED entry '%s'", key).contains(key);
+ }
+ softly.assertAll();
+ }
+
+}
diff --git a/src/test/java/io/lettuce/core/api/consistency/SyncAsyncConsistencyUnitTests.java b/src/test/java/io/lettuce/core/api/consistency/SyncAsyncConsistencyUnitTests.java
new file mode 100644
index 0000000000..46ecff7322
--- /dev/null
+++ b/src/test/java/io/lettuce/core/api/consistency/SyncAsyncConsistencyUnitTests.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2011-Present, Redis Ltd. and Contributors
+ * All rights reserved.
+ *
+ * Licensed under the MIT License.
+ */
+package io.lettuce.core.api.consistency;
+
+import static io.lettuce.TestTags.UNIT_TEST;
+
+import java.lang.reflect.Method;
+
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/**
+ * Verify that the sync and async command interfaces of every command group declare the same methods and that the async return
+ * types wrap the sync return types in {@code RedisFuture}.
+ *
+ * This parity is load-bearing at runtime: the sync API is a dynamic proxy that translates each sync method to the async method
+ * with the same name and parameter types (see {@code FutureSyncInvocationHandler}), so a mismatch throws at runtime.
+ */
+@Tag(UNIT_TEST)
+class SyncAsyncConsistencyUnitTests {
+
+ @ParameterizedTest
+ @EnumSource(CommandInterfaces.class)
+ void syncMethodsExistOnAsyncApiWithWrappedReturnType(CommandInterfaces group) {
+
+ SoftAssertions softly = new SoftAssertions();
+ softly.assertThat(TypeSignatures.typeParameterNames(group.async()))
+ .as("type parameters of %s", group.async().getSimpleName())
+ .isEqualTo(TypeSignatures.typeParameterNames(group.sync()));
+
+ for (Method syncMethod : TypeSignatures.apiMethods(group.sync())) {
+
+ Method asyncMethod = TypeSignatures.findCounterpart(group.async(), syncMethod);
+ if (asyncMethod == null) {
+ softly.fail("%s is missing on %s", TypeSignatures.describe(group.sync(), syncMethod),
+ group.async().getSimpleName());
+ continue;
+ }
+
+ softly.assertThat(TypeSignatures.normalize(asyncMethod.getGenericReturnType()))
+ .as("return type of %s", TypeSignatures.describe(group.async(), asyncMethod))
+ .isEqualTo(TypeSignatures.expectedAsyncReturnType(syncMethod, group.sync()));
+
+ softly.assertThat(TypeSignatures.parameterSignature(asyncMethod))
+ .as("parameter types of %s", TypeSignatures.describe(group.async(), asyncMethod))
+ .isEqualTo(TypeSignatures.parameterSignature(syncMethod));
+
+ softly.assertThat(asyncMethod.isAnnotationPresent(Deprecated.class))
+ .as("@Deprecated parity of %s", TypeSignatures.describe(group.async(), asyncMethod))
+ .isEqualTo(syncMethod.isAnnotationPresent(Deprecated.class));
+ }
+
+ softly.assertAll();
+ }
+
+ @ParameterizedTest
+ @EnumSource(CommandInterfaces.class)
+ void asyncMethodsExistOnSyncApi(CommandInterfaces group) {
+
+ SoftAssertions softly = new SoftAssertions();
+
+ for (Method asyncMethod : TypeSignatures.apiMethods(group.async())) {
+
+ if (KnownApiDeviations.contains(KnownApiDeviations.NOT_ON_SYNC_API, asyncMethod, group.sync())) {
+ continue;
+ }
+
+ if (TypeSignatures.findCounterpart(group.sync(), asyncMethod) == null) {
+ softly.fail("%s is missing on %s (this breaks the sync-over-async runtime proxy)",
+ TypeSignatures.describe(group.async(), asyncMethod), group.sync().getSimpleName());
+ }
+ }
+
+ softly.assertAll();
+ }
+
+}
diff --git a/src/test/java/io/lettuce/core/api/consistency/SyncReactiveConsistencyUnitTests.java b/src/test/java/io/lettuce/core/api/consistency/SyncReactiveConsistencyUnitTests.java
new file mode 100644
index 0000000000..d5065242a1
--- /dev/null
+++ b/src/test/java/io/lettuce/core/api/consistency/SyncReactiveConsistencyUnitTests.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2011-Present, Redis Ltd. and Contributors
+ * All rights reserved.
+ *
+ * Licensed under the MIT License.
+ */
+package io.lettuce.core.api.consistency;
+
+import static io.lettuce.TestTags.UNIT_TEST;
+
+import java.lang.reflect.Method;
+
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/**
+ * Verify that the sync and reactive command interfaces of every command group declare the same methods and that the reactive
+ * return types follow the mapping rules: {@code Mono} for scalars, {@code Flux} for {@code List}/{@code Set} results,
+ * plus the deviations recorded in {@link KnownApiDeviations}. Streaming-channel variants must be deprecated on the reactive API
+ * in favor of consuming the {@code Publisher}.
+ */
+@Tag(UNIT_TEST)
+class SyncReactiveConsistencyUnitTests {
+
+ @ParameterizedTest
+ @EnumSource(CommandInterfaces.class)
+ void syncMethodsExistOnReactiveApiWithMappedReturnType(CommandInterfaces group) {
+
+ SoftAssertions softly = new SoftAssertions();
+ softly.assertThat(TypeSignatures.typeParameterNames(group.reactive()))
+ .as("type parameters of %s", group.reactive().getSimpleName())
+ .isEqualTo(TypeSignatures.typeParameterNames(group.sync()));
+
+ for (Method syncMethod : TypeSignatures.apiMethods(group.sync())) {
+
+ Method reactiveMethod = TypeSignatures.findCounterpart(group.reactive(), syncMethod);
+ if (reactiveMethod == null) {
+ softly.fail("%s is missing on %s", TypeSignatures.describe(group.sync(), syncMethod),
+ group.reactive().getSimpleName());
+ continue;
+ }
+
+ softly.assertThat(TypeSignatures.normalize(reactiveMethod.getGenericReturnType()))
+ .as("return type of %s", TypeSignatures.describe(group.reactive(), reactiveMethod))
+ .isEqualTo(TypeSignatures.expectedReactiveReturnType(syncMethod, group.sync()));
+
+ if (!KnownApiDeviations.contains(KnownApiDeviations.REACTIVE_PARAMETER_FLAVOR_SPECIFIC, syncMethod, group.sync())) {
+ softly.assertThat(TypeSignatures.parameterSignature(reactiveMethod))
+ .as("parameter types of %s", TypeSignatures.describe(group.reactive(), reactiveMethod))
+ .isEqualTo(TypeSignatures.parameterSignature(syncMethod));
+ }
+
+ // streaming-channel variants are deprecated on the reactive API in favor of consuming the Publisher
+ boolean expectDeprecated = syncMethod.isAnnotationPresent(Deprecated.class)
+ || TypeSignatures.isStreamingChannelMethod(syncMethod);
+ softly.assertThat(reactiveMethod.isAnnotationPresent(Deprecated.class))
+ .as("@Deprecated parity of %s", TypeSignatures.describe(group.reactive(), reactiveMethod))
+ .isEqualTo(expectDeprecated);
+ }
+
+ softly.assertAll();
+ }
+
+ @ParameterizedTest
+ @EnumSource(CommandInterfaces.class)
+ void reactiveMethodsExistOnSyncApi(CommandInterfaces group) {
+
+ SoftAssertions softly = new SoftAssertions();
+
+ for (Method reactiveMethod : TypeSignatures.apiMethods(group.reactive())) {
+
+ if (KnownApiDeviations.contains(KnownApiDeviations.NOT_ON_SYNC_API, reactiveMethod, group.sync())
+ || KnownApiDeviations.contains(KnownApiDeviations.REACTIVE_ONLY, reactiveMethod, group.sync())) {
+ continue;
+ }
+
+ if (TypeSignatures.findCounterpart(group.sync(), reactiveMethod) == null) {
+ softly.fail("%s is missing on %s", TypeSignatures.describe(group.reactive(), reactiveMethod),
+ group.sync().getSimpleName());
+ }
+ }
+
+ softly.assertAll();
+ }
+
+}
diff --git a/src/test/java/io/lettuce/core/api/consistency/TypeSignatures.java b/src/test/java/io/lettuce/core/api/consistency/TypeSignatures.java
new file mode 100644
index 0000000000..55aeb99c10
--- /dev/null
+++ b/src/test/java/io/lettuce/core/api/consistency/TypeSignatures.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2011-Present, Redis Ltd. and Contributors
+ * All rights reserved.
+ *
+ * Licensed under the MIT License.
+ */
+package io.lettuce.core.api.consistency;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Type;
+import java.lang.reflect.TypeVariable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Reflection helpers for the API consistency test suite: normalized type rendering, method matching across interface flavors
+ * and computation of the expected wrapped return types per flavor.
+ */
+public final class TypeSignatures {
+
+ private static final Map, String> PRIMITIVE_BOX = new HashMap<>();
+
+ static {
+ PRIMITIVE_BOX.put(void.class, "Void");
+ PRIMITIVE_BOX.put(boolean.class, "Boolean");
+ PRIMITIVE_BOX.put(byte.class, "Byte");
+ PRIMITIVE_BOX.put(short.class, "Short");
+ PRIMITIVE_BOX.put(int.class, "Integer");
+ PRIMITIVE_BOX.put(long.class, "Long");
+ PRIMITIVE_BOX.put(float.class, "Float");
+ PRIMITIVE_BOX.put(double.class, "Double");
+ PRIMITIVE_BOX.put(char.class, "Character");
+ }
+
+ private TypeSignatures() {
+ }
+
+ /**
+ * The command methods of an interface: declared, non-static, non-synthetic, sorted for stable test output.
+ */
+ public static List apiMethods(Class> type) {
+ return Arrays.stream(type.getDeclaredMethods())
+ .filter(m -> !m.isSynthetic() && !m.isBridge() && !Modifier.isStatic(m.getModifiers()))
+ .sorted(Comparator.comparing(Method::toString)).collect(Collectors.toList());
+ }
+
+ /**
+ * Find the counterpart of {@code method} on {@code target} by name and erased parameter types (the same matching the sync
+ * proxy's {@code MethodTranslator} performs at runtime).
+ *
+ * @return the matching method or {@code null}.
+ */
+ public static Method findCounterpart(Class> target, Method method) {
+ try {
+ return target.getMethod(method.getName(), method.getParameterTypes());
+ } catch (NoSuchMethodException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Render a type as a normalized, package-less string, e.g. {@code Mono>} or {@code Flux>}.
+ */
+ public static String normalize(Type type) {
+ return type.getTypeName().replace('$', '.').replaceAll("(?:[a-z][A-Za-z0-9_]*\\.)+", "");
+ }
+
+ /**
+ * Render a type like {@link #normalize(Type)}, boxing primitives ({@code long} → {@code Long}, {@code void} →
+ * {@code Void}).
+ */
+ public static String normalizeBoxed(Type type) {
+ if (type instanceof Class> && ((Class>) type).isPrimitive()) {
+ return PRIMITIVE_BOX.get(type);
+ }
+ return normalize(type);
+ }
+
+ /**
+ * The expected async return type for a sync method: {@code RedisFuture} unless the method keeps its sync signature.
+ */
+ public static String expectedAsyncReturnType(Method syncMethod, Class> syncGroup) {
+ if (KnownApiDeviations.contains(KnownApiDeviations.KEEP_SYNC_RESULT_TYPE_ASYNC, syncMethod, syncGroup)) {
+ return normalize(syncMethod.getGenericReturnType());
+ }
+ return "RedisFuture<" + normalizeBoxed(syncMethod.getGenericReturnType()) + ">";
+ }
+
+ /**
+ * The expected reactive return type for a sync method, applying the override, keep-type, force-Flux, collection-to-Flux and
+ * Value-wrap rules in the same order as the former {@code CreateReactiveApi} generator.
+ */
+ public static String expectedReactiveReturnType(Method syncMethod, Class> syncGroup) {
+ String override = KnownApiDeviations.lookup(KnownApiDeviations.REACTIVE_RESULT_OVERRIDES, syncMethod, syncGroup);
+ if (override != null) {
+ return override;
+ }
+ if (KnownApiDeviations.contains(KnownApiDeviations.KEEP_SYNC_RESULT_TYPE_REACTIVE, syncMethod, syncGroup)) {
+ return normalize(syncMethod.getGenericReturnType());
+ }
+
+ String baseType = "Mono";
+ String typeArgument = normalizeBoxed(syncMethod.getGenericReturnType());
+
+ if (KnownApiDeviations.contains(KnownApiDeviations.FORCE_FLUX, syncMethod, syncGroup)) {
+ baseType = "Flux";
+ } else if (typeArgument.startsWith("List<")) {
+ baseType = "Flux";
+ typeArgument = typeArgument.substring(5, typeArgument.length() - 1);
+ } else if (typeArgument.startsWith("Set<")) {
+ baseType = "Flux";
+ typeArgument = typeArgument.substring(4, typeArgument.length() - 1);
+ }
+
+ if (KnownApiDeviations.contains(KnownApiDeviations.REACTIVE_VALUE_WRAP, syncMethod, syncGroup)) {
+ typeArgument = "Value<" + typeArgument + ">";
+ }
+
+ return baseType + "<" + typeArgument + ">";
+ }
+
+ /**
+ * The expected node-selection return type for a sync method: {@code Executions} or {@code AsyncExecutions}.
+ */
+ public static String expectedNodeSelectionReturnType(Method syncMethod, String wrapper) {
+ return wrapper + "<" + normalizeBoxed(syncMethod.getGenericReturnType()) + ">";
+ }
+
+ /**
+ * Whether a method consumes a {@code *StreamingChannel} (those variants are deprecated on the reactive API and absent from
+ * the coroutine API).
+ */
+ public static boolean isStreamingChannelMethod(Method method) {
+ return Arrays.stream(method.getParameterTypes()).anyMatch(p -> p.getSimpleName().contains("StreamingChannel"));
+ }
+
+ /**
+ * The normalized generic parameter list of a method, e.g. {@code K, Map, ScoredValue[]}. Unlike the erased
+ * matching of {@link #findCounterpart(Class, Method)}, this retains type arguments so that flavors cannot diverge in their
+ * generic signatures (e.g. {@code Map} vs {@code Map}).
+ */
+ public static String parameterSignature(Method method) {
+ return Arrays.stream(method.getGenericParameterTypes()).map(TypeSignatures::normalize)
+ .collect(Collectors.joining(", "));
+ }
+
+ /**
+ * The names of the type parameters declared by an interface, e.g. {@code [K, V]}.
+ */
+ public static List typeParameterNames(Class> type) {
+ List names = new ArrayList<>();
+ for (TypeVariable> variable : type.getTypeParameters()) {
+ names.add(variable.getName());
+ }
+ return names;
+ }
+
+ /**
+ * Describe a method as {@code Interface.name(Param, ...)} for assertion messages.
+ */
+ public static String describe(Class> group, Method method) {
+ return group.getSimpleName() + "." + KnownApiDeviations.signatureKey(method);
+ }
+
+}
diff --git a/src/test/kotlin/io/lettuce/core/api/consistency/KotlinCoroutinesConsistencyUnitTests.kt b/src/test/kotlin/io/lettuce/core/api/consistency/KotlinCoroutinesConsistencyUnitTests.kt
new file mode 100644
index 0000000000..cdff1667fe
--- /dev/null
+++ b/src/test/kotlin/io/lettuce/core/api/consistency/KotlinCoroutinesConsistencyUnitTests.kt
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2011-Present, Redis Ltd. and Contributors
+ * All rights reserved.
+ *
+ * Licensed under the MIT License.
+ */
+package io.lettuce.core.api.consistency
+
+import io.lettuce.TestTags.UNIT_TEST
+import java.lang.reflect.Method
+import kotlin.reflect.KFunction
+import kotlin.reflect.KType
+import kotlin.reflect.full.declaredMemberFunctions
+import kotlin.reflect.jvm.javaMethod
+import kotlinx.coroutines.flow.Flow
+import org.assertj.core.api.SoftAssertions
+import org.junit.jupiter.api.Tag
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.EnumSource
+
+/**
+ * Verify that the Kotlin coroutine command interfaces mirror the sync interfaces: every sync method (except deprecated,
+ * streaming-channel and explicitly skipped methods) must exist as a `suspend fun`, or as a plain function when it is
+ * non-suspendable or returns a [Flow]. Return-type details beyond the Flow/suspend shape (element nullability) are not
+ * verified.
+ */
+@Tag(UNIT_TEST)
+class KotlinCoroutinesConsistencyUnitTests {
+
+ @ParameterizedTest
+ @EnumSource(CommandInterfaces::class)
+ fun syncMethodsExistOnCoroutinesApiWithMatchingShape(group: CommandInterfaces) {
+
+ val softly = SoftAssertions()
+ val coroutineFunctions = commandFunctions(group.coroutines())
+
+ for (syncMethod in TypeSignatures.apiMethods(group.sync())) {
+
+ if (isSkippedOnCoroutinesApi(syncMethod, group)) {
+ continue
+ }
+
+ val function = coroutineFunctions[erasedKey(syncMethod.name, erasedParameters(syncMethod))]
+ if (function == null) {
+ softly.fail(
+ "%s is missing on %s", TypeSignatures.describe(group.sync(), syncMethod),
+ group.coroutines().simpleName
+ )
+ continue
+ }
+
+ verifyShape(softly, group, syncMethod, function)
+ }
+
+ softly.assertAll()
+ }
+
+ @ParameterizedTest
+ @EnumSource(CommandInterfaces::class)
+ fun coroutinesMethodsExistOnSyncOrAsyncApi(group: CommandInterfaces) {
+
+ val softly = SoftAssertions()
+
+ val knownKeys = TypeSignatures.apiMethods(group.sync()).map { erasedKey(it.name, erasedParameters(it)) } +
+ TypeSignatures.apiMethods(group.async())
+ .filter { KnownApiDeviations.contains(KnownApiDeviations.NOT_ON_SYNC_API, it, group.sync()) }
+ .map { erasedKey(it.name, erasedParameters(it)) }
+
+ for ((key, function) in commandFunctions(group.coroutines())) {
+ if (key !in knownKeys) {
+ softly.fail(
+ "%s.%s has no sync/async counterpart", group.coroutines().simpleName, function.name
+ )
+ }
+ }
+
+ softly.assertAll()
+ }
+
+ private fun verifyShape(softly: SoftAssertions, group: CommandInterfaces, syncMethod: Method, function: KFunction<*>) {
+
+ val override = KnownApiDeviations.lookup(KnownApiDeviations.COROUTINES_RESULT_OVERRIDES, syncMethod, group.sync())
+ val expectFlow = override?.startsWith("Flow<") ?: KnownApiDeviations.contains(
+ KnownApiDeviations.COROUTINES_FLOW, syncMethod, group.sync()
+ )
+ val expectSuspend = !expectFlow &&
+ !KnownApiDeviations.contains(KnownApiDeviations.COROUTINES_NON_SUSPENDABLE, syncMethod, group.sync())
+
+ val description = "${group.coroutines().simpleName}.${KnownApiDeviations.signatureKey(syncMethod)}"
+
+ softly.assertThat(function.isSuspend).describedAs("suspend modifier of %s", description).isEqualTo(expectSuspend)
+
+ if (expectFlow) {
+ softly.assertThat(function.returnType.classifier).describedAs("return type of %s", description)
+ .isEqualTo(Flow::class)
+
+ val expectedElement = expectedFlowElement(override, syncMethod)
+ if (expectedElement != null) {
+ val actualElement = function.returnType.arguments.firstOrNull()?.type?.let { normalize(it) }
+ softly.assertThat(actualElement).describedAs("Flow element type of %s", description)
+ .isEqualTo(expectedElement)
+ }
+ }
+ }
+
+ /**
+ * The expected Flow element: from the override, or the element type of a sync `List`/`Set` result, or the bare sync
+ * return type (generic dispatch). Returns null when no expectation can be derived.
+ */
+ private fun expectedFlowElement(override: String?, syncMethod: Method): String? {
+
+ if (override != null) {
+ return override.removePrefix("Flow<").removeSuffix(">")
+ }
+
+ val syncReturn = TypeSignatures.normalizeBoxed(syncMethod.genericReturnType)
+ val element = when {
+ syncReturn.startsWith("List<") -> syncReturn.removePrefix("List<").removeSuffix(">")
+ syncReturn.startsWith("Set<") -> syncReturn.removePrefix("Set<").removeSuffix(">")
+ syncReturn.contains('<') -> return null // no mechanical mapping for other generic containers
+ else -> syncReturn
+ }
+ return kotlinize(element)
+ }
+
+ private fun isSkippedOnCoroutinesApi(syncMethod: Method, group: CommandInterfaces): Boolean {
+
+ if (KnownApiDeviations.contains(KnownApiDeviations.COROUTINES_SKIP, syncMethod, group.sync())) {
+ return true
+ }
+ if (TypeSignatures.isStreamingChannelMethod(syncMethod)) {
+ return true
+ }
+ return syncMethod.isAnnotationPresent(java.lang.Deprecated::class.java) &&
+ !KnownApiDeviations.contains(KnownApiDeviations.COROUTINES_KEEP_DEPRECATED, syncMethod, group.sync())
+ }
+
+ /**
+ * Index the command functions of a coroutine interface by name and erased parameter types, dropping the trailing
+ * `Continuation` parameter of suspend functions so keys align with the sync methods.
+ */
+ private fun commandFunctions(coroutines: Class<*>): Map> {
+
+ return coroutines.kotlin.declaredMemberFunctions.mapNotNull { function ->
+ val javaMethod = function.javaMethod ?: return@mapNotNull null
+ val parameters = erasedParameters(javaMethod).let { if (function.isSuspend) it.dropLast(1) else it }
+ erasedKey(function.name, parameters) to function
+ }.toMap()
+ }
+
+ /**
+ * Erased parameter names, folding boxed types onto their primitives — the Kotlin flavor idiomatically declares
+ * `Long`/`vararg Double` where Java uses the boxed `Long`/`Double[]`.
+ */
+ private fun erasedParameters(method: Method): List =
+ method.parameterTypes.map { PRIMITIVE_EQUIVALENTS[it.name] ?: it.name }
+
+ private fun erasedKey(name: String, parameters: List) = "$name(${parameters.joinToString(",")})"
+
+ /** Render a Kotlin type as a normalized, package-less string, ignoring nullability. */
+ private fun normalize(type: KType): String =
+ type.toString().replace('$', '.').replace(Regex("(?:[a-z][A-Za-z0-9_]*\\.)+"), "").replace("?", "").trim()
+
+ /** Translate a normalized Java type rendering to its Kotlin counterpart. */
+ private fun kotlinize(javaType: String): String = javaType
+ .replace("? extends ", "out ")
+ .replace("? super ", "in ")
+ .replace(Regex("\\bObject\\b"), "Any")
+ .replace(Regex("\\bInteger\\b"), "Int")
+ .replace("byte[]", "ByteArray")
+
+ companion object {
+
+ private val PRIMITIVE_EQUIVALENTS = mapOf(
+ "java.lang.Boolean" to "boolean", "java.lang.Byte" to "byte", "java.lang.Short" to "short",
+ "java.lang.Integer" to "int", "java.lang.Long" to "long", "java.lang.Float" to "float",
+ "java.lang.Double" to "double", "java.lang.Character" to "char",
+ "[Ljava.lang.Boolean;" to "[Z", "[Ljava.lang.Byte;" to "[B", "[Ljava.lang.Short;" to "[S",
+ "[Ljava.lang.Integer;" to "[I", "[Ljava.lang.Long;" to "[J", "[Ljava.lang.Float;" to "[F",
+ "[Ljava.lang.Double;" to "[D", "[Ljava.lang.Character;" to "[C"
+ )
+ }
+
+}
diff --git a/src/test/kotlin/io/lettuce/core/api/coroutines/CoroutinesIntegrationTests.kt b/src/test/kotlin/io/lettuce/core/api/coroutines/CoroutinesIntegrationTests.kt
index 5dee65b309..64768fbce5 100644
--- a/src/test/kotlin/io/lettuce/core/api/coroutines/CoroutinesIntegrationTests.kt
+++ b/src/test/kotlin/io/lettuce/core/api/coroutines/CoroutinesIntegrationTests.kt
@@ -1,18 +1,27 @@
package io.lettuce.core.api.coroutines
import io.lettuce.TestTags
+import io.lettuce.core.Consumer
+import io.lettuce.core.KeyValue
import io.lettuce.core.RedisClient
+import io.lettuce.core.StreamDeletionPolicy
import io.lettuce.core.TestSupport
+import io.lettuce.core.XGroupCreateArgs
+import io.lettuce.core.XReadArgs.StreamOffset
import io.lettuce.core.api.StatefulRedisConnection
import io.lettuce.core.api.coroutines
import io.lettuce.core.cluster.RedisClusterClient
import io.lettuce.core.cluster.api.coroutines
+import io.lettuce.core.models.stream.StreamEntryDeletionResult
import io.lettuce.core.sentinel.SentinelTestSettings
import io.lettuce.core.sentinel.api.coroutines
import io.lettuce.test.LettuceExtension
import io.lettuce.test.condition.EnabledOnCommand
+import io.lettuce.test.condition.RedisConditions
+import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
@@ -56,6 +65,90 @@ class CoroutinesIntegrationTests : TestSupport() {
connection.close();
}
+ @Test
+ @EnabledOnCommand("HGETEX")
+ @Inject
+ internal fun shouldInvokeHashFieldExpiryCoroutines(connection: StatefulRedisConnection) {
+
+ runBlocking {
+
+ val api = connection.coroutines()
+ api.del(key)
+
+ assertThat(api.hsetex(key, mapOf("one" to "1", "two" to "2"))).isEqualTo(1L)
+ assertThat(api.hgetex(key, "one", "two").toList()).containsExactly(
+ KeyValue.just("one", "1"),
+ KeyValue.just("two", "2")
+ )
+ assertThat(api.hgetdel(key, "one").toList()).containsExactly(KeyValue.just("one", "1"))
+ assertThat(api.hget(key, "one")).isNull()
+
+ api.del(key)
+ }
+ }
+
+ @Test
+ @Inject
+ internal fun shouldInvokeBitopCoroutines(connection: StatefulRedisConnection) {
+
+ assumeTrue(RedisConditions.of(connection).hasVersionGreaterOrEqualsTo("8.1.240"))
+
+ runBlocking {
+
+ val api = connection.coroutines()
+ api.del(key, "one", "two")
+
+ // one has bits {0, 1}, two has bit {1}
+ api.setbit("one", 0L, 1)
+ api.setbit("one", 1L, 1)
+ api.setbit("two", 1L, 1)
+
+ assertThat(api.bitopDiff(key, "one", "two")).isEqualTo(1L) // {0}
+ assertThat(api.bitcount(key)).isEqualTo(1L)
+
+ assertThat(api.bitopDiff1(key, "one", "two")).isEqualTo(1L) // {}
+ assertThat(api.bitcount(key)).isEqualTo(0L)
+
+ assertThat(api.bitopAndor(key, "one", "two")).isEqualTo(1L) // {1}
+ assertThat(api.bitcount(key)).isEqualTo(1L)
+
+ assertThat(api.bitopOne(key, "one", "two")).isEqualTo(1L) // {0}
+ assertThat(api.bitcount(key)).isEqualTo(1L)
+
+ api.del(key, "one", "two")
+ }
+ }
+
+ @Test
+ @EnabledOnCommand("XACKDEL") // Redis 8.2
+ @Inject
+ internal fun shouldInvokeStreamDeletionCoroutines(connection: StatefulRedisConnection) {
+
+ runBlocking {
+
+ val api = connection.coroutines()
+ api.del(key)
+
+ val id1 = api.xadd(key, mapOf("field1" to "value1"))!!
+ val id2 = api.xadd(key, mapOf("field2" to "value2"))!!
+ val id3 = api.xadd(key, mapOf("field3" to "value3"))!!
+
+ api.xgroupCreate(StreamOffset.from(key, "0-0"), "group", XGroupCreateArgs.Builder.mkstream())
+ assertThat(api.xreadgroup(Consumer.from("group", "consumer"), StreamOffset.lastConsumed(key)).toList())
+ .hasSize(3)
+
+ assertThat(api.xackdel(key, "group", id1).toList()).containsExactly(StreamEntryDeletionResult.DELETED)
+ assertThat(api.xackdel(key, "group", StreamDeletionPolicy.DELETE_REFERENCES, id2).toList())
+ .containsExactly(StreamEntryDeletionResult.DELETED)
+
+ assertThat(api.xdelex(key, id3).toList()).containsExactly(StreamEntryDeletionResult.DELETED)
+ assertThat(api.xdelex(key, StreamDeletionPolicy.KEEP_REFERENCES, "999999-0").toList())
+ .containsExactly(StreamEntryDeletionResult.NOT_FOUND)
+
+ api.del(key)
+ }
+ }
+
@Test
@EnabledOnCommand("EXPIRETIME") // Redis 7.0
@Inject