From 39335cbf0be6622f72b622d574d081ae17e1d530 Mon Sep 17 00:00:00 2001 From: Yury-Fridlyand Date: Wed, 31 Jan 2024 16:49:43 -0800 Subject: [PATCH] Add java cluster client, request routes configuration and support for bulk response (#864) * Add cluster client, request routes configuration and support for bulk response (#59) Signed-off-by: Yury-Fridlyand --- .../src/main/java/glide/api/BaseClient.java | 58 +++++++- .../src/main/java/glide/api/RedisClient.java | 67 ++------- .../java/glide/api/RedisClusterClient.java | 61 ++++++++ .../api/commands/ClusterBaseCommands.java | 49 +++++++ .../java/glide/api/models/ClusterValue.java | 73 ++++++++++ .../RequestRoutingConfiguration.java | 82 +++++++++++ .../java/glide/managers/CommandManager.java | 104 ++++++++++---- .../java/glide/managers/models/Command.java | 4 + .../java/glide/api/RedisClientCreateTest.java | 12 +- .../glide/api/RedisClusterClientTest.java | 103 ++++++++++++++ .../glide/api/models/ClusterValueTests.java | 75 ++++++++++ .../glide/managers/CommandManagerTest.java | 133 +++++++++++++++++- .../src/test/java/glide/ConnectionTests.java | 13 +- .../test/java/glide/TestConfiguration.java | 1 + .../test/java/glide/cluster/CommandTests.java | 46 ++++++ .../glide/{ => standalone}/CommandTests.java | 4 +- 16 files changed, 784 insertions(+), 101 deletions(-) create mode 100644 java/client/src/main/java/glide/api/RedisClusterClient.java create mode 100644 java/client/src/main/java/glide/api/commands/ClusterBaseCommands.java create mode 100644 java/client/src/main/java/glide/api/models/ClusterValue.java create mode 100644 java/client/src/main/java/glide/api/models/configuration/RequestRoutingConfiguration.java create mode 100644 java/client/src/test/java/glide/api/RedisClusterClientTest.java create mode 100644 java/client/src/test/java/glide/api/models/ClusterValueTests.java create mode 100644 java/integTest/src/test/java/glide/cluster/CommandTests.java rename java/integTest/src/test/java/glide/{ => standalone}/CommandTests.java (90%) diff --git a/java/client/src/main/java/glide/api/BaseClient.java b/java/client/src/main/java/glide/api/BaseClient.java index c5f5f424c9..386ca2e047 100644 --- a/java/client/src/main/java/glide/api/BaseClient.java +++ b/java/client/src/main/java/glide/api/BaseClient.java @@ -1,11 +1,18 @@ /** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide.api; +import static glide.ffi.resolvers.SocketListenerResolver.getSocket; + +import glide.api.models.configuration.BaseClientConfiguration; +import glide.connectors.handlers.CallbackDispatcher; +import glide.connectors.handlers.ChannelHandler; import glide.ffi.resolvers.RedisValueResolver; import glide.managers.BaseCommandResponseResolver; import glide.managers.CommandManager; import glide.managers.ConnectionManager; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.function.BiFunction; import lombok.AllArgsConstructor; import response.ResponseOuterClass.Response; @@ -13,8 +20,8 @@ @AllArgsConstructor public abstract class BaseClient implements AutoCloseable { - protected ConnectionManager connectionManager; - protected CommandManager commandManager; + protected final ConnectionManager connectionManager; + protected final CommandManager commandManager; /** * Extracts the response from the Protobuf response and either throws an exception or returns the @@ -23,10 +30,36 @@ public abstract class BaseClient implements AutoCloseable { * @param response Redis protobuf message * @return Response Object */ - protected static Object handleObjectResponse(Response response) { - // return function to convert protobuf.Response into the response object by - // calling valueFromPointer - return (new BaseCommandResponseResolver(RedisValueResolver::valueFromPointer)).apply(response); + protected Object handleObjectResponse(Response response) { + // convert protobuf response into Object and then Object into T + return new BaseCommandResponseResolver(RedisValueResolver::valueFromPointer).apply(response); + } + + /** + * Async request for an async (non-blocking) Redis client. + * + * @param config Redis client Configuration + * @param constructor Redis client constructor reference + * @param Client type + * @return a Future to connect and return a RedisClient + */ + protected static CompletableFuture CreateClient( + BaseClientConfiguration config, + BiFunction constructor) { + try { + ChannelHandler channelHandler = buildChannelHandler(); + ConnectionManager connectionManager = buildConnectionManager(channelHandler); + CommandManager commandManager = buildCommandManager(channelHandler); + // TODO: Support exception throwing, including interrupted exceptions + return connectionManager + .connectToRedis(config) + .thenApply(ignore -> constructor.apply(connectionManager, commandManager)); + } catch (InterruptedException e) { + // Something bad happened while we were establishing netty connection to UDS + var future = new CompletableFuture(); + future.completeExceptionally(e); + return future; + } } /** @@ -45,4 +78,17 @@ public void close() throws ExecutionException { throw new RuntimeException(e); } } + + protected static ChannelHandler buildChannelHandler() throws InterruptedException { + CallbackDispatcher callbackDispatcher = new CallbackDispatcher(); + return new ChannelHandler(callbackDispatcher, getSocket()); + } + + protected static ConnectionManager buildConnectionManager(ChannelHandler channelHandler) { + return new ConnectionManager(channelHandler); + } + + protected static CommandManager buildCommandManager(ChannelHandler channelHandler) { + return new CommandManager(channelHandler); + } } diff --git a/java/client/src/main/java/glide/api/RedisClient.java b/java/client/src/main/java/glide/api/RedisClient.java index c758917b87..c39ebfb753 100644 --- a/java/client/src/main/java/glide/api/RedisClient.java +++ b/java/client/src/main/java/glide/api/RedisClient.java @@ -1,82 +1,37 @@ /** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide.api; -import static glide.ffi.resolvers.SocketListenerResolver.getSocket; - import glide.api.commands.BaseCommands; import glide.api.models.configuration.RedisClientConfiguration; -import glide.connectors.handlers.CallbackDispatcher; -import glide.connectors.handlers.ChannelHandler; import glide.managers.CommandManager; import glide.managers.ConnectionManager; import glide.managers.models.Command; import java.util.concurrent.CompletableFuture; /** - * Async (non-blocking) client for Redis in Standalone mode. Use {@link - * #CreateClient(RedisClientConfiguration)} to request a client to Redis. + * Async (non-blocking) client for Redis in Standalone mode. Use {@link #CreateClient} to request a + * client to Redis. */ public class RedisClient extends BaseClient implements BaseCommands { - /** - * Request an async (non-blocking) Redis client in Standalone mode. - * - * @param config - Redis Client Configuration - * @return a Future to connect and return a RedisClient - */ - public static CompletableFuture CreateClient(RedisClientConfiguration config) { - try { - ChannelHandler channelHandler = buildChannelHandler(); - ConnectionManager connectionManager = buildConnectionManager(channelHandler); - CommandManager commandManager = buildCommandManager(channelHandler); - // TODO: Support exception throwing, including interrupted exceptions - return connectionManager - .connectToRedis(config) - .thenApply(ignore -> new RedisClient(connectionManager, commandManager)); - } catch (InterruptedException e) { - // Something bad happened while we were establishing netty connection to UDS - var future = new CompletableFuture(); - future.completeExceptionally(e); - return future; - } - } - - protected static ChannelHandler buildChannelHandler() throws InterruptedException { - CallbackDispatcher callbackDispatcher = new CallbackDispatcher(); - return new ChannelHandler(callbackDispatcher, getSocket()); - } - - protected static ConnectionManager buildConnectionManager(ChannelHandler channelHandler) { - return new ConnectionManager(channelHandler); - } - - protected static CommandManager buildCommandManager(ChannelHandler channelHandler) { - return new CommandManager(channelHandler); - } - protected RedisClient(ConnectionManager connectionManager, CommandManager commandManager) { super(connectionManager, commandManager); } /** - * Executes a single command, without checking inputs. Every part of the command, including - * subcommands, should be added as a separate value in args. - * - * @remarks This function should only be used for single-response commands. Commands that don't - * return response (such as SUBSCRIBE), or that return potentially more than a single response - * (such as XREAD), or that change the client's behavior (such as entering pub/sub mode on - * RESP2 connections) shouldn't be called using this function. - * @example Returns a list of all pub/sub clients: - *
-     * Object result = client.customCommand(new String[]{"CLIENT","LIST","TYPE", "PUBSUB"}).get();
-     * 
+ * Async request for an async (non-blocking) Redis client in Standalone mode. * - * @param args arguments for the custom command - * @return a CompletableFuture with response result from Redis + * @param config Redis client Configuration + * @return a Future to connect and return a RedisClient */ + public static CompletableFuture CreateClient(RedisClientConfiguration config) { + return CreateClient(config, RedisClient::new); + } + + @Override public CompletableFuture customCommand(String[] args) { Command command = Command.builder().requestType(Command.RequestType.CUSTOM_COMMAND).arguments(args).build(); - return commandManager.submitNewCommand(command, BaseClient::handleObjectResponse); + return commandManager.submitNewCommand(command, this::handleObjectResponse); } } diff --git a/java/client/src/main/java/glide/api/RedisClusterClient.java b/java/client/src/main/java/glide/api/RedisClusterClient.java new file mode 100644 index 0000000000..948ee7240b --- /dev/null +++ b/java/client/src/main/java/glide/api/RedisClusterClient.java @@ -0,0 +1,61 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api; + +import glide.api.commands.ClusterBaseCommands; +import glide.api.models.ClusterValue; +import glide.api.models.configuration.RedisClusterClientConfiguration; +import glide.api.models.configuration.RequestRoutingConfiguration.Route; +import glide.managers.CommandManager; +import glide.managers.ConnectionManager; +import glide.managers.models.Command; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Async (non-blocking) client for Redis in Cluster mode. Use {@link #CreateClient} to request a + * client to Redis. + */ +public class RedisClusterClient extends BaseClient implements ClusterBaseCommands { + + protected RedisClusterClient(ConnectionManager connectionManager, CommandManager commandManager) { + super(connectionManager, commandManager); + } + + /** + * Async request for an async (non-blocking) Redis client in Cluster mode. + * + * @param config Redis cluster client Configuration + * @return a Future to connect and return a ClusterClient + */ + public static CompletableFuture CreateClient( + RedisClusterClientConfiguration config) { + return CreateClient(config, RedisClusterClient::new); + } + + @Override + public CompletableFuture> customCommand(String[] args) { + Command command = + Command.builder().requestType(Command.RequestType.CUSTOM_COMMAND).arguments(args).build(); + // TODO if a command returns a map as a single value, ClusterValue misleads user + return commandManager.submitNewCommand( + command, response -> ClusterValue.of(handleObjectResponse(response))); + } + + @Override + @SuppressWarnings("unchecked") + public CompletableFuture> customCommand(String[] args, Route route) { + Command command = + Command.builder() + .requestType(Command.RequestType.CUSTOM_COMMAND) + .arguments(args) + .route(route) + .build(); + + return commandManager.submitNewCommand( + command, + response -> + route.isSingleNodeRoute() + ? ClusterValue.ofSingleValue(handleObjectResponse(response)) + : ClusterValue.ofMultiValue((Map) handleObjectResponse(response))); + } +} diff --git a/java/client/src/main/java/glide/api/commands/ClusterBaseCommands.java b/java/client/src/main/java/glide/api/commands/ClusterBaseCommands.java new file mode 100644 index 0000000000..b58808e0fe --- /dev/null +++ b/java/client/src/main/java/glide/api/commands/ClusterBaseCommands.java @@ -0,0 +1,49 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.commands; + +import glide.api.models.ClusterValue; +import glide.api.models.configuration.RequestRoutingConfiguration.Route; +import java.util.concurrent.CompletableFuture; + +/** + * Base Commands interface to handle generic command and transaction requests with routing options. + */ +public interface ClusterBaseCommands { + + /** + * Executes a single command, without checking inputs. Every part of the command, including + * subcommands, should be added as a separate value in {@code args}. + * + * @remarks This function should only be used for single-response commands. Commands that don't + * return response (such as SUBSCRIBE), or that return potentially more than a single + * response (such as XREAD), or that change the client's behavior (such as entering + * pub/sub mode on RESP2 connections) shouldn't be called using + * this function. + * @example Returns a list of all pub/sub clients: + *

+ * Object result = client.customCommand(new String[]{ "CLIENT", "LIST", "TYPE", "PUBSUB" }).get(); + * + * @param args Arguments for the custom command including the command name + * @return A CompletableFuture with response result from Redis + */ + CompletableFuture> customCommand(String[] args); + + /** + * Executes a single command, without checking inputs. Every part of the command, including + * subcommands, should be added as a separate value in {@code args}. + * + * @remarks This function should only be used for single-response commands. Commands that don't + * return response (such as SUBSCRIBE), or that return potentially more than a single + * response (such as XREAD), or that change the client's behavior (such as entering + * pub/sub mode on RESP2 connections) shouldn't be called using + * this function. + * @example Returns a list of all pub/sub clients: + *

+ * Object result = client.customCommand(new String[]{ "CLIENT", "LIST", "TYPE", "PUBSUB" }).get(); + * + * @param args Arguments for the custom command including the command name + * @param route Routing configuration for the command + * @return A CompletableFuture with response result from Redis + */ + CompletableFuture> customCommand(String[] args, Route route); +} diff --git a/java/client/src/main/java/glide/api/models/ClusterValue.java b/java/client/src/main/java/glide/api/models/ClusterValue.java new file mode 100644 index 0000000000..a690f154c5 --- /dev/null +++ b/java/client/src/main/java/glide/api/models/ClusterValue.java @@ -0,0 +1,73 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.models; + +import java.util.Map; + +/** + * union-like type which can store single-value or multi-value retrieved from Redis. The + * multi-value, if defined, contains the routed value as a Map containing a cluster + * node address to cluster node value. + * + * @param The wrapped data type + */ +public class ClusterValue { + private Map multiValue = null; + + private T singleValue = null; + + private ClusterValue() {} + + /** + * Get per-node value.
+ * Check with {@link #hasMultiData()} prior to accessing the data. + */ + public Map getMultiValue() { + assert hasMultiData() : "No multi value stored"; + return multiValue; + } + + /** + * Get the single value.
+ * Check with {@link #hasSingleData()} ()} prior to accessing the data. + */ + public T getSingleValue() { + assert hasSingleData() : "No single value stored"; + return singleValue; + } + + /** A constructor for the value with type auto-detection. */ + @SuppressWarnings("unchecked") + public static ClusterValue of(Object data) { + var res = new ClusterValue(); + if (data instanceof Map) { + res.multiValue = (Map) data; + } else { + res.singleValue = (T) data; + } + return res; + } + + /** A constructor for the value. */ + public static ClusterValue ofSingleValue(T data) { + var res = new ClusterValue(); + res.singleValue = data; + return res; + } + + /** A constructor for the value. */ + public static ClusterValue ofMultiValue(Map data) { + var res = new ClusterValue(); + res.multiValue = data; + return res; + } + + /** Check that multi-value is stored in this object. Use it prior to accessing the data. */ + public boolean hasMultiData() { + return multiValue != null; + } + + /** Check that single-value is stored in this object. Use it prior to accessing the data. */ + public boolean hasSingleData() { + return !hasMultiData(); + } +} diff --git a/java/client/src/main/java/glide/api/models/configuration/RequestRoutingConfiguration.java b/java/client/src/main/java/glide/api/models/configuration/RequestRoutingConfiguration.java new file mode 100644 index 0000000000..acb729af72 --- /dev/null +++ b/java/client/src/main/java/glide/api/models/configuration/RequestRoutingConfiguration.java @@ -0,0 +1,82 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.models.configuration; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** Request routing configuration. */ +public class RequestRoutingConfiguration { + + /** + * Basic interface. Please use one of the following implementations: + * + *

    + *
  • {@link SimpleRoute} + *
  • {@link SlotIdRoute} + *
  • {@link SlotKeyRoute} + *
+ */ + public interface Route { + boolean isSingleNodeRoute(); + } + + public enum SimpleRoute implements Route { + /** Route request to all nodes. */ + ALL_NODES, + /** Route request to all primary nodes. */ + ALL_PRIMARIES, + /** Route request to a random node. */ + RANDOM; + + @Override + public boolean isSingleNodeRoute() { + return this == RANDOM; + } + } + + public enum SlotType { + PRIMARY, + REPLICA, + } + + /** + * Request routing configuration overrides the {@link ReadFrom} connection configuration.
+ * If {@link SlotType#REPLICA} is used, the request will be routed to a replica, even if the + * strategy is {@link ReadFrom#PRIMARY}. + */ + @RequiredArgsConstructor + @Getter + public static class SlotIdRoute implements Route { + /** + * Slot number. There are 16384 slots in a redis cluster, and each shard manages a slot range. + * Unless the slot is known, it's better to route using {@link SlotType#PRIMARY}. + */ + private final int slotId; + + private final SlotType slotType; + + @Override + public boolean isSingleNodeRoute() { + return true; + } + } + + /** + * Request routing configuration overrides the {@link ReadFrom} connection configuration.
+ * If {@link SlotType#REPLICA} is used, the request will be routed to a replica, even if the + * strategy is {@link ReadFrom#PRIMARY}. + */ + @RequiredArgsConstructor + @Getter + public static class SlotKeyRoute implements Route { + /** The request will be sent to nodes managing this key. */ + private final String slotKey; + + private final SlotType slotType; + + @Override + public boolean isSingleNodeRoute() { + return true; + } + } +} diff --git a/java/client/src/main/java/glide/managers/CommandManager.java b/java/client/src/main/java/glide/managers/CommandManager.java index ba1cc9646e..83db01a3bb 100644 --- a/java/client/src/main/java/glide/managers/CommandManager.java +++ b/java/client/src/main/java/glide/managers/CommandManager.java @@ -1,12 +1,23 @@ /** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide.managers; +import glide.api.models.configuration.RequestRoutingConfiguration.Route; +import glide.api.models.configuration.RequestRoutingConfiguration.SimpleRoute; +import glide.api.models.configuration.RequestRoutingConfiguration.SlotIdRoute; +import glide.api.models.configuration.RequestRoutingConfiguration.SlotKeyRoute; +import glide.connectors.handlers.CallbackDispatcher; import glide.connectors.handlers.ChannelHandler; import glide.managers.models.Command; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import redis_request.RedisRequestOuterClass; +import redis_request.RedisRequestOuterClass.Command.ArgsArray; +import redis_request.RedisRequestOuterClass.RedisRequest; import redis_request.RedisRequestOuterClass.RequestType; +import redis_request.RedisRequestOuterClass.Routes; +import redis_request.RedisRequestOuterClass.SimpleRoutes; +import redis_request.RedisRequestOuterClass.SlotTypes; import response.ResponseOuterClass.Response; /** @@ -22,8 +33,8 @@ public class CommandManager { /** * Build a command and send. * - * @param command - * @param responseHandler - to handle the response object + * @param command The command to execute + * @param responseHandler The handler for the response object * @return A result promise of type T */ public CompletableFuture submitNewCommand( @@ -31,45 +42,78 @@ public CompletableFuture submitNewCommand( // write command request to channel // when complete, convert the response to our expected type T using the given responseHandler return channel - .write(prepareRedisRequest(command.getRequestType(), command.getArguments()), true) - .thenApplyAsync(response -> responseHandler.apply(response)); + .write( + prepareRedisRequest( + command.getRequestType(), + command.getArguments(), + Optional.ofNullable(command.getRoute())), + true) + .thenApplyAsync(responseHandler::apply); + } + + private RequestType mapRequestTypes(Command.RequestType inType) { + switch (inType) { + case CUSTOM_COMMAND: + return RequestType.CustomCommand; + } + throw new RuntimeException("Unsupported request type"); } /** - * Build a protobuf command/transaction request object.
+ * Build a protobuf command/transaction request object with routing options.
* Used by {@link CommandManager}. * - * @param command - Redis command - * @param args - Redis command arguments as string array - * @return An uncompleted request. CallbackDispatcher is responsible to complete it by adding a - * callback id. + * @param command Redis command type + * @param args Redis command arguments + * @param route Command routing parameters + * @return An uncompleted request. {@link CallbackDispatcher} is responsible to complete it by + * adding a callback id. */ - private RedisRequestOuterClass.RedisRequest.Builder prepareRedisRequest( - Command.RequestType command, String[] args) { - RedisRequestOuterClass.Command.ArgsArray.Builder commandArgs = - RedisRequestOuterClass.Command.ArgsArray.newBuilder(); + private RedisRequest.Builder prepareRedisRequest( + Command.RequestType command, String[] args, Optional route) { + ArgsArray.Builder commandArgs = ArgsArray.newBuilder(); for (var arg : args) { commandArgs.addArgs(arg); } - // TODO: set route properly when no RouteOptions given - return RedisRequestOuterClass.RedisRequest.newBuilder() - .setSingleCommand( - RedisRequestOuterClass.Command.newBuilder() - .setRequestType(mapRequestTypes(command)) - .setArgsArray(commandArgs.build()) - .build()) - .setRoute( - RedisRequestOuterClass.Routes.newBuilder() - .setSimpleRoutes(RedisRequestOuterClass.SimpleRoutes.AllNodes) - .build()); - } + var builder = + RedisRequest.newBuilder() + .setSingleCommand( + RedisRequestOuterClass.Command.newBuilder() + .setRequestType(mapRequestTypes(command)) + .setArgsArray(commandArgs.build()) + .build()); - private RequestType mapRequestTypes(Command.RequestType inType) { - switch (inType) { - case CUSTOM_COMMAND: - return RequestType.CustomCommand; + if (route.isEmpty()) { + return builder; } - throw new RuntimeException("Unsupported request type"); + + if (route.get() instanceof SimpleRoute) { + builder.setRoute( + Routes.newBuilder() + .setSimpleRoutes(SimpleRoutes.forNumber(((SimpleRoute) route.get()).ordinal())) + .build()); + } else if (route.get() instanceof SlotIdRoute) { + builder.setRoute( + Routes.newBuilder() + .setSlotIdRoute( + RedisRequestOuterClass.SlotIdRoute.newBuilder() + .setSlotId(((SlotIdRoute) route.get()).getSlotId()) + .setSlotType( + SlotTypes.forNumber( + ((SlotIdRoute) route.get()).getSlotType().ordinal())))); + } else if (route.get() instanceof SlotKeyRoute) { + builder.setRoute( + Routes.newBuilder() + .setSlotKeyRoute( + RedisRequestOuterClass.SlotKeyRoute.newBuilder() + .setSlotKey(((SlotKeyRoute) route.get()).getSlotKey()) + .setSlotType( + SlotTypes.forNumber( + ((SlotKeyRoute) route.get()).getSlotType().ordinal())))); + } else { + throw new IllegalArgumentException("Unknown type of route"); + } + return builder; } } diff --git a/java/client/src/main/java/glide/managers/models/Command.java b/java/client/src/main/java/glide/managers/models/Command.java index 8f69d5691d..4b45f38593 100644 --- a/java/client/src/main/java/glide/managers/models/Command.java +++ b/java/client/src/main/java/glide/managers/models/Command.java @@ -1,6 +1,7 @@ /** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide.managers.models; +import glide.api.models.configuration.RequestRoutingConfiguration.Route; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -15,6 +16,9 @@ public class Command { /** Redis command request type */ @NonNull final RequestType requestType; + /** Request routing configuration */ + final Route route; + /** List of Arguments for the Redis command request */ @Builder.Default final String[] arguments = new String[] {}; diff --git a/java/client/src/test/java/glide/api/RedisClientCreateTest.java b/java/client/src/test/java/glide/api/RedisClientCreateTest.java index 1af3a00a0e..463e4db9db 100644 --- a/java/client/src/test/java/glide/api/RedisClientCreateTest.java +++ b/java/client/src/test/java/glide/api/RedisClientCreateTest.java @@ -6,8 +6,10 @@ import static glide.api.RedisClient.buildConnectionManager; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; import glide.api.models.configuration.RedisClientConfiguration; @@ -22,26 +24,26 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; -import org.mockito.Mockito; public class RedisClientCreateTest { - private MockedStatic mockedClient; + private MockedStatic mockedClient; private ChannelHandler channelHandler; private ConnectionManager connectionManager; private CommandManager commandManager; @BeforeEach public void init() { - mockedClient = Mockito.mockStatic(RedisClient.class); + mockedClient = mockStatic(BaseClient.class); channelHandler = mock(ChannelHandler.class); commandManager = mock(CommandManager.class); connectionManager = mock(ConnectionManager.class); - mockedClient.when(RedisClient::buildChannelHandler).thenReturn(channelHandler); + mockedClient.when(BaseClient::buildChannelHandler).thenReturn(channelHandler); mockedClient.when(() -> buildConnectionManager(channelHandler)).thenReturn(connectionManager); mockedClient.when(() -> buildCommandManager(channelHandler)).thenReturn(commandManager); + mockedClient.when(() -> CreateClient(any(), any())).thenCallRealMethod(); } @AfterEach @@ -59,7 +61,6 @@ public void createClient_with_config_successfully_returns_RedisClient() { RedisClientConfiguration config = RedisClientConfiguration.builder().build(); when(connectionManager.connectToRedis(eq(config))).thenReturn(connectToRedisFuture); - mockedClient.when(() -> CreateClient(config)).thenCallRealMethod(); // exercise CompletableFuture result = CreateClient(config); @@ -80,7 +81,6 @@ public void createClient_error_on_connection_throws_ExecutionException() { RedisClientConfiguration config = RedisClientConfiguration.builder().build(); when(connectionManager.connectToRedis(eq(config))).thenReturn(connectToRedisFuture); - mockedClient.when(() -> CreateClient(config)).thenCallRealMethod(); // exercise CompletableFuture result = CreateClient(config); diff --git a/java/client/src/test/java/glide/api/RedisClusterClientTest.java b/java/client/src/test/java/glide/api/RedisClusterClientTest.java new file mode 100644 index 0000000000..32c459cafa --- /dev/null +++ b/java/client/src/test/java/glide/api/RedisClusterClientTest.java @@ -0,0 +1,103 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import glide.api.models.configuration.RequestRoutingConfiguration.SimpleRoute; +import glide.managers.CommandManager; +import glide.managers.RedisExceptionCheckedFunction; +import glide.managers.models.Command; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; +import response.ResponseOuterClass.Response; + +public class RedisClusterClientTest { + + @Test + @SneakyThrows + public void custom_command_returns_single_value() { + var commandManager = new TestCommandManager(null); + + var client = new TestClient(commandManager, "TEST"); + + var value = client.customCommand(new String[0]).get(); + assertAll( + () -> assertTrue(value.hasSingleData()), + () -> assertEquals("TEST", value.getSingleValue())); + } + + @Test + @SneakyThrows + public void custom_command_returns_multi_value() { + var commandManager = new TestCommandManager(null); + + var data = Map.of("key1", "value1", "key2", "value2"); + var client = new TestClient(commandManager, data); + + var value = client.customCommand(new String[0]).get(); + assertAll( + () -> assertTrue(value.hasMultiData()), () -> assertEquals(data, value.getMultiValue())); + } + + @Test + @SneakyThrows + // test checks that even a map returned as a single value when single node route is used + public void custom_command_with_single_node_route_returns_single_value() { + var commandManager = new TestCommandManager(null); + + var data = Map.of("key1", "value1", "key2", "value2"); + var client = new TestClient(commandManager, data); + + var value = client.customCommand(new String[0], SimpleRoute.RANDOM).get(); + assertAll( + () -> assertTrue(value.hasSingleData()), () -> assertEquals(data, value.getSingleValue())); + } + + @Test + @SneakyThrows + public void custom_command_with_multi_node_route_returns_multi_value() { + var commandManager = new TestCommandManager(null); + + var data = Map.of("key1", "value1", "key2", "value2"); + var client = new TestClient(commandManager, data); + + var value = client.customCommand(new String[0], SimpleRoute.ALL_NODES).get(); + assertAll( + () -> assertTrue(value.hasMultiData()), () -> assertEquals(data, value.getMultiValue())); + } + + private static class TestClient extends RedisClusterClient { + + private final Object object; + + public TestClient(CommandManager commandManager, Object objectToReturn) { + super(null, commandManager); + object = objectToReturn; + } + + @Override + protected Object handleObjectResponse(Response response) { + return object; + } + } + + private static class TestCommandManager extends CommandManager { + + private final Response response; + + public TestCommandManager(Response responseToReturn) { + super(null); + response = responseToReturn; + } + + @Override + public CompletableFuture submitNewCommand( + Command command, RedisExceptionCheckedFunction responseHandler) { + return CompletableFuture.supplyAsync(() -> responseHandler.apply(response)); + } + } +} diff --git a/java/client/src/test/java/glide/api/models/ClusterValueTests.java b/java/client/src/test/java/glide/api/models/ClusterValueTests.java new file mode 100644 index 0000000000..f74ab21494 --- /dev/null +++ b/java/client/src/test/java/glide/api/models/ClusterValueTests.java @@ -0,0 +1,75 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.models; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class ClusterValueTests { + + @Test + public void handle_null() { + var value = ClusterValue.of(null); + assertAll( + () -> assertFalse(value.hasMultiData()), + () -> assertTrue(value.hasSingleData()), + () -> assertNull(value.getSingleValue()), + () -> + assertEquals( + "No multi value stored", + assertThrows(Throwable.class, value::getMultiValue).getMessage())); + } + + @Test + public void handle_single_data() { + var value = ClusterValue.of(42); + assertAll( + () -> assertFalse(value.hasMultiData()), + () -> assertTrue(value.hasSingleData()), + () -> assertEquals(42, value.getSingleValue()), + () -> + assertEquals( + "No multi value stored", + assertThrows(Throwable.class, value::getMultiValue).getMessage())); + } + + @Test + public void handle_multi_data() { + var data = Map.of("node1", Map.of("config1", "param1", "config2", "param2"), "node2", Map.of()); + var value = ClusterValue.of(data); + assertAll( + () -> assertTrue(value.hasMultiData()), + () -> assertFalse(value.hasSingleData()), + () -> assertNotNull(value.getMultiValue()), + () -> assertEquals(data, value.getMultiValue()), + () -> + assertEquals( + "No single value stored", + assertThrows(Throwable.class, value::getSingleValue).getMessage())); + } + + @Test + public void single_value_ctor() { + var value = ClusterValue.ofSingleValue(Map.of("config1", "param1", "config2", "param2")); + assertAll( + () -> assertFalse(value.hasMultiData()), + () -> assertTrue(value.hasSingleData()), + () -> assertNotNull(value.getSingleValue())); + } + + @Test + public void multi_value_ctor() { + var value = ClusterValue.ofMultiValue(Map.of("config1", "param1", "config2", "param2")); + assertAll( + () -> assertTrue(value.hasMultiData()), + () -> assertFalse(value.hasSingleData()), + () -> assertNotNull(value.getMultiValue())); + } +} diff --git a/java/client/src/test/java/glide/managers/CommandManagerTest.java b/java/client/src/test/java/glide/managers/CommandManagerTest.java index caeb40c1a8..8825fd115b 100644 --- a/java/client/src/test/java/glide/managers/CommandManagerTest.java +++ b/java/client/src/test/java/glide/managers/CommandManagerTest.java @@ -1,7 +1,9 @@ /** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide.managers; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -9,10 +11,14 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static response.ResponseOuterClass.RequestErrorType.UNRECOGNIZED; -import static response.ResponseOuterClass.RequestErrorType.Unspecified; +import glide.api.models.configuration.RequestRoutingConfiguration.SimpleRoute; +import glide.api.models.configuration.RequestRoutingConfiguration.SlotIdRoute; +import glide.api.models.configuration.RequestRoutingConfiguration.SlotKeyRoute; +import glide.api.models.configuration.RequestRoutingConfiguration.SlotType; import glide.api.models.exceptions.ClosingException; import glide.api.models.exceptions.ConnectionException; import glide.api.models.exceptions.ExecAbortException; @@ -20,6 +26,7 @@ import glide.api.models.exceptions.TimeoutException; import glide.connectors.handlers.ChannelHandler; import glide.managers.models.Command; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import lombok.SneakyThrows; @@ -27,6 +34,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; +import redis_request.RedisRequestOuterClass.RedisRequest; +import redis_request.RedisRequestOuterClass.SimpleRoutes; +import redis_request.RedisRequestOuterClass.SlotTypes; import response.ResponseOuterClass; import response.ResponseOuterClass.RequestError; import response.ResponseOuterClass.Response; @@ -191,4 +202,124 @@ public void BaseCommandResponseResolver_handles_all_errors( } assertEquals(requestErrorType.toString(), executionException.getCause().getMessage()); } + + @ParameterizedTest + @EnumSource(value = SimpleRoute.class) + public void prepare_request_with_simple_routes(SimpleRoute routeType) { + CompletableFuture future = new CompletableFuture<>(); + when(channelHandler.write(any(), anyBoolean())).thenReturn(future); + var command = + Command.builder().requestType(Command.RequestType.CUSTOM_COMMAND).route(routeType).build(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(RedisRequest.Builder.class); + + var protobufToClientRouteMapping = + Map.of( + SimpleRoutes.AllNodes, SimpleRoute.ALL_NODES, + SimpleRoutes.AllPrimaries, SimpleRoute.ALL_PRIMARIES, + SimpleRoutes.Random, SimpleRoute.RANDOM); + + service.submitNewCommand(command, r -> null); + verify(channelHandler).write(captor.capture(), anyBoolean()); + var requestBuilder = captor.getValue(); + + assertAll( + () -> assertTrue(requestBuilder.hasRoute()), + () -> assertTrue(requestBuilder.getRoute().hasSimpleRoutes()), + () -> + assertEquals( + routeType, + protobufToClientRouteMapping.get(requestBuilder.getRoute().getSimpleRoutes())), + () -> assertFalse(requestBuilder.getRoute().hasSlotIdRoute()), + () -> assertFalse(requestBuilder.getRoute().hasSlotKeyRoute())); + } + + @ParameterizedTest + @EnumSource(value = SlotType.class) + public void prepare_request_with_slot_id_routes(SlotType slotType) { + CompletableFuture future = new CompletableFuture<>(); + when(channelHandler.write(any(), anyBoolean())).thenReturn(future); + var command = + Command.builder() + .requestType(Command.RequestType.CUSTOM_COMMAND) + .route(new SlotIdRoute(42, slotType)) + .build(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(RedisRequest.Builder.class); + + service.submitNewCommand(command, r -> null); + verify(channelHandler).write(captor.capture(), anyBoolean()); + var requestBuilder = captor.getValue(); + + var protobufToClientRouteMapping = + Map.of( + SlotTypes.Primary, SlotType.PRIMARY, + SlotTypes.Replica, SlotType.REPLICA); + + assertAll( + () -> assertTrue(requestBuilder.hasRoute()), + () -> assertTrue(requestBuilder.getRoute().hasSlotIdRoute()), + () -> + assertEquals( + slotType, + protobufToClientRouteMapping.get( + requestBuilder.getRoute().getSlotIdRoute().getSlotType())), + () -> assertEquals(42, requestBuilder.getRoute().getSlotIdRoute().getSlotId()), + () -> assertFalse(requestBuilder.getRoute().hasSimpleRoutes()), + () -> assertFalse(requestBuilder.getRoute().hasSlotKeyRoute())); + } + + @ParameterizedTest + @EnumSource(value = SlotType.class) + public void prepare_request_with_slot_key_routes(SlotType slotType) { + CompletableFuture future = new CompletableFuture<>(); + when(channelHandler.write(any(), anyBoolean())).thenReturn(future); + var command = + Command.builder() + .requestType(Command.RequestType.CUSTOM_COMMAND) + .route(new SlotKeyRoute("TEST", slotType)) + .build(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(RedisRequest.Builder.class); + + service.submitNewCommand(command, r -> null); + verify(channelHandler).write(captor.capture(), anyBoolean()); + var requestBuilder = captor.getValue(); + + var protobufToClientRouteMapping = + Map.of( + SlotTypes.Primary, SlotType.PRIMARY, + SlotTypes.Replica, SlotType.REPLICA); + + assertAll( + () -> assertTrue(requestBuilder.hasRoute()), + () -> assertTrue(requestBuilder.getRoute().hasSlotKeyRoute()), + () -> + assertEquals( + slotType, + protobufToClientRouteMapping.get( + requestBuilder.getRoute().getSlotKeyRoute().getSlotType())), + () -> assertEquals("TEST", requestBuilder.getRoute().getSlotKeyRoute().getSlotKey()), + () -> assertFalse(requestBuilder.getRoute().hasSimpleRoutes()), + () -> assertFalse(requestBuilder.getRoute().hasSlotIdRoute())); + } + + @Test + public void prepare_request_with_unknown_route_type() { + CompletableFuture future = new CompletableFuture<>(); + when(channelHandler.write(any(), anyBoolean())).thenReturn(future); + var command = + Command.builder() + .requestType(Command.RequestType.CUSTOM_COMMAND) + .route(() -> false) + .build(); + + var exception = + assertThrows( + IllegalArgumentException.class, () -> service.submitNewCommand(command, r -> null)); + assertEquals("Unknown type of route", exception.getMessage()); + } } diff --git a/java/integTest/src/test/java/glide/ConnectionTests.java b/java/integTest/src/test/java/glide/ConnectionTests.java index c04c5e0666..96cc52008b 100644 --- a/java/integTest/src/test/java/glide/ConnectionTests.java +++ b/java/integTest/src/test/java/glide/ConnectionTests.java @@ -1,3 +1,4 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide; import glide.api.RedisClient; @@ -22,5 +23,15 @@ public void basic_client() { regularClient.close(); } - // TODO cluster client once implemented + @Test + @SneakyThrows + public void cluster_client() { + var regularClient = + RedisClient.CreateClient( + RedisClientConfiguration.builder() + .address(NodeAddress.builder().port(TestConfiguration.CLUSTER_PORTS[0]).build()) + .build()) + .get(10, TimeUnit.SECONDS); + regularClient.close(); + } } diff --git a/java/integTest/src/test/java/glide/TestConfiguration.java b/java/integTest/src/test/java/glide/TestConfiguration.java index 385458cc48..fedfb389a5 100644 --- a/java/integTest/src/test/java/glide/TestConfiguration.java +++ b/java/integTest/src/test/java/glide/TestConfiguration.java @@ -1,3 +1,4 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide; import java.util.Arrays; diff --git a/java/integTest/src/test/java/glide/cluster/CommandTests.java b/java/integTest/src/test/java/glide/cluster/CommandTests.java new file mode 100644 index 0000000000..c53f016d4a --- /dev/null +++ b/java/integTest/src/test/java/glide/cluster/CommandTests.java @@ -0,0 +1,46 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.cluster; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import glide.TestConfiguration; +import glide.api.RedisClusterClient; +import glide.api.models.configuration.NodeAddress; +import glide.api.models.configuration.RedisClusterClientConfiguration; +import java.util.concurrent.TimeUnit; +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class CommandTests { + + private static RedisClusterClient clusterClient = null; + + @BeforeAll + @SneakyThrows + public static void init() { + clusterClient = + RedisClusterClient.CreateClient( + RedisClusterClientConfiguration.builder() + .address(NodeAddress.builder().port(TestConfiguration.CLUSTER_PORTS[0]).build()) + .requestTimeout(5000) + .build()) + .get(10, TimeUnit.SECONDS); + } + + @AfterAll + @SneakyThrows + public static void deinit() { + clusterClient.close(); + } + + @Test + @SneakyThrows + public void custom_command_info() { + var data = clusterClient.customCommand(new String[] {"info"}).get(10, TimeUnit.SECONDS); + for (var info : data.getMultiValue().values()) { + assertTrue(((String) info).contains("# Stats")); + } + } +} diff --git a/java/integTest/src/test/java/glide/CommandTests.java b/java/integTest/src/test/java/glide/standalone/CommandTests.java similarity index 90% rename from java/integTest/src/test/java/glide/CommandTests.java rename to java/integTest/src/test/java/glide/standalone/CommandTests.java index 74d872cbd9..0c3f0723f5 100644 --- a/java/integTest/src/test/java/glide/CommandTests.java +++ b/java/integTest/src/test/java/glide/standalone/CommandTests.java @@ -1,7 +1,9 @@ -package glide; +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.standalone; import static org.junit.jupiter.api.Assertions.assertTrue; +import glide.TestConfiguration; import glide.api.RedisClient; import glide.api.models.configuration.NodeAddress; import glide.api.models.configuration.RedisClientConfiguration;