From a7675124833e3e33ed1ae2532b3ed0100917359a Mon Sep 17 00:00:00 2001 From: Igor Malinovskiy Date: Tue, 28 Jul 2026 17:50:04 +0200 Subject: [PATCH 1/5] Add API consistency test suite for the command interface flavors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce reflection-based unit tests that verify the sync, async, reactive, Kotlin coroutine and cluster node-selection command interfaces stay in lockstep, replacing the consistency-by-regeneration guarantee of the template-based API generators: - CommandInterfaces: catalog of all command groups and their flavors - KnownApiDeviations: single registry of intentional deviations, ported from the generator rule tables - TypeSignatures: shared reflection and type-normalization helpers - SyncAsync/SyncReactive/NodeSelection/AggregateInterface/ CommandBuilderCoverage/KotlinCoroutines consistency tests The suite checks method parity in both directions, the per-flavor return-type mapping (RedisFuture, Mono/Flux, suspend/Flow, Executions), aggregate interface wiring and interface-to-command- builder coverage. Sync/async parity is load-bearing at runtime: the sync API is a dynamic proxy over async (FutureSyncInvocationHandler). Supersedes SyncAsyncApiConvergenceUnitTests. Adds kotlin-reflect as a test dependency. Note: at this commit the suite documents real drift in the committed interfaces — several tests fail intentionally; the deviations are fixed in the follow-up commit. Co-Authored-By: Claude Fable 5 --- pom.xml | 6 + .../SyncAsyncApiConvergenceUnitTests.java | 71 ---- ...ggregateInterfaceConsistencyUnitTests.java | 90 +++++ .../CommandBuilderCoverageUnitTests.java | 134 ++++++++ .../api/consistency/CommandInterfaces.java | 312 ++++++++++++++++++ .../api/consistency/KnownApiDeviations.java | 262 +++++++++++++++ .../NodeSelectionConsistencyUnitTests.java | 139 ++++++++ .../SyncAsyncConsistencyUnitTests.java | 75 +++++ .../SyncReactiveConsistencyUnitTests.java | 82 +++++ .../core/api/consistency/TypeSignatures.java | 161 +++++++++ .../KotlinCoroutinesConsistencyUnitTests.kt | 184 +++++++++++ 11 files changed, 1445 insertions(+), 71 deletions(-) delete mode 100644 src/test/java/io/lettuce/core/SyncAsyncApiConvergenceUnitTests.java create mode 100644 src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java create mode 100644 src/test/java/io/lettuce/core/api/consistency/CommandBuilderCoverageUnitTests.java create mode 100644 src/test/java/io/lettuce/core/api/consistency/CommandInterfaces.java create mode 100644 src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java create mode 100644 src/test/java/io/lettuce/core/api/consistency/NodeSelectionConsistencyUnitTests.java create mode 100644 src/test/java/io/lettuce/core/api/consistency/SyncAsyncConsistencyUnitTests.java create mode 100644 src/test/java/io/lettuce/core/api/consistency/SyncReactiveConsistencyUnitTests.java create mode 100644 src/test/java/io/lettuce/core/api/consistency/TypeSignatures.java create mode 100644 src/test/kotlin/io/lettuce/core/api/consistency/KotlinCoroutinesConsistencyUnitTests.kt diff --git a/pom.xml b/pom.xml index a3555d6968..44008c5776 100644 --- a/pom.xml +++ b/pom.xml @@ -495,6 +495,12 @@ test + + org.jetbrains.kotlin + kotlin-reflect + test + + diff --git a/src/test/java/io/lettuce/core/SyncAsyncApiConvergenceUnitTests.java b/src/test/java/io/lettuce/core/SyncAsyncApiConvergenceUnitTests.java deleted file mode 100644 index facad48792..0000000000 --- a/src/test/java/io/lettuce/core/SyncAsyncApiConvergenceUnitTests.java +++ /dev/null @@ -1,71 +0,0 @@ -package io.lettuce.core; - -import static io.lettuce.TestTags.UNIT_TEST; -import static org.assertj.core.api.Assertions.assertThat; - -import java.lang.reflect.*; -import java.util.Arrays; -import java.util.stream.Stream; - -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -import io.lettuce.core.api.async.RedisAsyncCommands; -import io.lettuce.core.api.reactive.RedisReactiveCommands; -import io.lettuce.core.api.sync.RedisCommands; - -/** - * @author Mark Paluch - * @since 3.0 - */ -@Tag(UNIT_TEST) -class SyncAsyncApiConvergenceUnitTests { - - @SuppressWarnings("rawtypes") - private Class asyncClass = RedisAsyncCommands.class; - - static Stream parameters() { - return Arrays.stream(RedisCommands.class.getMethods()); - } - - @ParameterizedTest - @MethodSource("parameters") - void testMethodPresentOnAsyncApi(Method syncMethod) throws Exception { - - Method method = RedisAsyncCommands.class.getMethod(syncMethod.getName(), syncMethod.getParameterTypes()); - assertThat(method).isNotNull(); - } - - @ParameterizedTest - @MethodSource("parameters") - void testMethodPresentOnReactiveApi(Method syncMethod) throws Exception { - - Method method = RedisReactiveCommands.class.getMethod(syncMethod.getName(), syncMethod.getParameterTypes()); - assertThat(method).isNotNull(); - } - - @ParameterizedTest - @MethodSource("parameters") - void testSameResultType(Method syncMethod) throws Exception { - - Method method = asyncClass.getMethod(syncMethod.getName(), syncMethod.getParameterTypes()); - Type returnType = method.getGenericReturnType(); - - if (method.getReturnType().equals(RedisFuture.class)) { - ParameterizedType genericReturnType = (ParameterizedType) method.getGenericReturnType(); - Type[] actualTypeArguments = genericReturnType.getActualTypeArguments(); - - if (actualTypeArguments[0] instanceof GenericArrayType) { - GenericArrayType arrayType = (GenericArrayType) actualTypeArguments[0]; - returnType = Array.newInstance((Class) arrayType.getGenericComponentType(), 0).getClass(); - } else { - returnType = actualTypeArguments[0]; - } - } - - assertThat(returnType.toString()).describedAs(syncMethod.toString()) - .isEqualTo(syncMethod.getGenericReturnType().toString()); - } - -} diff --git a/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java b/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java new file mode 100644 index 0000000000..ba5dd2fc39 --- /dev/null +++ b/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java @@ -0,0 +1,90 @@ +/* + * 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.util.EnumSet; +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.api.async.RedisAsyncCommands; +import io.lettuce.core.api.reactive.RedisReactiveCommands; +import io.lettuce.core.api.sync.RedisCommands; +import io.lettuce.core.cluster.api.async.NodeSelectionAsyncCommands; +import io.lettuce.core.cluster.api.async.RedisClusterAsyncCommands; +import io.lettuce.core.cluster.api.reactive.RedisClusterReactiveCommands; +import io.lettuce.core.cluster.api.sync.NodeSelectionCommands; +import io.lettuce.core.cluster.api.sync.RedisClusterCommands; + +/** + * Verify that the aggregate command interfaces extend the per-group interface of every command group they are supposed to + * cover, so that a newly registered command group cannot be forgotten on the umbrella interfaces. + */ +@Tag(UNIT_TEST) +class AggregateInterfaceConsistencyUnitTests { + + private static final Set STANDALONE_GROUPS = EnumSet + .complementOf(EnumSet.of(CommandInterfaces.SENTINEL)); + + private static final Set 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()); + } + + softly.assertAll(); + } + + @Test + void nodeSelectionAggregatesCoverAllNodeSelectionGroups() { + + SoftAssertions softly = new SoftAssertions(); + + for (CommandInterfaces group : CLUSTER_GROUPS) { + if (!group.hasNodeSelection() || KnownApiDeviations.NODE_SELECTION_AGGREGATE_PENDING.contains(group.name())) { + 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(); + } + +} 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..1fd0c182d3 --- /dev/null +++ b/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java @@ -0,0 +1,262 @@ +/* + * 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"); + + /** + * 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"); + + /** + * Methods that exist only on the reactive API (reactive-specific accessors used by the coroutine implementations). + */ + public static final Set REACTIVE_ONLY = setOf("getJsonParser"); + + /** + * 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"); + + /** + * Command groups whose node-selection aggregate wiring is known to be incomplete: {@code NodeSelectionCommands} and + * {@code NodeSelectionAsyncCommands} do not extend the ACL and Array groups, and {@code NodeSelectionAsyncCommands} extends + * the sync {@code NodeSelectionStreamCommands} instead of the async flavor. Correcting the Stream wiring changes + * the return types of stream commands on {@code AsyncNodeSelection} from {@code Executions} to {@code AsyncExecutions} — a + * breaking change scheduled for the 8.0 release. + */ + public static final Set NODE_SELECTION_AGGREGATE_PENDING = setOf("ACL", "ARRAY", "STREAM"); + + /** + * 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"); + 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..360d1f2bf1 --- /dev/null +++ b/src/test/java/io/lettuce/core/api/consistency/NodeSelectionConsistencyUnitTests.java @@ -0,0 +1,139 @@ +/* + * 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)); + } + + @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..cc69b9a6b1 --- /dev/null +++ b/src/test/java/io/lettuce/core/api/consistency/SyncAsyncConsistencyUnitTests.java @@ -0,0 +1,75 @@ +/* + * 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.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..a58b0af179 --- /dev/null +++ b/src/test/java/io/lettuce/core/api/consistency/SyncReactiveConsistencyUnitTests.java @@ -0,0 +1,82 @@ +/* + * 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 (TypeSignatures.isStreamingChannelMethod(syncMethod)) { + softly.assertThat(reactiveMethod.isAnnotationPresent(Deprecated.class)) + .as("%s must be @Deprecated (streaming-channel variant)", + TypeSignatures.describe(group.reactive(), reactiveMethod)) + .isTrue(); + } + } + + 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..06d77665cf --- /dev/null +++ b/src/test/java/io/lettuce/core/api/consistency/TypeSignatures.java @@ -0,0 +1,161 @@ +/* + * 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 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" + ) + } + +} From 33ac972c466eb0faad5504bd3fc096ca6358ed2d Mon Sep 17 00:00:00 2001 From: Igor Malinovskiy Date: Tue, 28 Jul 2026 17:50:16 +0200 Subject: [PATCH 2/5] Fix API deviations surfaced by the consistency test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real drift found by the new consistency tests, accumulated while the API generators were out of use: - RedisCoroutinesCommands did not extend RediSearchCoroutinesCommands (the implementation already delegated to it) - Kotlin coroutine API was missing bitopDiff/bitopDiff1/bitopAndor/ bitopOne, hgetex(key, fields), hsetex(key, map) and the xackdel/xdelex stream commands - reactive hgetdel/hgetex streaming-channel variants were missing their @Deprecated marker The node-selection aggregate wiring drift (missing ACL/Array groups, async aggregate extending the sync Stream interface) is exempted via KnownApiDeviations.NODE_SELECTION_AGGREGATE_PENDING — fixing it is a breaking change scheduled for the 8.0 release. Makes the API consistency test suite pass. Co-Authored-By: Claude Fable 5 --- .../reactive/RedisHashReactiveCommands.java | 6 +++ .../api/coroutines/RedisCoroutinesCommands.kt | 3 +- .../coroutines/RedisHashCoroutinesCommands.kt | 20 ++++++++ .../RedisHashCoroutinesCommandsImpl.kt | 4 ++ .../RedisStreamCoroutinesCommands.kt | 45 ++++++++++++++++ .../RedisStreamCoroutinesCommandsImpl.kt | 17 +++++++ .../RedisStringCoroutinesCommands.kt | 51 +++++++++++++++++++ .../RedisStringCoroutinesCommandsImpl.kt | 12 +++++ 8 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/lettuce/core/api/reactive/RedisHashReactiveCommands.java b/src/main/java/io/lettuce/core/api/reactive/RedisHashReactiveCommands.java index 2e84abaeb2..030521efd2 100644 --- a/src/main/java/io/lettuce/core/api/reactive/RedisHashReactiveCommands.java +++ b/src/main/java/io/lettuce/core/api/reactive/RedisHashReactiveCommands.java @@ -478,7 +478,10 @@ public interface RedisHashReactiveCommands { * @param hGetExArgs hgetex arguments. * @param fields fields to retrieve. * @return Long the number of fields that were removed from the hash. + * @deprecated since 7.7 in favor of consuming large results through the {@link org.reactivestreams.Publisher} returned by + * {@link #hgetex}. */ + @Deprecated Mono hgetex(KeyValueStreamingChannel channel, K key, HGetExArgs hGetExArgs, K... fields); /** @@ -497,7 +500,10 @@ public interface RedisHashReactiveCommands { * @param key the key. * @param fields fields to retrieve and delete. * @return Long the number of fields that were removed from the hash. + * @deprecated since 7.7 in favor of consuming large results through the {@link org.reactivestreams.Publisher} returned by + * {@link #hgetdel}. */ + @Deprecated Mono hgetdel(KeyValueStreamingChannel channel, K key, K... fields); /** diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommands.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommands.kt index a2530d3751..8e65983d5e 100644 --- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommands.kt +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommands.kt @@ -54,7 +54,8 @@ interface RedisCoroutinesCommands : RedisArrayCoroutinesCommands, RedisBloomFilterCoroutinesCommands, RedisCuckooFilterCoroutinesCommands, - RedisTopKCoroutinesCommands { + RedisTopKCoroutinesCommands, + RediSearchCoroutinesCommands { /** * Authenticate to the server. diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommands.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommands.kt index 5efbfbddaa..a5087eabfd 100644 --- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommands.kt +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommands.kt @@ -280,6 +280,26 @@ interface RedisHashCoroutinesCommands { */ suspend fun hsetex(key: K, hSetExArgs: HSetExArgs, map: Map): Long? + /** + * Set the value of one or more fields of a given hash key. + * + * @param key the key of the hash. + * @param map the field/value pairs to update. + * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set + * @since 7.7 + */ + suspend fun hsetex(key: K, map: Map): Long? + + /** + * Get the value of one or more fields of a given hash key. + * + * @param key the key of the hash. + * @param fields fields to retrieve. + * @return List> array-reply list of fields and their values. + * @since 7.7 + */ + fun hgetex(key: K, vararg fields: K): Flow> + /** * Get the value of one or more fields of a given hash key, and optionally set their expiration * diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommandsImpl.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommandsImpl.kt index c8625cafd2..4e44dbe48d 100644 --- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommandsImpl.kt +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommandsImpl.kt @@ -65,6 +65,10 @@ internal class RedisHashCoroutinesCommandsImpl(internal val op override suspend fun hsetex(key: K, hSetExArgs: HSetExArgs, map: Map): Long? = ops.hsetex(key, hSetExArgs, map).awaitFirstOrNull() + override suspend fun hsetex(key: K, map: Map): Long? = ops.hsetex(key, map).awaitFirstOrNull() + + override fun hgetex(key: K, vararg fields: K): Flow> = ops.hgetex(key, *fields).asFlow() + override fun hgetex(key: K, hGetExArgs: HGetExArgs, vararg fields: K): Flow> = ops.hgetex(key, hGetExArgs, *fields).asFlow() override fun hgetdel(key: K, vararg fields: K): Flow> = ops.hgetdel(key, *fields).asFlow() diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStreamCoroutinesCommands.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStreamCoroutinesCommands.kt index 39f99e385d..edda2c4558 100644 --- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStreamCoroutinesCommands.kt +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStreamCoroutinesCommands.kt @@ -25,6 +25,7 @@ import io.lettuce.core.XReadArgs.StreamOffset import io.lettuce.core.models.stream.ClaimedMessages import io.lettuce.core.models.stream.PendingMessage import io.lettuce.core.models.stream.PendingMessages +import io.lettuce.core.models.stream.StreamEntryDeletionResult import kotlinx.coroutines.flow.Flow /** @@ -173,6 +174,50 @@ interface RedisStreamCoroutinesCommands { */ suspend fun xdel(key: K, vararg messageIds: String): Long? + /** + * Acknowledge and delete one or multiple messages for a consumer group. + * + * @param key the stream key. + * @param group name of the consumer group. + * @param messageIds message Ids to acknowledge and delete. + * @return simple-reply an array of deletion results, one per message id. + * @since 7.7 + */ + fun xackdel(key: K, group: K, vararg messageIds: String): Flow + + /** + * Acknowledge and delete one or multiple messages for a consumer group applying the given deletion policy. + * + * @param key the stream key. + * @param group name of the consumer group. + * @param policy the deletion policy to apply. + * @param messageIds message Ids to acknowledge and delete. + * @return simple-reply an array of deletion results, one per message id. + * @since 7.7 + */ + fun xackdel(key: K, group: K, policy: StreamDeletionPolicy, vararg messageIds: String): Flow + + /** + * Removes the specified entries from the stream. + * + * @param key the stream key. + * @param messageIds stream entry IDs to delete. + * @return simple-reply an array of deletion results, one per message id. + * @since 7.7 + */ + fun xdelex(key: K, vararg messageIds: String): Flow + + /** + * Removes the specified entries from the stream applying the given deletion policy. + * + * @param key the stream key. + * @param policy the deletion policy to apply. + * @param messageIds stream entry IDs to delete. + * @return simple-reply an array of deletion results, one per message id. + * @since 7.7 + */ + fun xdelex(key: K, policy: StreamDeletionPolicy, vararg messageIds: String): Flow + /** * Create a consumer group. * diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStreamCoroutinesCommandsImpl.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStreamCoroutinesCommandsImpl.kt index 90a8838e71..74eefe1ea0 100644 --- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStreamCoroutinesCommandsImpl.kt +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStreamCoroutinesCommandsImpl.kt @@ -26,6 +26,7 @@ import io.lettuce.core.api.reactive.RedisStreamReactiveCommands import io.lettuce.core.models.stream.ClaimedMessages import io.lettuce.core.models.stream.PendingMessage import io.lettuce.core.models.stream.PendingMessages +import io.lettuce.core.models.stream.StreamEntryDeletionResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.toList import kotlinx.coroutines.reactive.asFlow @@ -66,6 +67,22 @@ internal class RedisStreamCoroutinesCommandsImpl(internal val override suspend fun xdel(key: K, vararg messageIds: String): Long? = ops.xdel(key, *messageIds).awaitFirstOrNull() + override fun xackdel(key: K, group: K, vararg messageIds: String): Flow = + ops.xackdel(key, group, *messageIds).asFlow() + + override fun xackdel( + key: K, + group: K, + policy: StreamDeletionPolicy, + vararg messageIds: String + ): Flow = ops.xackdel(key, group, policy, *messageIds).asFlow() + + override fun xdelex(key: K, vararg messageIds: String): Flow = + ops.xdelex(key, *messageIds).asFlow() + + override fun xdelex(key: K, policy: StreamDeletionPolicy, vararg messageIds: String): Flow = + ops.xdelex(key, policy, *messageIds).asFlow() + override suspend fun xgroupCreate(streamOffset: StreamOffset, group: K): String? = ops.xgroupCreate(streamOffset, group).awaitFirstOrNull() override suspend fun xgroupCreate(streamOffset: StreamOffset, group: K, args: XGroupCreateArgs): String? = ops.xgroupCreate(streamOffset, group, args).awaitFirstOrNull() diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStringCoroutinesCommands.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStringCoroutinesCommands.kt index 5875f69b71..c6975a3612 100644 --- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStringCoroutinesCommands.kt +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStringCoroutinesCommands.kt @@ -176,6 +176,57 @@ interface RedisStringCoroutinesCommands { */ suspend fun bitopXor(destination: K, vararg keys: K): Long? + /** + * Perform bitwise DIFF between strings. Members of the source key that are not members of any of the other keys. + * Equivalent to: X ∧ ¬(Y1 ∨ Y2 ∨ …) + * + * @param destination result key of the operation. + * @param sourceKey the source key (X) for comparison. + * @param keys one or more additional keys (Y1, Y2, ...). At least one key is required. + * @return Long integer-reply The size of the string stored in the destination key, that is equal to the size of the + * longest input string. + * @since 7.7 + */ + suspend fun bitopDiff(destination: K, sourceKey: K, vararg keys: K): Long? + + /** + * Perform bitwise DIFF1 between strings. Members of one or more of the keys that are not members of the source key. + * Equivalent to: ¬X ∧ (Y1 ∨ Y2 ∨ …) + * + * @param destination result key of the operation. + * @param sourceKey the source key (X) for comparison. + * @param keys one or more additional keys (Y1, Y2, ...). At least one key is required. + * @return Long integer-reply The size of the string stored in the destination key, that is equal to the size of the + * longest input string. + * @since 7.7 + */ + suspend fun bitopDiff1(destination: K, sourceKey: K, vararg keys: K): Long? + + /** + * Perform bitwise ANDOR between strings. Members of the source key that are also members of one or more of the other + * keys. Equivalent to: X ∧ (Y1 ∨ Y2 ∨ …) + * + * @param destination result key of the operation. + * @param sourceKey the source key (X) for comparison. + * @param keys one or more additional keys (Y1, Y2, ...). At least one key is required. + * @return Long integer-reply The size of the string stored in the destination key, that is equal to the size of the + * longest input string. + * @since 7.7 + */ + suspend fun bitopAndor(destination: K, sourceKey: K, vararg keys: K): Long? + + /** + * Perform bitwise ONE between strings. Members of exactly one of the given keys. For two keys this is equivalent to XOR. + * For more than two keys, returns members that appear in exactly one key. + * + * @param destination result key of the operation. + * @param keys operation input key names. + * @return Long integer-reply The size of the string stored in the destination key, that is equal to the size of the + * longest input string. + * @since 7.7 + */ + suspend fun bitopOne(destination: K, vararg keys: K): Long? + /** * Decrement the integer value of a key by one. * diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStringCoroutinesCommandsImpl.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStringCoroutinesCommandsImpl.kt index bc85bff3da..758f2b11f6 100644 --- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStringCoroutinesCommandsImpl.kt +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisStringCoroutinesCommandsImpl.kt @@ -63,6 +63,18 @@ internal class RedisStringCoroutinesCommandsImpl(internal val override suspend fun bitopXor(destination: K, vararg keys: K): Long? = ops.bitopXor(destination, *keys).awaitFirstOrNull() + override suspend fun bitopDiff(destination: K, sourceKey: K, vararg keys: K): Long? = + ops.bitopDiff(destination, sourceKey, *keys).awaitFirstOrNull() + + override suspend fun bitopDiff1(destination: K, sourceKey: K, vararg keys: K): Long? = + ops.bitopDiff1(destination, sourceKey, *keys).awaitFirstOrNull() + + override suspend fun bitopAndor(destination: K, sourceKey: K, vararg keys: K): Long? = + ops.bitopAndor(destination, sourceKey, *keys).awaitFirstOrNull() + + override suspend fun bitopOne(destination: K, vararg keys: K): Long? = + ops.bitopOne(destination, *keys).awaitFirstOrNull() + override suspend fun decr(key: K): Long? = ops.decr(key).awaitFirstOrNull() override suspend fun decrby(key: K, amount: Long): Long? = From 622d6750ad15a2ca20f6f8d468046dc739829fbb Mon Sep 17 00:00:00 2001 From: Igor Malinovskiy Date: Tue, 28 Jul 2026 18:54:08 +0200 Subject: [PATCH 3/5] Address review feedback for the API consistency suite - verify the methods declared directly on the aggregate interfaces (auth, select, the CLUSTER commands, ...) against their async and reactive counterparts, restoring coverage the removed SyncAsyncApiConvergenceUnitTests had via RedisCommands.getMethods() - include RedisClusterCoroutinesCommands in the aggregate wiring check and add its missing FUNCTION/JSON/VECTOR_SET/ARRAY/SEARCH groups (experimental API; the impl already delegates per group) - keep the Kotlin generator's FLOW_METHODS in sync with the hand-added Flow methods for as long as the generators still exist - add behavioral coroutine integration tests for the new hash-field-expiry, BITOP and stream-deletion commands Co-Authored-By: Claude Fable 5 --- .../RedisClusterCoroutinesCommands.kt | 5 + .../RedisClusterCoroutinesCommandsImpl.kt | 5 + .../KotlinCompilationUnitFactory.java | 12 +-- ...ggregateInterfaceConsistencyUnitTests.java | 83 ++++++++++++++++- .../api/consistency/KnownApiDeviations.java | 22 ++++- .../coroutines/CoroutinesIntegrationTests.kt | 93 +++++++++++++++++++ 6 files changed, 211 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommands.kt b/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommands.kt index c49da571b5..be92476013 100644 --- a/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommands.kt +++ b/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommands.kt @@ -45,6 +45,11 @@ interface RedisClusterCoroutinesCommands : RedisSortedSetCoroutinesCommands, RedisStreamCoroutinesCommands, RedisStringCoroutinesCommands, + RedisFunctionCoroutinesCommands, + RedisJsonCoroutinesCommands, + RedisVectorSetCoroutinesCommands, + RedisArrayCoroutinesCommands, + RediSearchCoroutinesCommands, RedisBloomFilterCoroutinesCommands, RedisCuckooFilterCoroutinesCommands, RedisTopKCoroutinesCommands { diff --git a/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommandsImpl.kt b/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommandsImpl.kt index 0eba4a424d..d67a9d8427 100644 --- a/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommandsImpl.kt +++ b/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommandsImpl.kt @@ -53,6 +53,11 @@ internal class RedisClusterCoroutinesCommandsImpl( RedisSortedSetCoroutinesCommands by RedisSortedSetCoroutinesCommandsImpl(ops), RedisStreamCoroutinesCommands by RedisStreamCoroutinesCommandsImpl(ops), RedisStringCoroutinesCommands by RedisStringCoroutinesCommandsImpl(ops), + RedisFunctionCoroutinesCommands by RedisFunctionCoroutinesCommandsImpl(ops), + RedisJsonCoroutinesCommands by RedisJsonCoroutinesCommandsImpl(ops), + RedisVectorSetCoroutinesCommands by RedisVectorSetCoroutinesCommandsImpl(ops), + RedisArrayCoroutinesCommands by RedisArrayCoroutinesCommandsImpl(ops), + RediSearchCoroutinesCommands by RediSearchCoroutinesCommandsImpl(ops), RedisBloomFilterCoroutinesCommands by RedisBloomFilterCoroutinesCommandsImpl(ops), RedisCuckooFilterCoroutinesCommands by RedisCuckooFilterCoroutinesCommandsImpl(ops), RedisTopKCoroutinesCommands by RedisTopKCoroutinesCommandsImpl(ops) { diff --git a/src/test/java/io/lettuce/apigenerator/KotlinCompilationUnitFactory.java b/src/test/java/io/lettuce/apigenerator/KotlinCompilationUnitFactory.java index 9b160643ee..ab2e93e7c3 100644 --- a/src/test/java/io/lettuce/apigenerator/KotlinCompilationUnitFactory.java +++ b/src/test/java/io/lettuce/apigenerator/KotlinCompilationUnitFactory.java @@ -70,12 +70,12 @@ class KotlinCompilationUnitFactory { "getStatefulConnection"); private static final Set FLOW_METHODS = LettuceSets.unmodifiableSet("aclList", "aclLog", "dispatch", "geohash", - "georadius", "georadiusbymember", "geosearch", "hgetall", "hkeys", "hmget", "hvals", "keys", "mget", "sdiff", - "sinter", "smembers", "smismember", "sort", "sortReadOnly", "srandmember", "sunion", "xclaim", "xpending", "xrange", - "xread", "xreadgroup", "xrevrange", "zdiff", "zdiffWithScores", "zinter", "zinterWithScores", "zpopmax", "zpopmin", - "zrange", "zrangeWithScores", "zrangebylex", "zrangebyscore", "zrangebyscoreWithScores", "zrevrange", - "zrevrangeWithScores", "zrevrangebylex", "zrevrangebyscore", "zrevrangebyscore", "zrevrangebyscoreWithScores", - "zunion", "zunionWithScores"); + "georadius", "georadiusbymember", "geosearch", "hgetall", "hgetdel", "hgetex", "hkeys", "hmget", "hvals", "keys", + "mget", "sdiff", "sinter", "smembers", "smismember", "sort", "sortReadOnly", "srandmember", "sunion", "xackdel", + "xclaim", "xdelex", "xpending", "xrange", "xread", "xreadgroup", "xrevrange", "zdiff", "zdiffWithScores", "zinter", + "zinterWithScores", "zpopmax", "zpopmin", "zrange", "zrangeWithScores", "zrangebylex", "zrangebyscore", + "zrangebyscoreWithScores", "zrevrange", "zrevrangeWithScores", "zrevrangebylex", "zrevrangebyscore", + "zrevrangebyscore", "zrevrangebyscoreWithScores", "zunion", "zunionWithScores"); private static final Set NON_NULLABLE_RESULT_METHODS = LettuceSets.unmodifiableSet("discard", "multi", "exec", "watch", "unwatch", "getMasterAddrByName", "master", "reset", "failover", "monitor", diff --git a/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java b/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java index ba5dd2fc39..81ae60a0d9 100644 --- a/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java +++ b/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java @@ -8,6 +8,7 @@ import static io.lettuce.TestTags.UNIT_TEST; +import java.lang.reflect.Method; import java.util.EnumSet; import java.util.Set; @@ -19,14 +20,20 @@ import io.lettuce.core.api.reactive.RedisReactiveCommands; import io.lettuce.core.api.sync.RedisCommands; import io.lettuce.core.cluster.api.async.NodeSelectionAsyncCommands; +import io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands; import io.lettuce.core.cluster.api.async.RedisClusterAsyncCommands; +import io.lettuce.core.cluster.api.coroutines.RedisClusterCoroutinesCommands; +import io.lettuce.core.cluster.api.reactive.RedisAdvancedClusterReactiveCommands; import io.lettuce.core.cluster.api.reactive.RedisClusterReactiveCommands; import io.lettuce.core.cluster.api.sync.NodeSelectionCommands; +import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; import io.lettuce.core.cluster.api.sync.RedisClusterCommands; /** * Verify that the aggregate command interfaces extend the per-group interface of every command group they are supposed to - * cover, so that a newly registered command group cannot be forgotten on the umbrella interfaces. + * cover, so that a newly registered command group cannot be forgotten on the umbrella interfaces — and that the methods + * declared directly on the aggregates ({@code auth}, {@code select}, the {@code CLUSTER} commands, …) stay in lockstep across + * the sync, async and reactive flavors. */ @Tag(UNIT_TEST) class AggregateInterfaceConsistencyUnitTests { @@ -61,6 +68,7 @@ void clusterAggregatesCoverAllClusterGroups() { 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(); @@ -87,4 +95,77 @@ private static void assertExtends(SoftAssertions softly, Class aggregate, Cla .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; + } + + 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/KnownApiDeviations.java b/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java index 1fd0c182d3..9195608a8b 100644 --- a/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java +++ b/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java @@ -47,14 +47,29 @@ public final class KnownApiDeviations { * {@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"); + "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"); + "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). @@ -182,6 +197,9 @@ public final class KnownApiDeviations { // 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<>(); 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 From 4edb596182579d6b946d80bc83d0fb0111b7dea2 Mon Sep 17 00:00:00 2001 From: Igor Malinovskiy Date: Tue, 28 Jul 2026 19:10:17 +0200 Subject: [PATCH 4/5] Verify parameter signatures and @Deprecated parity across API flavors Erased-parameter matching alone would accept flavors whose generic parameter signatures diverge (e.g. Map vs Map), and nothing compared @Deprecated across the Java flavors. The suite now verifies both for sync/async, sync/reactive, node-selection and the aggregate-declared methods. The reactive dispatch parameter mutation (CommandOutput) and the intentional deprecation of the CommandOutput-based node-selection dispatch overloads are recorded in KnownApiDeviations. Fixes surfaced by the new checks: RedisAdvancedClusterReactiveCommands .keysLegacy and RedisAdvancedClusterAsyncCommands.masters carried a @deprecated tag without the matching @Deprecated annotation. Co-Authored-By: Claude Fable 5 --- .../async/RedisAdvancedClusterAsyncCommands.java | 1 + .../RedisAdvancedClusterReactiveCommands.java | 1 + .../AggregateInterfaceConsistencyUnitTests.java | 8 ++++++++ .../core/api/consistency/KnownApiDeviations.java | 14 ++++++++++++++ .../NodeSelectionConsistencyUnitTests.java | 10 ++++++++++ .../SyncAsyncConsistencyUnitTests.java | 8 ++++++++ .../SyncReactiveConsistencyUnitTests.java | 16 +++++++++++----- .../core/api/consistency/TypeSignatures.java | 10 ++++++++++ 8 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/lettuce/core/cluster/api/async/RedisAdvancedClusterAsyncCommands.java b/src/main/java/io/lettuce/core/cluster/api/async/RedisAdvancedClusterAsyncCommands.java index f89ba6b706..4998acd09e 100644 --- a/src/main/java/io/lettuce/core/cluster/api/async/RedisAdvancedClusterAsyncCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/async/RedisAdvancedClusterAsyncCommands.java @@ -86,6 +86,7 @@ public interface RedisAdvancedClusterAsyncCommands extends RedisClusterAsy * @return API with asynchronous executed commands on a selection of upstream cluster nodes. * @deprecated since 6.0 in favor of {@link #upstream()}. */ + @Deprecated default AsyncNodeSelection masters() { return nodes(redisClusterNode -> redisClusterNode.is(RedisClusterNode.NodeFlag.UPSTREAM)); } diff --git a/src/main/java/io/lettuce/core/cluster/api/reactive/RedisAdvancedClusterReactiveCommands.java b/src/main/java/io/lettuce/core/cluster/api/reactive/RedisAdvancedClusterReactiveCommands.java index f8558dd80d..cdf973dbd8 100644 --- a/src/main/java/io/lettuce/core/cluster/api/reactive/RedisAdvancedClusterReactiveCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/reactive/RedisAdvancedClusterReactiveCommands.java @@ -206,6 +206,7 @@ public interface RedisAdvancedClusterReactiveCommands extends RedisCluster * @return K array-reply list of keys matching {@code pattern}. * @deprecated Use {@link #keys(String)} instead. This legacy overload will be removed in a later version. */ + @Deprecated Flux keysLegacy(K pattern); /** diff --git a/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java b/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java index 81ae60a0d9..69690035c5 100644 --- a/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java +++ b/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java @@ -160,6 +160,14 @@ private void assertCounterpart(SoftAssertions softly, Class sync, Method sync 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; } diff --git a/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java b/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java index 9195608a8b..4a86d31a87 100644 --- a/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java +++ b/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java @@ -76,6 +76,13 @@ public final class KnownApiDeviations { */ 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}. @@ -112,6 +119,13 @@ public final class KnownApiDeviations { */ 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"); + /** * Command groups whose node-selection aggregate wiring is known to be incomplete: {@code NodeSelectionCommands} and * {@code NodeSelectionAsyncCommands} do not extend the ACL and Array groups, and {@code NodeSelectionAsyncCommands} extends diff --git a/src/test/java/io/lettuce/core/api/consistency/NodeSelectionConsistencyUnitTests.java b/src/test/java/io/lettuce/core/api/consistency/NodeSelectionConsistencyUnitTests.java index 360d1f2bf1..05212bcbc2 100644 --- a/src/test/java/io/lettuce/core/api/consistency/NodeSelectionConsistencyUnitTests.java +++ b/src/test/java/io/lettuce/core/api/consistency/NodeSelectionConsistencyUnitTests.java @@ -74,6 +74,16 @@ private void assertCounterpart(SoftAssertions softly, CommandInterfaces group, C 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 diff --git a/src/test/java/io/lettuce/core/api/consistency/SyncAsyncConsistencyUnitTests.java b/src/test/java/io/lettuce/core/api/consistency/SyncAsyncConsistencyUnitTests.java index cc69b9a6b1..46ecff7322 100644 --- a/src/test/java/io/lettuce/core/api/consistency/SyncAsyncConsistencyUnitTests.java +++ b/src/test/java/io/lettuce/core/api/consistency/SyncAsyncConsistencyUnitTests.java @@ -46,6 +46,14 @@ void syncMethodsExistOnAsyncApiWithWrappedReturnType(CommandInterfaces group) { 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(); diff --git a/src/test/java/io/lettuce/core/api/consistency/SyncReactiveConsistencyUnitTests.java b/src/test/java/io/lettuce/core/api/consistency/SyncReactiveConsistencyUnitTests.java index a58b0af179..d5065242a1 100644 --- a/src/test/java/io/lettuce/core/api/consistency/SyncReactiveConsistencyUnitTests.java +++ b/src/test/java/io/lettuce/core/api/consistency/SyncReactiveConsistencyUnitTests.java @@ -46,12 +46,18 @@ void syncMethodsExistOnReactiveApiWithMappedReturnType(CommandInterfaces group) .as("return type of %s", TypeSignatures.describe(group.reactive(), reactiveMethod)) .isEqualTo(TypeSignatures.expectedReactiveReturnType(syncMethod, group.sync())); - if (TypeSignatures.isStreamingChannelMethod(syncMethod)) { - softly.assertThat(reactiveMethod.isAnnotationPresent(Deprecated.class)) - .as("%s must be @Deprecated (streaming-channel variant)", - TypeSignatures.describe(group.reactive(), reactiveMethod)) - .isTrue(); + 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(); diff --git a/src/test/java/io/lettuce/core/api/consistency/TypeSignatures.java b/src/test/java/io/lettuce/core/api/consistency/TypeSignatures.java index 06d77665cf..55aeb99c10 100644 --- a/src/test/java/io/lettuce/core/api/consistency/TypeSignatures.java +++ b/src/test/java/io/lettuce/core/api/consistency/TypeSignatures.java @@ -140,6 +140,16 @@ 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]}. */ From f5f86b36c5e7c15d2ce289c2bfb8539d342e11ff Mon Sep 17 00:00:00 2001 From: Igor Malinovskiy Date: Tue, 28 Jul 2026 18:22:48 +0200 Subject: [PATCH 5/5] Fix node-selection aggregate interface wiring NodeSelectionCommands and NodeSelectionAsyncCommands did not extend the ACL and Array node-selection groups, and NodeSelectionAsyncCommands extended the sync NodeSelectionStreamCommands instead of NodeSelectionStreamAsyncCommands. Breaking change: stream commands on AsyncNodeSelection now return AsyncExecutions instead of Executions. Removes the NODE_SELECTION_AGGREGATE_PENDING exemption so the consistency test suite enforces the aggregate wiring again. Co-Authored-By: Claude Fable 5 --- .../api/async/NodeSelectionAsyncCommands.java | 18 +++++++++--------- .../api/sync/NodeSelectionCommands.java | 15 ++++++++------- ...AggregateInterfaceConsistencyUnitTests.java | 2 +- .../api/consistency/KnownApiDeviations.java | 9 --------- 4 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionAsyncCommands.java b/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionAsyncCommands.java index 0f0f4ad0c4..6f3c0ece85 100644 --- a/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionAsyncCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionAsyncCommands.java @@ -1,7 +1,6 @@ package io.lettuce.core.cluster.api.async; import io.lettuce.core.cluster.api.NodeSelectionSupport; -import io.lettuce.core.cluster.api.sync.NodeSelectionStreamCommands; /** * Asynchronous and thread-safe Redis API to execute commands on a {@link NodeSelectionSupport}. @@ -10,12 +9,13 @@ * @author Tihomir Mateev * @author Yordan Tsintsov */ -public interface NodeSelectionAsyncCommands extends BaseNodeSelectionAsyncCommands, - NodeSelectionFunctionAsyncCommands, NodeSelectionGeoAsyncCommands, NodeSelectionHashAsyncCommands, - NodeSelectionHLLAsyncCommands, NodeSelectionKeyAsyncCommands, NodeSelectionListAsyncCommands, - NodeSelectionScriptingAsyncCommands, NodeSelectionServerAsyncCommands, NodeSelectionSetAsyncCommands, - NodeSelectionSortedSetAsyncCommands, NodeSelectionStreamCommands, NodeSelectionStringAsyncCommands, - NodeSelectionJsonAsyncCommands, NodeSelectionVectorSetAsyncCommands, NodeSelectionSearchAsyncCommands, - NodeSelectionBloomFilterAsyncCommands, NodeSelectionCuckooFilterAsyncCommands, - NodeSelectionTopKAsyncCommands { +public interface NodeSelectionAsyncCommands + extends BaseNodeSelectionAsyncCommands, NodeSelectionAclAsyncCommands, + NodeSelectionArrayAsyncCommands, NodeSelectionFunctionAsyncCommands, NodeSelectionGeoAsyncCommands, + NodeSelectionHashAsyncCommands, NodeSelectionHLLAsyncCommands, NodeSelectionKeyAsyncCommands, + NodeSelectionListAsyncCommands, NodeSelectionScriptingAsyncCommands, NodeSelectionServerAsyncCommands, + NodeSelectionSetAsyncCommands, NodeSelectionSortedSetAsyncCommands, NodeSelectionStreamAsyncCommands, + NodeSelectionStringAsyncCommands, NodeSelectionJsonAsyncCommands, NodeSelectionVectorSetAsyncCommands, + NodeSelectionSearchAsyncCommands, NodeSelectionBloomFilterAsyncCommands, + NodeSelectionCuckooFilterAsyncCommands, NodeSelectionTopKAsyncCommands { } diff --git a/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionCommands.java b/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionCommands.java index c1d331ff06..95274295d8 100644 --- a/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionCommands.java @@ -8,11 +8,12 @@ * @author Mark Paluch * @author Tihomir Mateev */ -public interface NodeSelectionCommands extends BaseNodeSelectionCommands, NodeSelectionFunctionCommands, - NodeSelectionGeoCommands, NodeSelectionHashCommands, NodeSelectionHLLCommands, - NodeSelectionKeyCommands, NodeSelectionListCommands, NodeSelectionScriptingCommands, - NodeSelectionServerCommands, NodeSelectionSetCommands, NodeSelectionSortedSetCommands, - NodeSelectionStreamCommands, NodeSelectionStringCommands, NodeSelectionJsonCommands, - NodeSelectionVectorSetCommands, NodeSelectionSearchCommands, NodeSelectionBloomFilterCommands, - NodeSelectionCuckooFilterCommands, NodeSelectionTopKCommands { +public interface NodeSelectionCommands + extends BaseNodeSelectionCommands, NodeSelectionAclCommands, NodeSelectionArrayCommands, + NodeSelectionFunctionCommands, NodeSelectionGeoCommands, NodeSelectionHashCommands, + NodeSelectionHLLCommands, NodeSelectionKeyCommands, NodeSelectionListCommands, + NodeSelectionScriptingCommands, NodeSelectionServerCommands, NodeSelectionSetCommands, + NodeSelectionSortedSetCommands, NodeSelectionStreamCommands, NodeSelectionStringCommands, + NodeSelectionJsonCommands, NodeSelectionVectorSetCommands, NodeSelectionSearchCommands, + NodeSelectionBloomFilterCommands, NodeSelectionCuckooFilterCommands, NodeSelectionTopKCommands { } diff --git a/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java b/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java index 69690035c5..c9c1af9acf 100644 --- a/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java +++ b/src/test/java/io/lettuce/core/api/consistency/AggregateInterfaceConsistencyUnitTests.java @@ -80,7 +80,7 @@ void nodeSelectionAggregatesCoverAllNodeSelectionGroups() { SoftAssertions softly = new SoftAssertions(); for (CommandInterfaces group : CLUSTER_GROUPS) { - if (!group.hasNodeSelection() || KnownApiDeviations.NODE_SELECTION_AGGREGATE_PENDING.contains(group.name())) { + if (!group.hasNodeSelection()) { continue; } assertExtends(softly, NodeSelectionCommands.class, group.nodeSelectionSync()); diff --git a/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java b/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java index 4a86d31a87..6644f25257 100644 --- a/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java +++ b/src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java @@ -126,15 +126,6 @@ public final class KnownApiDeviations { */ public static final Set NODE_SELECTION_EXTRA_DEPRECATED = setOf("dispatch"); - /** - * Command groups whose node-selection aggregate wiring is known to be incomplete: {@code NodeSelectionCommands} and - * {@code NodeSelectionAsyncCommands} do not extend the ACL and Array groups, and {@code NodeSelectionAsyncCommands} extends - * the sync {@code NodeSelectionStreamCommands} instead of the async flavor. Correcting the Stream wiring changes - * the return types of stream commands on {@code AsyncNodeSelection} from {@code Executions} to {@code AsyncExecutions} — a - * breaking change scheduled for the 8.0 release. - */ - public static final Set NODE_SELECTION_AGGREGATE_PENDING = setOf("ACL", "ARRAY", "STREAM"); - /** * Sync methods with no coroutine counterpart. From {@code KotlinCompilationUnitFactory#SKIP_METHODS}. */