diff --git a/glide-core/src/protobuf/redis_request.proto b/glide-core/src/protobuf/redis_request.proto index 7a8f3a2746..b962f02217 100644 --- a/glide-core/src/protobuf/redis_request.proto +++ b/glide-core/src/protobuf/redis_request.proto @@ -193,6 +193,7 @@ enum RequestType { FunctionList = 151; FunctionDelete = 152; FunctionFlush = 153; + FCall = 154; LMPop = 155; ExpireTime = 156; PExpireTime = 157; diff --git a/glide-core/src/request_type.rs b/glide-core/src/request_type.rs index 8e20449c1d..18432b5886 100644 --- a/glide-core/src/request_type.rs +++ b/glide-core/src/request_type.rs @@ -163,6 +163,7 @@ pub enum RequestType { FunctionList = 151, FunctionDelete = 152, FunctionFlush = 153, + FCall = 154, LMPop = 155, ExpireTime = 156, PExpireTime = 157, @@ -346,6 +347,7 @@ impl From<::protobuf::EnumOrUnknown> for RequestType { ProtobufRequestType::FunctionList => RequestType::FunctionList, ProtobufRequestType::FunctionDelete => RequestType::FunctionDelete, ProtobufRequestType::FunctionFlush => RequestType::FunctionFlush, + ProtobufRequestType::FCall => RequestType::FCall, ProtobufRequestType::BitPos => RequestType::BitPos, ProtobufRequestType::BitOp => RequestType::BitOp, ProtobufRequestType::HStrlen => RequestType::HStrlen, @@ -526,6 +528,7 @@ impl RequestType { RequestType::FunctionList => Some(get_two_word_command("FUNCTION", "LIST")), RequestType::FunctionDelete => Some(get_two_word_command("FUNCTION", "DELETE")), RequestType::FunctionFlush => Some(get_two_word_command("FUNCTION", "FLUSH")), + RequestType::FCall => Some(cmd("FCALL")), RequestType::BitPos => Some(cmd("BITPOS")), RequestType::BitOp => Some(cmd("BITOP")), RequestType::HStrlen => Some(cmd("HSTRLEN")), diff --git a/java/client/src/main/java/glide/api/BaseClient.java b/java/client/src/main/java/glide/api/BaseClient.java index db038b9373..2d13cd71c4 100644 --- a/java/client/src/main/java/glide/api/BaseClient.java +++ b/java/client/src/main/java/glide/api/BaseClient.java @@ -34,6 +34,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.Expire; import static redis_request.RedisRequestOuterClass.RequestType.ExpireAt; import static redis_request.RedisRequestOuterClass.RequestType.ExpireTime; +import static redis_request.RedisRequestOuterClass.RequestType.FCall; import static redis_request.RedisRequestOuterClass.RequestType.GeoAdd; import static redis_request.RedisRequestOuterClass.RequestType.GeoDist; import static redis_request.RedisRequestOuterClass.RequestType.GeoHash; @@ -152,6 +153,7 @@ import glide.api.commands.HashBaseCommands; import glide.api.commands.HyperLogLogBaseCommands; import glide.api.commands.ListBaseCommands; +import glide.api.commands.ScriptingAndFunctionsBaseCommands; import glide.api.commands.SetBaseCommands; import glide.api.commands.SortedSetBaseCommands; import glide.api.commands.StreamBaseCommands; @@ -220,7 +222,8 @@ public abstract class BaseClient SortedSetBaseCommands, StreamBaseCommands, HyperLogLogBaseCommands, - GeospatialIndicesBaseCommands { + GeospatialIndicesBaseCommands, + ScriptingAndFunctionsBaseCommands { /** Redis simple string response with "OK" */ public static final String OK = ConstantResponse.OK.toString(); @@ -1750,6 +1753,14 @@ public CompletableFuture sintercard(@NonNull String[] keys, long limit) { return commandManager.submitNewCommand(SInterCard, arguments, this::handleLongResponse); } + @Override + public CompletableFuture fcall( + @NonNull String function, @NonNull String[] keys, @NonNull String[] arguments) { + String[] args = + concatenateArrays(new String[] {function, Long.toString(keys.length)}, keys, arguments); + return commandManager.submitNewCommand(FCall, args, this::handleObjectOrNullResponse); + } + @Override public CompletableFuture copy( @NonNull String source, @NonNull String destination, boolean replace) { diff --git a/java/client/src/main/java/glide/api/RedisClient.java b/java/client/src/main/java/glide/api/RedisClient.java index 9855e424e8..4f675f7e11 100644 --- a/java/client/src/main/java/glide/api/RedisClient.java +++ b/java/client/src/main/java/glide/api/RedisClient.java @@ -254,6 +254,11 @@ public CompletableFuture functionDelete(@NonNull String libName) { FunctionDelete, new String[] {libName}, this::handleStringResponse); } + @Override + public CompletableFuture fcall(@NonNull String function) { + return fcall(function, new String[0], new String[0]); + } + @Override public CompletableFuture copy( @NonNull String source, @NonNull String destination, long destinationDB) { diff --git a/java/client/src/main/java/glide/api/RedisClusterClient.java b/java/client/src/main/java/glide/api/RedisClusterClient.java index 0e88aea23e..400e407d85 100644 --- a/java/client/src/main/java/glide/api/RedisClusterClient.java +++ b/java/client/src/main/java/glide/api/RedisClusterClient.java @@ -18,6 +18,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.CustomCommand; import static redis_request.RedisRequestOuterClass.RequestType.DBSize; import static redis_request.RedisRequestOuterClass.RequestType.Echo; +import static redis_request.RedisRequestOuterClass.RequestType.FCall; import static redis_request.RedisRequestOuterClass.RequestType.FlushAll; import static redis_request.RedisRequestOuterClass.RequestType.FunctionDelete; import static redis_request.RedisRequestOuterClass.RequestType.FunctionFlush; @@ -543,4 +544,35 @@ public CompletableFuture functionDelete(@NonNull String libName, @NonNul return commandManager.submitNewCommand( FunctionDelete, new String[] {libName}, route, this::handleStringResponse); } + + @Override + public CompletableFuture fcall(@NonNull String function) { + return fcall(function, new String[0]); + } + + @Override + public CompletableFuture> fcall( + @NonNull String function, @NonNull Route route) { + return fcall(function, new String[0], route); + } + + @Override + public CompletableFuture fcall(@NonNull String function, @NonNull String[] arguments) { + String[] args = concatenateArrays(new String[] {function, "0"}, arguments); // 0 - key count + return commandManager.submitNewCommand(FCall, args, this::handleObjectOrNullResponse); + } + + @Override + public CompletableFuture> fcall( + @NonNull String function, @NonNull String[] arguments, @NonNull Route route) { + String[] args = concatenateArrays(new String[] {function, "0"}, arguments); // 0 - key count + return commandManager.submitNewCommand( + FCall, + args, + route, + response -> + route instanceof SingleNodeRoute + ? ClusterValue.ofSingleValue(handleObjectOrNullResponse(response)) + : ClusterValue.ofMultiValue(handleMapResponse(response))); + } } diff --git a/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsBaseCommands.java b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsBaseCommands.java new file mode 100644 index 0000000000..ac4773d74f --- /dev/null +++ b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsBaseCommands.java @@ -0,0 +1,41 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.commands; + +import java.util.concurrent.CompletableFuture; + +/** + * Supports commands and transactions for the "Scripting and Function" group for standalone and + * cluster clients. + * + * @see Scripting and Function + * Commands + */ +public interface ScriptingAndFunctionsBaseCommands { + + /** + * Invokes a previously loaded function. + * + * @apiNote When in cluster mode + *
    + *
  • all keys must map to the same hash slot. + *
  • if no keys are given, command will be routed to a random node. + *
+ * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param function The function name. + * @param keys An array of keys accessed by the function. To ensure the correct + * execution of functions, both in standalone and clustered deployments, all names of keys + * that a function accesses must be explicitly provided as keys. + * @param arguments An array of function arguments. Arguments + * should not represent names of keys. + * @return The invoked function's return value. + * @example + *
{@code
+     * String[] args = new String[] { "Answer", "to", "the", "Ultimate", "Question", "of", "Life,", "the", "Universe,", "and", "Everything"};
+     * Object response = client.fcall("Deep_Thought", new String[0], args).get();
+     * assert response == 42L;
+     * }
+ */ + CompletableFuture fcall(String function, String[] keys, String[] arguments); +} diff --git a/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsClusterCommands.java b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsClusterCommands.java index f9d6f5b754..bb9eabf156 100644 --- a/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsClusterCommands.java +++ b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsClusterCommands.java @@ -265,4 +265,78 @@ CompletableFuture[]>> functionList( * } */ CompletableFuture functionDelete(String libName, Route route); + + /** + * Invokes a previously loaded function.
+ * The command will be routed to a random node. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param function The function name. + * @return The invoked function's return value. + * @example + *
{@code
+     * Object response = client.fcall("Deep_Thought").get();
+     * assert response == 42L;
+     * }
+ */ + CompletableFuture fcall(String function); + + /** + * Invokes a previously loaded function. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param function The function name. + * @param route Specifies the routing configuration for the command. The client will route the + * command to the nodes defined by route. + * @return The invoked function's return value wrapped by a {@link ClusterValue}. + * @example + *
{@code
+     * ClusterValue response = client.fcall("Deep_Thought", ALL_NODES).get();
+     * for (Object nodeResponse : response.getMultiValue().values()) {
+     *   assert nodeResponse == 42L;
+     * }
+     * }
+     */
+    CompletableFuture> fcall(String function, Route route);
+
+    /**
+     * Invokes a previously loaded function.
+ * The command will be routed to a random node. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param function The function name. + * @param arguments An array of function arguments. Arguments + * should not represent names of keys. + * @return The invoked function's return value. + * @example + *
{@code
+     * String[] args = new String[] { "Answer", "to", "the", "Ultimate", "Question", "of", "Life,", "the", "Universe,", "and", "Everything" };
+     * Object response = client.fcall("Deep_Thought", args).get();
+     * assert response == 42L;
+     * }
+ */ + CompletableFuture fcall(String function, String[] arguments); + + /** + * Invokes a previously loaded function. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param function The function name. + * @param arguments An array of function arguments. Arguments + * should not represent names of keys. + * @param route Specifies the routing configuration for the command. The client will route the + * command to the nodes defined by route. + * @return The invoked function's return value wrapped by a {@link ClusterValue}. + * @example + *
{@code
+     * String[] args = new String[] { "Answer", "to", "the", "Ultimate", "Question", "of", "Life,", "the", "Universe,", "and", "Everything" };
+     * ClusterValue response = client.fcall("Deep_Thought", args, RANDOM).get();
+     * assert response.getSingleValue() == 42L;
+     * }
+     */
+    CompletableFuture> fcall(String function, String[] arguments, Route route);
 }
diff --git a/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsCommands.java b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsCommands.java
index 35b196c7ac..2815e4a2a3 100644
--- a/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsCommands.java
+++ b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsCommands.java
@@ -6,8 +6,8 @@
 import java.util.concurrent.CompletableFuture;
 
 /**
- * Supports commands and transactions for the "Scripting and Function" group for standalone and
- * cluster clients.
+ * Supports commands and transactions for the "Scripting and Function" group for a standalone
+ * client.
  *
  * @see Scripting and Function
  *     Commands
@@ -127,4 +127,19 @@ public interface ScriptingAndFunctionsCommands {
      * }
      */
     CompletableFuture functionDelete(String libName);
+
+    /**
+     * Invokes a previously loaded function.
+     *
+     * @since Redis 7.0 and above.
+     * @see redis.io for details.
+     * @param function The function name.
+     * @return The invoked function's return value.
+     * @example
+     *     
{@code
+     * Object response = client.fcall("Deep_Thought").get();
+     * assert response == 42L;
+     * }
+ */ + CompletableFuture fcall(String function); } diff --git a/java/client/src/main/java/glide/api/models/BaseTransaction.java b/java/client/src/main/java/glide/api/models/BaseTransaction.java index e61e5a146c..f72732d601 100644 --- a/java/client/src/main/java/glide/api/models/BaseTransaction.java +++ b/java/client/src/main/java/glide/api/models/BaseTransaction.java @@ -49,6 +49,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.Expire; import static redis_request.RedisRequestOuterClass.RequestType.ExpireAt; import static redis_request.RedisRequestOuterClass.RequestType.ExpireTime; +import static redis_request.RedisRequestOuterClass.RequestType.FCall; import static redis_request.RedisRequestOuterClass.RequestType.FlushAll; import static redis_request.RedisRequestOuterClass.RequestType.FunctionDelete; import static redis_request.RedisRequestOuterClass.RequestType.FunctionFlush; @@ -3707,6 +3708,42 @@ public T functionList(@NonNull String libNamePattern, boolean withCode) { return getThis(); } + /** + * Invokes a previously loaded function. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param function The function name. + * @param keys An array of key arguments accessed by the function. To ensure the + * correct execution of functions, both in standalone and clustered deployments, all names of + * keys that a function accesses must be explicitly provided as keys. + * @param arguments An array of function arguments. Arguments + * should not represent names of keys. + * @return Command Response - The invoked function's return value. + */ + public T fcall(@NonNull String function, @NonNull String[] keys, @NonNull String[] arguments) { + ArgsArray commandArgs = + buildArgs( + concatenateArrays( + new String[] {function, Long.toString(keys.length)}, keys, arguments)); + protobufTransaction.addCommands(buildCommand(FCall, commandArgs)); + return getThis(); + } + + /** + * Invokes a previously loaded function. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param function The function name. + * @param arguments An array of function arguments. Arguments + * should not represent names of keys. + * @return Command Response - The invoked function's return value. + */ + public T fcall(@NonNull String function, @NonNull String[] arguments) { + return fcall(function, new String[0], arguments); + } + /** * Sets or clears the bit at offset in the string value stored at key. * The offset is a zero-based index, with 0 being the first element of diff --git a/java/client/src/test/java/glide/api/RedisClientTest.java b/java/client/src/test/java/glide/api/RedisClientTest.java index 195940e30d..ca3ede173e 100644 --- a/java/client/src/test/java/glide/api/RedisClientTest.java +++ b/java/client/src/test/java/glide/api/RedisClientTest.java @@ -81,6 +81,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.Expire; import static redis_request.RedisRequestOuterClass.RequestType.ExpireAt; import static redis_request.RedisRequestOuterClass.RequestType.ExpireTime; +import static redis_request.RedisRequestOuterClass.RequestType.FCall; import static redis_request.RedisRequestOuterClass.RequestType.FlushAll; import static redis_request.RedisRequestOuterClass.RequestType.FunctionDelete; import static redis_request.RedisRequestOuterClass.RequestType.FunctionFlush; @@ -5087,6 +5088,52 @@ public void functionDelete_returns_success() { assertEquals(OK, payload); } + @SneakyThrows + @Test + public void fcall_with_keys_and_args_returns_success() { + // setup + String function = "func"; + String[] keys = new String[] {"key1", "key2"}; + String[] arguments = new String[] {"1", "2"}; + String[] args = new String[] {function, "2", "key1", "key2", "1", "2"}; + Object value = "42"; + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.submitNewCommand(eq(FCall), eq(args), any())).thenReturn(testResponse); + + // exercise + CompletableFuture response = service.fcall(function, keys, arguments); + Object payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void fcall_returns_success() { + // setup + String function = "func"; + String[] args = new String[] {function, "0"}; + Object value = "42"; + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.submitNewCommand(eq(FCall), eq(args), any())).thenReturn(testResponse); + + // exercise + CompletableFuture response = service.fcall(function); + Object payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + @SneakyThrows @Test public void bitcount_returns_success() { diff --git a/java/client/src/test/java/glide/api/RedisClusterClientTest.java b/java/client/src/test/java/glide/api/RedisClusterClientTest.java index 7bf0fbffb9..15ff0a59fd 100644 --- a/java/client/src/test/java/glide/api/RedisClusterClientTest.java +++ b/java/client/src/test/java/glide/api/RedisClusterClientTest.java @@ -26,6 +26,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.ConfigSet; import static redis_request.RedisRequestOuterClass.RequestType.DBSize; import static redis_request.RedisRequestOuterClass.RequestType.Echo; +import static redis_request.RedisRequestOuterClass.RequestType.FCall; import static redis_request.RedisRequestOuterClass.RequestType.FlushAll; import static redis_request.RedisRequestOuterClass.RequestType.FunctionDelete; import static redis_request.RedisRequestOuterClass.RequestType.FunctionFlush; @@ -1448,4 +1449,98 @@ public void functionDelete_with_route_returns_success() { assertEquals(testResponse, response); assertEquals(OK, payload); } + + @SneakyThrows + @Test + public void fcall_without_keys_and_without_args_returns_success() { + // setup + String function = "func"; + String[] args = new String[] {function, "0"}; + Object value = "42"; + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.submitNewCommand(eq(FCall), eq(args), any())).thenReturn(testResponse); + + // exercise + CompletableFuture response = service.fcall(function); + Object payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void fcall_without_keys_and_without_args_but_with_route_returns_success() { + // setup + String function = "func"; + String[] args = new String[] {function, "0"}; + ClusterValue value = ClusterValue.ofSingleValue("42"); + CompletableFuture> testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.>submitNewCommand( + eq(FCall), eq(args), eq(RANDOM), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture> response = service.fcall(function, RANDOM); + ClusterValue payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void fcall_without_keys_returns_success() { + // setup + String function = "func"; + String[] arguments = new String[] {"1", "2"}; + String[] args = new String[] {function, "0", "1", "2"}; + Object value = "42"; + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.submitNewCommand(eq(FCall), eq(args), any())).thenReturn(testResponse); + + // exercise + CompletableFuture response = service.fcall(function, arguments); + Object payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void fcall_without_keys_and_with_route_returns_success() { + // setup + String function = "func"; + String[] arguments = new String[] {"1", "2"}; + String[] args = new String[] {function, "0", "1", "2"}; + ClusterValue value = ClusterValue.ofSingleValue("42"); + CompletableFuture> testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.>submitNewCommand( + eq(FCall), eq(args), eq(RANDOM), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture> response = service.fcall(function, arguments, RANDOM); + ClusterValue payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } } diff --git a/java/client/src/test/java/glide/api/models/TransactionTests.java b/java/client/src/test/java/glide/api/models/TransactionTests.java index 2454e210d7..0dad33a1db 100644 --- a/java/client/src/test/java/glide/api/models/TransactionTests.java +++ b/java/client/src/test/java/glide/api/models/TransactionTests.java @@ -62,6 +62,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.Expire; import static redis_request.RedisRequestOuterClass.RequestType.ExpireAt; import static redis_request.RedisRequestOuterClass.RequestType.ExpireTime; +import static redis_request.RedisRequestOuterClass.RequestType.FCall; import static redis_request.RedisRequestOuterClass.RequestType.FlushAll; import static redis_request.RedisRequestOuterClass.RequestType.FunctionDelete; import static redis_request.RedisRequestOuterClass.RequestType.FunctionFlush; @@ -848,6 +849,11 @@ InfScoreBound.NEGATIVE_INFINITY, new ScoreBoundary(3, false), new Limit(1, 2)), results.add(Pair.of(FunctionList, buildArgs(WITH_CODE_REDIS_API))); results.add(Pair.of(FunctionList, buildArgs(LIBRARY_NAME_REDIS_API, "*"))); + transaction.fcall("func", new String[] {"key1", "key2"}, new String[] {"arg1", "arg2"}); + results.add(Pair.of(FCall, buildArgs("func", "2", "key1", "key2", "arg1", "arg2"))); + transaction.fcall("func", new String[] {"arg1", "arg2"}); + results.add(Pair.of(FCall, buildArgs("func", "0", "arg1", "arg2"))); + transaction.geodist("key", "Place", "Place2"); results.add(Pair.of(GeoDist, buildArgs("key", "Place", "Place2"))); transaction.geodist("key", "Place", "Place2", GeoUnit.KILOMETERS); diff --git a/java/integTest/src/test/java/glide/TestUtilities.java b/java/integTest/src/test/java/glide/TestUtilities.java index 9337595389..9032f7f048 100644 --- a/java/integTest/src/test/java/glide/TestUtilities.java +++ b/java/integTest/src/test/java/glide/TestUtilities.java @@ -164,14 +164,20 @@ public static void checkFunctionListResponse( assertTrue(hasLib); } - /** Generate a dummy LUA library code. */ - public static String generateLuaLibCode(String libName, List funcNames) { + /** Generate a LUA library code. */ + public static String generateLuaLibCode( + String libName, Map functions, boolean readonly) { StringBuilder code = new StringBuilder("#!lua name=" + libName + "\n"); - for (var funcName : funcNames) { - code.append("redis.register_function('") - .append(funcName) - // function returns first argument - .append("', function(keys, args) return args[1] end)\n"); + for (var function : functions.entrySet()) { + code.append("redis.register_function{ function_name = '") + .append(function.getKey()) + .append("', callback = function(keys, args) ") + .append(function.getValue()) + .append(" end"); + if (readonly) { + code.append(", flags = { 'no-writes' }"); + } + code.append(" }\n"); } return code.toString(); } diff --git a/java/integTest/src/test/java/glide/TransactionTestUtilities.java b/java/integTest/src/test/java/glide/TransactionTestUtilities.java index a6abc8ce87..e65f9e2fe5 100644 --- a/java/integTest/src/test/java/glide/TransactionTestUtilities.java +++ b/java/integTest/src/test/java/glide/TransactionTestUtilities.java @@ -722,8 +722,10 @@ private static Object[] scriptingAndFunctionsCommands(BaseTransaction transac } final String code = - "#!lua name=mylib1T \n" - + " redis.register_function('myfunc1T', function(keys, args) return args[1] end)"; + "#!lua name=mylib1T\n" + + "redis.register_function('myfunc1T'," + + "function(keys, args) return args[1] end)"; // function returns first argument + var expectedFuncData = new HashMap() { { @@ -751,6 +753,8 @@ private static Object[] scriptingAndFunctionsCommands(BaseTransaction transac .functionList(false) .functionLoad(code, false) .functionLoad(code, true) + .fcall("myfunc1T", new String[0], new String[] {"a", "b"}) + .fcall("myfunc1T", new String[] {"a", "b"}) .functionList("otherLib", false) .functionList("mylib1T", true) .functionDelete("mylib1T") @@ -761,6 +765,8 @@ private static Object[] scriptingAndFunctionsCommands(BaseTransaction transac new Map[0], // functionList(false) "mylib1T", // functionLoad(code, false) "mylib1T", // functionLoad(code, true) + "a", // fcall("myfunc1T", new String[0], new String[]{"a", "b"}) + "a", // fcall("myfunc1T", new String[] {"a", "b"}) new Map[0], // functionList("otherLib", false) expectedLibData, // functionList("mylib1T", true) OK, // functionDelete("mylib1T") diff --git a/java/integTest/src/test/java/glide/cluster/CommandTests.java b/java/integTest/src/test/java/glide/cluster/CommandTests.java index c19f708647..42fa5514cf 100644 --- a/java/integTest/src/test/java/glide/cluster/CommandTests.java +++ b/java/integTest/src/test/java/glide/cluster/CommandTests.java @@ -3,6 +3,7 @@ import static glide.TestConfiguration.CLUSTER_PORTS; import static glide.TestConfiguration.REDIS_VERSION; +import static glide.TestUtilities.assertDeepEquals; import static glide.TestUtilities.checkFunctionListResponse; import static glide.TestUtilities.generateLuaLibCode; import static glide.TestUtilities.getFirstEntryFromMultiValue; @@ -28,6 +29,7 @@ import static glide.api.models.configuration.RequestRoutingConfiguration.SlotType.PRIMARY; import static glide.api.models.configuration.RequestRoutingConfiguration.SlotType.REPLICA; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -37,6 +39,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import glide.api.RedisClusterClient; +import glide.api.models.ClusterTransaction; import glide.api.models.ClusterValue; import glide.api.models.commands.FlushMode; import glide.api.models.commands.InfoOptions; @@ -47,6 +50,7 @@ import glide.api.models.configuration.NodeAddress; import glide.api.models.configuration.RedisClusterClientConfiguration; import glide.api.models.configuration.RequestRoutingConfiguration.Route; +import glide.api.models.configuration.RequestRoutingConfiguration.SingleNodeRoute; import glide.api.models.configuration.RequestRoutingConfiguration.SlotKeyRoute; import glide.api.models.exceptions.RedisException; import glide.api.models.exceptions.RequestException; @@ -747,6 +751,10 @@ public static Stream callCrossSlotCommandsWhichShouldFail() { Arguments.of("sintercard", "7.0.0", clusterClient.sintercard(new String[] {"abc", "def"})), Arguments.of( "sintercard", "7.0.0", clusterClient.sintercard(new String[] {"abc", "def"}, 1)), + Arguments.of( + "fcall", + "7.0.0", + clusterClient.fcall("func", new String[] {"abc", "zxy", "lkn"}, new String[0])), Arguments.of( "xread", null, clusterClient.xread(Map.of("abc", "stream1", "zxy", "stream2"))), Arguments.of("copy", "6.2.0", clusterClient.copy("abc", "def", true))); @@ -813,18 +821,26 @@ public void flushall() { @SneakyThrows @ParameterizedTest(name = "functionLoad: singleNodeRoute = {0}") @ValueSource(booleans = {true, false}) - public void function_commands(boolean singleNodeRoute) { + public void function_commands_without_keys_with_route(boolean singleNodeRoute) { assumeTrue(REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0"), "This feature added in redis 7"); String libName = "mylib1c_" + singleNodeRoute; String funcName = "myfunc1c_" + singleNodeRoute; - - String code = generateLuaLibCode(libName, List.of(funcName)); + // function $funcName returns first argument + String code = generateLuaLibCode(libName, Map.of(funcName, "return args[1]"), false); Route route = singleNodeRoute ? new SlotKeyRoute("1", PRIMARY) : ALL_PRIMARIES; assertEquals(OK, clusterClient.functionFlush(SYNC, route).get()); assertEquals(libName, clusterClient.functionLoad(code, false, route).get()); - // TODO test function with FCALL when fixed in redis-rs and implemented + + var fcallResult = clusterClient.fcall(funcName, new String[] {"one", "two"}, route).get(); + if (route instanceof SingleNodeRoute) { + assertEquals("one", fcallResult.getSingleValue()); + } else { + for (var nodeResponse : fcallResult.getMultiValue().values()) { + assertEquals("one", nodeResponse); + } + } var expectedDescription = new HashMap() { @@ -874,7 +890,11 @@ public void function_commands(boolean singleNodeRoute) { // re-load library with overwriting assertEquals(libName, clusterClient.functionLoad(code, true, route).get()); String newFuncName = "myfunc2c_" + singleNodeRoute; - String newCode = generateLuaLibCode(libName, List.of(funcName, newFuncName)); + // function $funcName returns first argument + // function $newFuncName returns argument array len + String newCode = + generateLuaLibCode( + libName, Map.of(funcName, "return args[1]", newFuncName, "return #args"), false); assertEquals(libName, clusterClient.functionLoad(newCode, true, route).get()); @@ -894,7 +914,7 @@ public void function_commands(boolean singleNodeRoute) { } // load new lib and delete it - first lib remains loaded - String anotherLib = generateLuaLibCode("anotherLib", List.of("anotherFunc")); + String anotherLib = generateLuaLibCode("anotherLib", Map.of("anotherFunc", ""), false); assertEquals("anotherLib", clusterClient.functionLoad(anotherLib, true, route).get()); assertEquals(OK, clusterClient.functionDelete("anotherLib", route).get()); @@ -918,24 +938,35 @@ public void function_commands(boolean singleNodeRoute) { } } - // TODO test with FCALL + fcallResult = clusterClient.fcall(newFuncName, new String[] {"one", "two"}, route).get(); + if (route instanceof SingleNodeRoute) { + assertEquals(2L, fcallResult.getSingleValue()); + } else { + for (var nodeResponse : fcallResult.getMultiValue().values()) { + assertEquals(2L, nodeResponse); + } + } assertEquals(OK, clusterClient.functionFlush(route).get()); } @SneakyThrows @Test - public void function_commands() { + public void function_commands_without_keys_and_without_route() { assumeTrue(REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0"), "This feature added in redis 7"); assertEquals(OK, clusterClient.functionFlush(SYNC).get()); String libName = "mylib1c"; String funcName = "myfunc1c"; - String code = generateLuaLibCode(libName, List.of(funcName)); + // function $funcName returns first argument + // generating RO functions to execution on a replica (default routing goes to RANDOM including + // replicas) + String code = generateLuaLibCode(libName, Map.of(funcName, "return args[1]"), true); assertEquals(libName, clusterClient.functionLoad(code, false).get()); - // TODO test function with FCALL when fixed in redis-rs and implemented + + assertEquals("one", clusterClient.fcall(funcName, new String[] {"one", "two"}).get()); var flist = clusterClient.functionList(false).get(); var expectedDescription = @@ -947,7 +978,7 @@ public void function_commands() { var expectedFlags = new HashMap>() { { - put(funcName, Set.of()); + put(funcName, Set.of("no-writes")); } }; checkFunctionListResponse(flist, libName, expectedDescription, expectedFlags, Optional.empty()); @@ -966,12 +997,16 @@ public void function_commands() { // re-load library with overwriting assertEquals(libName, clusterClient.functionLoad(code, true).get()); String newFuncName = "myfunc2c"; - String newCode = generateLuaLibCode(libName, List.of(funcName, newFuncName)); + // function $funcName returns first argument + // function $newFuncName returns argument array len + String newCode = + generateLuaLibCode( + libName, Map.of(funcName, "return args[1]", newFuncName, "return #args"), true); assertEquals(libName, clusterClient.functionLoad(newCode, true).get()); // load new lib and delete it - first lib remains loaded - String anotherLib = generateLuaLibCode("anotherLib", List.of("anotherFunc")); + String anotherLib = generateLuaLibCode("anotherLib", Map.of("anotherFunc", ""), false); assertEquals("anotherLib", clusterClient.functionLoad(anotherLib, true).get()); assertEquals(OK, clusterClient.functionDelete("anotherLib").get()); @@ -984,15 +1019,81 @@ public void function_commands() { flist = clusterClient.functionList(libName, false).get(); expectedDescription.put(newFuncName, null); - expectedFlags.put(newFuncName, Set.of()); + expectedFlags.put(newFuncName, Set.of("no-writes")); checkFunctionListResponse(flist, libName, expectedDescription, expectedFlags, Optional.empty()); flist = clusterClient.functionList(libName, true).get(); checkFunctionListResponse( flist, libName, expectedDescription, expectedFlags, Optional.of(newCode)); - // TODO test with FCALL + assertEquals(2L, clusterClient.fcall(newFuncName, new String[] {"one", "two"}).get()); assertEquals(OK, clusterClient.functionFlush(ASYNC).get()); } + + @ParameterizedTest + @ValueSource(strings = {"abc", "xyz", "kln"}) + @SneakyThrows + public void fcall_with_keys(String prefix) { + assumeTrue(REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0"), "This feature added in redis 7"); + + String key = "{" + prefix + "}-fcall_with_keys-"; + SingleNodeRoute route = new SlotKeyRoute(key, PRIMARY); + String libName = "mylib_with_keys"; + String funcName = "myfunc_with_keys"; + // function $funcName returns array with first two arguments + String code = generateLuaLibCode(libName, Map.of(funcName, "return {keys[1], keys[2]}"), false); + + // loading function to the node where key is stored + assertEquals(libName, clusterClient.functionLoad(code, false, route).get()); + + // due to common prefix, all keys are mapped to the same hash slot + var functionResult = + clusterClient.fcall(funcName, new String[] {key + 1, key + 2}, new String[0]).get(); + assertArrayEquals(new Object[] {key + 1, key + 2}, (Object[]) functionResult); + + var transaction = + new ClusterTransaction().fcall(funcName, new String[] {key + 1, key + 2}, new String[0]); + + // check response from a routed transaction request + assertDeepEquals( + new Object[][] {{key + 1, key + 2}}, clusterClient.exec(transaction, route).get()); + // if no route given, GLIDE should detect it automatically + assertDeepEquals(new Object[][] {{key + 1, key + 2}}, clusterClient.exec(transaction).get()); + + assertEquals(OK, clusterClient.functionDelete(libName, route).get()); + } + + @SneakyThrows + @Test + public void fcall_readonly_function() { + assumeTrue(REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0"), "This feature added in redis 7"); + + String libName = "fcall_readonly_function"; + // intentionally using a REPLICA route + Route route = new SlotKeyRoute(libName, REPLICA); + String funcName = "fcall_readonly_function"; + + // function $funcName returns a magic number + String code = generateLuaLibCode(libName, Map.of(funcName, "return 42"), false); + + assertEquals(libName, clusterClient.functionLoad(code, false).get()); + + // fcall on a replica node should fail, because a function isn't guaranteed to be RO + var executionException = + assertThrows(ExecutionException.class, () -> clusterClient.fcall(funcName, route).get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + assertTrue( + executionException.getMessage().contains("You can't write against a read only replica.")); + + // create the same function, but with RO flag + code = generateLuaLibCode(libName, Map.of(funcName, "return 42"), true); + + assertEquals(libName, clusterClient.functionLoad(code, true).get()); + + // fcall should succeed now + assertEquals(42L, clusterClient.fcall(funcName, route).get().getSingleValue()); + + assertEquals(OK, clusterClient.functionDelete(libName).get()); + } } diff --git a/java/integTest/src/test/java/glide/standalone/CommandTests.java b/java/integTest/src/test/java/glide/standalone/CommandTests.java index ed4743bb72..1ec5835a89 100644 --- a/java/integTest/src/test/java/glide/standalone/CommandTests.java +++ b/java/integTest/src/test/java/glide/standalone/CommandTests.java @@ -35,7 +35,6 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -400,9 +399,13 @@ public void function_commands() { String libName = "mylib1c"; String funcName = "myfunc1c"; - String code = generateLuaLibCode(libName, List.of(funcName)); + // function $funcName returns first argument + String code = generateLuaLibCode(libName, Map.of(funcName, "return args[1]"), false); assertEquals(libName, regularClient.functionLoad(code, false).get()); - // TODO test function with FCALL when fixed in redis-rs and implemented + + var functionResult = + regularClient.fcall(funcName, new String[0], new String[] {"one", "two"}).get(); + assertEquals("one", functionResult); var flist = regularClient.functionList(false).get(); var expectedDescription = @@ -433,11 +436,15 @@ public void function_commands() { // re-load library with overwriting assertEquals(libName, regularClient.functionLoad(code, true).get()); String newFuncName = "myfunc2c"; - String newCode = generateLuaLibCode(libName, List.of(funcName, newFuncName)); + // function $funcName returns first argument + // function $newFuncName returns argument array len + String newCode = + generateLuaLibCode( + libName, Map.of(funcName, "return args[1]", newFuncName, "return #args"), false); assertEquals(libName, regularClient.functionLoad(newCode, true).get()); // load new lib and delete it - first lib remains loaded - String anotherLib = generateLuaLibCode("anotherLib", List.of("anotherFunc")); + String anotherLib = generateLuaLibCode("anotherLib", Map.of("anotherFunc", ""), false); assertEquals("anotherLib", regularClient.functionLoad(anotherLib, true).get()); assertEquals(OK, regularClient.functionDelete("anotherLib").get()); @@ -457,7 +464,10 @@ public void function_commands() { checkFunctionListResponse( flist, libName, expectedDescription, expectedFlags, Optional.of(newCode)); - // TODO test with FCALL + functionResult = + regularClient.fcall(newFuncName, new String[0], new String[] {"one", "two"}).get(); + assertEquals(2L, functionResult); + assertEquals(OK, regularClient.functionFlush(ASYNC).get()); }