From 7e77c1f2558358d88803eac4e9841ee207cf07bc Mon Sep 17 00:00:00 2001 From: Yury-Fridlyand Date: Thu, 13 Jun 2024 18:42:45 -0700 Subject: [PATCH] Java: Add `FUNCTION STATS` command. (#1561) Add `FUNCTION STATS` command. (#333) Signed-off-by: Yury-Fridlyand --- glide-core/src/client/value_conversion.rs | 261 ++++++++++++++++++ glide-core/src/protobuf/redis_request.proto | 1 + glide-core/src/request_type.rs | 3 + .../src/main/java/glide/api/BaseClient.java | 11 + .../src/main/java/glide/api/RedisClient.java | 9 + .../java/glide/api/RedisClusterClient.java | 31 +++ .../ScriptingAndFunctionsClusterCommands.java | 67 +++++ .../ScriptingAndFunctionsCommands.java | 31 +++ .../glide/api/models/BaseTransaction.java | 18 ++ .../test/java/glide/api/RedisClientTest.java | 24 ++ .../glide/api/RedisClusterClientTest.java | 53 ++++ .../glide/api/models/TransactionTests.java | 4 + .../src/test/java/glide/TestUtilities.java | 35 +++ .../java/glide/TransactionTestUtilities.java | 32 ++- .../test/java/glide/cluster/CommandTests.java | 139 ++++++++-- .../java/glide/standalone/CommandTests.java | 54 +++- 16 files changed, 739 insertions(+), 34 deletions(-) diff --git a/glide-core/src/client/value_conversion.rs b/glide-core/src/client/value_conversion.rs index 7d007141cd..fe74fdb6d2 100644 --- a/glide-core/src/client/value_conversion.rs +++ b/glide-core/src/client/value_conversion.rs @@ -31,6 +31,7 @@ pub(crate) enum ExpectedReturnType<'a> { ArrayOfMemberScorePairs, ZMPopReturnType, KeyWithMemberAndScore, + FunctionStatsReturnType, } pub(crate) fn convert_to_expected_type( @@ -442,6 +443,87 @@ pub(crate) fn convert_to_expected_type( ) .into()), }, + // `FUNCTION STATS` returns nested maps with different types of data + /* RESP2 response example + 1) "running_script" + 2) 1) "name" + 2) "" + 3) "command" + 4) 1) "fcall" + 2) "" + ... rest `fcall` args ... + 5) "duration_ms" + 6) (integer) 24529 + 3) "engines" + 4) 1) "LUA" + 2) 1) "libraries_count" + 2) (integer) 3 + 3) "functions_count" + 4) (integer) 5 + + 1) "running_script" + 2) (nil) + 3) "engines" + 4) ... + + RESP3 response example + 1# "running_script" => + 1# "name" => "" + 2# "command" => + 1) "fcall" + 2) "" + ... rest `fcall` args ... + 3# "duration_ms" => (integer) 5000 + 2# "engines" => + 1# "LUA" => + 1# "libraries_count" => (integer) 3 + 2# "functions_count" => (integer) 5 + */ + // First part of the response (`running_script`) is converted as `Map[str, any]` + // Second part is converted as `Map[str, Map[str, int]]` + ExpectedReturnType::FunctionStatsReturnType => match value { + // TODO reuse https://github.com/Bit-Quill/glide-for-redis/pull/331 and https://github.com/aws/glide-for-redis/pull/1489 + Value::Map(map) => { + if map[0].0 == Value::BulkString(b"running_script".into()) { + // already a RESP3 response - do nothing + Ok(Value::Map(map)) + } else { + // cluster (multi-node) response - go recursive + convert_map_entries( + map, + Some(ExpectedReturnType::BulkString), + Some(ExpectedReturnType::FunctionStatsReturnType), + ) + } + } + Value::Array(mut array) if array.len() == 4 => { + let mut result: Vec<(Value, Value)> = Vec::with_capacity(2); + let running_script_info = array.remove(1); + let running_script_converted = match running_script_info { + Value::Nil => Ok(Value::Nil), + Value::Array(inner_map_as_array) => { + convert_array_to_map_by_type(inner_map_as_array, None, None) + } + _ => Err((ErrorKind::TypeError, "Response couldn't be converted").into()), + }; + result.push((array.remove(0), running_script_converted?)); + let Value::Array(engines_info) = array.remove(1) else { + return Err((ErrorKind::TypeError, "Incorrect value type received").into()); + }; + let engines_info_converted = convert_array_to_map_by_type( + engines_info, + Some(ExpectedReturnType::BulkString), + Some(ExpectedReturnType::Map { + key_type: &None, + value_type: &None, + }), + ); + result.push((array.remove(0), engines_info_converted?)); + + Ok(Value::Map(result)) + } + _ => Err((ErrorKind::TypeError, "Response couldn't be converted").into()), + }, } } @@ -740,6 +822,7 @@ pub(crate) fn expected_type_for_cmd(cmd: &Cmd) -> Option { b"FUNCTION LIST" => Some(ExpectedReturnType::ArrayOfMaps( &ExpectedReturnType::ArrayOfMaps(&ExpectedReturnType::StringOrSet), )), + b"FUNCTION STATS" => Some(ExpectedReturnType::FunctionStatsReturnType), _ => None, } } @@ -1297,6 +1380,184 @@ mod tests { ); } + #[test] + fn convert_function_stats() { + assert!(matches!( + expected_type_for_cmd(redis::cmd("FUNCTION").arg("STATS")), + Some(ExpectedReturnType::FunctionStatsReturnType) + )); + + let resp2_response_non_empty_first_part_data = vec![ + Value::BulkString(b"running_script".into()), + Value::Array(vec![ + Value::BulkString(b"name".into()), + Value::BulkString(b"".into()), + Value::BulkString(b"command".into()), + Value::Array(vec![ + Value::BulkString(b"fcall".into()), + Value::BulkString(b"".into()), + Value::BulkString(b"... rest `fcall` args ...".into()), + ]), + Value::BulkString(b"duration_ms".into()), + Value::Int(24529), + ]), + ]; + + let resp2_response_empty_first_part_data = + vec![Value::BulkString(b"running_script".into()), Value::Nil]; + + let resp2_response_second_part_data = vec![ + Value::BulkString(b"engines".into()), + Value::Array(vec![ + Value::BulkString(b"LUA".into()), + Value::Array(vec![ + Value::BulkString(b"libraries_count".into()), + Value::Int(3), + Value::BulkString(b"functions_count".into()), + Value::Int(5), + ]), + ]), + ]; + let resp2_response_with_non_empty_first_part = Value::Array( + [ + resp2_response_non_empty_first_part_data.clone(), + resp2_response_second_part_data.clone(), + ] + .concat(), + ); + + let resp2_response_with_empty_first_part = Value::Array( + [ + resp2_response_empty_first_part_data.clone(), + resp2_response_second_part_data.clone(), + ] + .concat(), + ); + + let resp2_cluster_response = Value::Map(vec![ + ( + Value::BulkString(b"node1".into()), + resp2_response_with_non_empty_first_part.clone(), + ), + ( + Value::BulkString(b"node2".into()), + resp2_response_with_empty_first_part.clone(), + ), + ( + Value::BulkString(b"node3".into()), + resp2_response_with_empty_first_part.clone(), + ), + ]); + + let resp3_response_non_empty_first_part_data = vec![( + Value::BulkString(b"running_script".into()), + Value::Map(vec![ + ( + Value::BulkString(b"name".into()), + Value::BulkString(b"".into()), + ), + ( + Value::BulkString(b"command".into()), + Value::Array(vec![ + Value::BulkString(b"fcall".into()), + Value::BulkString(b"".into()), + Value::BulkString(b"... rest `fcall` args ...".into()), + ]), + ), + (Value::BulkString(b"duration_ms".into()), Value::Int(24529)), + ]), + )]; + + let resp3_response_empty_first_part_data = + vec![(Value::BulkString(b"running_script".into()), Value::Nil)]; + + let resp3_response_second_part_data = vec![( + Value::BulkString(b"engines".into()), + Value::Map(vec![( + Value::BulkString(b"LUA".into()), + Value::Map(vec![ + (Value::BulkString(b"libraries_count".into()), Value::Int(3)), + (Value::BulkString(b"functions_count".into()), Value::Int(5)), + ]), + )]), + )]; + + let resp3_response_with_non_empty_first_part = Value::Map( + [ + resp3_response_non_empty_first_part_data.clone(), + resp3_response_second_part_data.clone(), + ] + .concat(), + ); + + let resp3_response_with_empty_first_part = Value::Map( + [ + resp3_response_empty_first_part_data.clone(), + resp3_response_second_part_data.clone(), + ] + .concat(), + ); + + let resp3_cluster_response = Value::Map(vec![ + ( + Value::BulkString(b"node1".into()), + resp3_response_with_non_empty_first_part.clone(), + ), + ( + Value::BulkString(b"node2".into()), + resp3_response_with_empty_first_part.clone(), + ), + ( + Value::BulkString(b"node3".into()), + resp3_response_with_empty_first_part.clone(), + ), + ]); + + let conversion_type = Some(ExpectedReturnType::FunctionStatsReturnType); + // resp2 -> resp3 conversion with non-empty `running_script` block + assert_eq!( + convert_to_expected_type( + resp2_response_with_non_empty_first_part.clone(), + conversion_type + ), + Ok(resp3_response_with_non_empty_first_part.clone()) + ); + // resp2 -> resp3 conversion with empty `running_script` block + assert_eq!( + convert_to_expected_type( + resp2_response_with_empty_first_part.clone(), + conversion_type + ), + Ok(resp3_response_with_empty_first_part.clone()) + ); + // resp2 -> resp3 cluster response + assert_eq!( + convert_to_expected_type(resp2_cluster_response.clone(), conversion_type), + Ok(resp3_cluster_response.clone()) + ); + // resp3 -> resp3 conversion with non-empty `running_script` block + assert_eq!( + convert_to_expected_type( + resp3_response_with_non_empty_first_part.clone(), + conversion_type + ), + Ok(resp3_response_with_non_empty_first_part.clone()) + ); + // resp3 -> resp3 conversion with empty `running_script` block + assert_eq!( + convert_to_expected_type( + resp3_response_with_empty_first_part.clone(), + conversion_type + ), + Ok(resp3_response_with_empty_first_part.clone()) + ); + // resp3 -> resp3 cluster response + assert_eq!( + convert_to_expected_type(resp3_cluster_response.clone(), conversion_type), + Ok(resp3_cluster_response.clone()) + ); + } + #[test] fn convert_smismember() { assert!(matches!( diff --git a/glide-core/src/protobuf/redis_request.proto b/glide-core/src/protobuf/redis_request.proto index 9882ca78ca..38de7099c7 100644 --- a/glide-core/src/protobuf/redis_request.proto +++ b/glide-core/src/protobuf/redis_request.proto @@ -201,6 +201,7 @@ enum RequestType { XLen = 159; Sort = 160; FunctionKill = 161; + FunctionStats = 162; LSet = 165; XDel = 166; XRange = 167; diff --git a/glide-core/src/request_type.rs b/glide-core/src/request_type.rs index e0230b5847..03287d5e62 100644 --- a/glide-core/src/request_type.rs +++ b/glide-core/src/request_type.rs @@ -171,6 +171,7 @@ pub enum RequestType { XLen = 159, Sort = 160, FunctionKill = 161, + FunctionStats = 162, LSet = 165, XDel = 166, XRange = 167, @@ -359,6 +360,7 @@ impl From<::protobuf::EnumOrUnknown> for RequestType { ProtobufRequestType::PExpireTime => RequestType::PExpireTime, ProtobufRequestType::XLen => RequestType::XLen, ProtobufRequestType::FunctionKill => RequestType::FunctionKill, + ProtobufRequestType::FunctionStats => RequestType::FunctionStats, ProtobufRequestType::LSet => RequestType::LSet, ProtobufRequestType::XDel => RequestType::XDel, ProtobufRequestType::XRange => RequestType::XRange, @@ -544,6 +546,7 @@ impl RequestType { RequestType::PExpireTime => Some(cmd("PEXPIRETIME")), RequestType::XLen => Some(cmd("XLEN")), RequestType::FunctionKill => Some(get_two_word_command("FUNCTION", "KILL")), + RequestType::FunctionStats => Some(get_two_word_command("FUNCTION", "STATS")), RequestType::LSet => Some(cmd("LSET")), RequestType::XDel => Some(cmd("XDEL")), RequestType::XRange => Some(cmd("XRANGE")), diff --git a/java/client/src/main/java/glide/api/BaseClient.java b/java/client/src/main/java/glide/api/BaseClient.java index 18a4b2aea7..4478b9d3cd 100644 --- a/java/client/src/main/java/glide/api/BaseClient.java +++ b/java/client/src/main/java/glide/api/BaseClient.java @@ -439,6 +439,17 @@ protected Map[] handleFunctionListResponse(Object[] response) { return data; } + /** Process a FUNCTION STATS standalone response. */ + protected Map> handleFunctionStatsResponse( + Map> response) { + Map runningScriptInfo = response.get("running_script"); + if (runningScriptInfo != null) { + Object[] command = (Object[]) runningScriptInfo.get("command"); + runningScriptInfo.put("command", castArray(command, String.class)); + } + return response; + } + @Override public CompletableFuture del(@NonNull String[] keys) { return commandManager.submitNewCommand(Del, keys, this::handleLongResponse); diff --git a/java/client/src/main/java/glide/api/RedisClient.java b/java/client/src/main/java/glide/api/RedisClient.java index 4afb9b03e8..f4a6057844 100644 --- a/java/client/src/main/java/glide/api/RedisClient.java +++ b/java/client/src/main/java/glide/api/RedisClient.java @@ -23,6 +23,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.FunctionKill; import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionStats; import static redis_request.RedisRequestOuterClass.RequestType.Info; import static redis_request.RedisRequestOuterClass.RequestType.LastSave; import static redis_request.RedisRequestOuterClass.RequestType.Lolwut; @@ -283,4 +284,12 @@ public CompletableFuture copy( public CompletableFuture functionKill() { return commandManager.submitNewCommand(FunctionKill, new String[0], this::handleStringResponse); } + + @Override + public CompletableFuture>> functionStats() { + return commandManager.submitNewCommand( + FunctionStats, + new String[0], + response -> handleFunctionStatsResponse(handleMapResponse(response))); + } } diff --git a/java/client/src/main/java/glide/api/RedisClusterClient.java b/java/client/src/main/java/glide/api/RedisClusterClient.java index 4520c15164..5b7e7f5a16 100644 --- a/java/client/src/main/java/glide/api/RedisClusterClient.java +++ b/java/client/src/main/java/glide/api/RedisClusterClient.java @@ -25,6 +25,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.FunctionKill; import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionStats; import static redis_request.RedisRequestOuterClass.RequestType.Info; import static redis_request.RedisRequestOuterClass.RequestType.LastSave; import static redis_request.RedisRequestOuterClass.RequestType.Lolwut; @@ -587,4 +588,34 @@ public CompletableFuture functionKill(@NonNull Route route) { return commandManager.submitNewCommand( FunctionKill, new String[0], route, this::handleStringResponse); } + + /** Process a FUNCTION STATS cluster response. */ + protected ClusterValue>> handleFunctionStatsResponse( + Response response, boolean isSingleValue) { + if (isSingleValue) { + return ClusterValue.ofSingleValue(handleFunctionStatsResponse(handleMapResponse(response))); + } else { + Map>> data = handleMapResponse(response); + for (var nodeInfo : data.entrySet()) { + nodeInfo.setValue(handleFunctionStatsResponse(nodeInfo.getValue())); + } + return ClusterValue.ofMultiValue(data); + } + } + + @Override + public CompletableFuture>>> functionStats() { + return commandManager.submitNewCommand( + FunctionStats, new String[0], response -> handleFunctionStatsResponse(response, false)); + } + + @Override + public CompletableFuture>>> functionStats( + @NonNull Route route) { + return commandManager.submitNewCommand( + FunctionStats, + new String[0], + route, + response -> handleFunctionStatsResponse(response, route instanceof SingleNodeRoute)); + } } 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 52d9119cbe..84432a9954 100644 --- a/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsClusterCommands.java +++ b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsClusterCommands.java @@ -372,4 +372,71 @@ CompletableFuture[]>> functionList( * } */ CompletableFuture functionKill(Route route); + + /** + * Returns information about the function that's currently running and information about the + * available execution engines.
+ * The command will be routed to all primary nodes. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @return A Map with two keys: + *
    + *
  • running_script with information about the running script. + *
  • engines with information about available engines and their stats. + *
+ * See example for more details. + * @example + *
{@code
+     * Map>> response = client.functionStats().get().getMultiValue();
+     * for (String node : response.keySet()) {
+     *   Map runningScriptInfo = response.get(node).get("running_script");
+     *   if (runningScriptInfo != null) {
+     *     String[] commandLine = (String[]) runningScriptInfo.get("command");
+     *     System.out.printf("Node '%s' is currently running function '%s' with command line '%s', which has been running for %d ms%n",
+     *         node, runningScriptInfo.get("name"), String.join(" ", commandLine), (long) runningScriptInfo.get("duration_ms"));
+     *   }
+     *   Map enginesInfo = response.get(node).get("engines");
+     *   for (String engineName : enginesInfo.keySet()) {
+     *     Map engine = (Map) enginesInfo.get(engineName);
+     *     System.out.printf("Node '%s' supports engine '%s', which has %d libraries and %d functions in total%n",
+     *         node, engineName, engine.get("libraries_count"), engine.get("functions_count"));
+     *   }
+     * }
+     * }
+ */ + CompletableFuture>>> functionStats(); + + /** + * Returns information about the function that's currently running and information about the + * available execution engines. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param route Specifies the routing configuration for the command. The client will route the + * command to the nodes defined by route. + * @return A Map with two keys: + *
    + *
  • running_script with information about the running script. + *
  • engines with information about available engines and their stats. + *
+ * See example for more details. + * @example + *
{@code
+     * Map> response = client.functionStats(RANDOM).get().getSingleValue();
+     * Map runningScriptInfo = response.get("running_script");
+     * if (runningScriptInfo != null) {
+     *   String[] commandLine = (String[]) runningScriptInfo.get("command");
+     *   System.out.printf("Node is currently running function '%s' with command line '%s', which has been running for %d ms%n",
+     *       runningScriptInfo.get("name"), String.join(" ", commandLine), (long)runningScriptInfo.get("duration_ms"));
+     * }
+     * Map enginesInfo = response.get("engines");
+     * for (String engineName : enginesInfo.keySet()) {
+     *   Map engine = (Map) enginesInfo.get(engineName);
+     *   System.out.printf("Node supports engine '%s', which has %d libraries and %d functions in total%n",
+     *       engineName, engine.get("libraries_count"), engine.get("functions_count"));
+     * }
+     * }
+ */ + CompletableFuture>>> functionStats(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 77102e10e4..daa69819dd 100644 --- a/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsCommands.java +++ b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsCommands.java @@ -157,4 +157,35 @@ public interface ScriptingAndFunctionsCommands { * } */ CompletableFuture functionKill(); + + /** + * Returns information about the function that's currently running and information about the + * available execution engines. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @return A Map with two keys: + *
    + *
  • running_script with information about the running script. + *
  • engines with information about available engines and their stats. + *
+ * See example for more details. + * @example + *
{@code
+     * Map> response = client.functionStats().get();
+     * Map runningScriptInfo = response.get("running_script");
+     * if (runningScriptInfo != null) {
+     *   String[] commandLine = (String[]) runningScriptInfo.get("command");
+     *   System.out.printf("Server is currently running function '%s' with command line '%s', which has been running for %d ms%n",
+     *       runningScriptInfo.get("name"), String.join(" ", commandLine), (long)runningScriptInfo.get("duration_ms"));
+     * }
+     * Map enginesInfo = response.get("engines");
+     * for (String engineName : enginesInfo.keySet()) {
+     *   Map engine = (Map) enginesInfo.get(engineName);
+     *   System.out.printf("Server supports engine '%s', which has %d libraries and %d functions in total%n",
+     *       engineName, engine.get("libraries_count"), engine.get("functions_count"));
+     * }
+     * }
+ */ + CompletableFuture>> functionStats(); } 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 62a3128c47..c0ad83c79c 100644 --- a/java/client/src/main/java/glide/api/models/BaseTransaction.java +++ b/java/client/src/main/java/glide/api/models/BaseTransaction.java @@ -56,6 +56,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.FunctionFlush; import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionStats; import static redis_request.RedisRequestOuterClass.RequestType.GeoAdd; import static redis_request.RedisRequestOuterClass.RequestType.GeoDist; import static redis_request.RedisRequestOuterClass.RequestType.GeoHash; @@ -3826,6 +3827,23 @@ public T fcall(@NonNull String function, @NonNull String[] arguments) { return fcall(function, new String[0], arguments); } + /** + * Returns information about the function that's currently running and information about the + * available execution engines. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @return Command Response - A Map with two keys: + *
    + *
  • running_script with information about the running script. + *
  • engines with information about available engines and their stats. + *
+ */ + public T functionStats() { + protobufTransaction.addCommands(buildCommand(FunctionStats)); + return getThis(); + } + /** * 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 9337a906f5..b5184a76cb 100644 --- a/java/client/src/test/java/glide/api/RedisClientTest.java +++ b/java/client/src/test/java/glide/api/RedisClientTest.java @@ -89,6 +89,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.FunctionKill; import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionStats; import static redis_request.RedisRequestOuterClass.RequestType.GeoAdd; import static redis_request.RedisRequestOuterClass.RequestType.GeoDist; import static redis_request.RedisRequestOuterClass.RequestType.GeoHash; @@ -5250,6 +5251,29 @@ public void functionKill_returns_success() { assertEquals(OK, payload); } + @SneakyThrows + @Test + public void functionStats_returns_success() { + // setup + String[] args = new String[0]; + Map> value = Map.of("1", Map.of("2", 2)); + CompletableFuture>> testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.>>submitNewCommand( + eq(FunctionStats), eq(args), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture>> response = service.functionStats(); + Map> 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 46cf670800..6ed9c5111c 100644 --- a/java/client/src/test/java/glide/api/RedisClusterClientTest.java +++ b/java/client/src/test/java/glide/api/RedisClusterClientTest.java @@ -33,6 +33,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.FunctionKill; import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionStats; import static redis_request.RedisRequestOuterClass.RequestType.Info; import static redis_request.RedisRequestOuterClass.RequestType.LastSave; import static redis_request.RedisRequestOuterClass.RequestType.Lolwut; @@ -1586,4 +1587,56 @@ public void functionKill_with_route_returns_success() { assertEquals(testResponse, response); assertEquals(OK, payload); } + + @SneakyThrows + @Test + public void functionStats_returns_success() { + // setup + String[] args = new String[0]; + ClusterValue>> value = + ClusterValue.ofSingleValue(Map.of("1", Map.of("2", 2))); + CompletableFuture>>> testResponse = + new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.>>>submitNewCommand( + eq(FunctionStats), eq(args), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture>>> response = + service.functionStats(); + ClusterValue>> payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void functionStats_with_route_returns_success() { + // setup + String[] args = new String[0]; + ClusterValue>> value = + ClusterValue.ofSingleValue(Map.of("1", Map.of("2", 2))); + CompletableFuture>>> testResponse = + new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.>>>submitNewCommand( + eq(FunctionStats), eq(args), eq(RANDOM), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture>>> response = + service.functionStats(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 426569ed69..f92a9710d5 100644 --- a/java/client/src/test/java/glide/api/models/TransactionTests.java +++ b/java/client/src/test/java/glide/api/models/TransactionTests.java @@ -68,6 +68,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.FunctionFlush; import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionStats; import static redis_request.RedisRequestOuterClass.RequestType.GeoAdd; import static redis_request.RedisRequestOuterClass.RequestType.GeoDist; import static redis_request.RedisRequestOuterClass.RequestType.GeoHash; @@ -875,6 +876,9 @@ InfScoreBound.NEGATIVE_INFINITY, new ScoreBoundary(3, false), new Limit(1, 2)), transaction.fcall("func", new String[] {"arg1", "arg2"}); results.add(Pair.of(FCall, buildArgs("func", "0", "arg1", "arg2"))); + transaction.functionStats(); + results.add(Pair.of(FunctionStats, buildArgs())); + 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 73b52bb3dc..e162a0ea9e 100644 --- a/java/integTest/src/test/java/glide/TestUtilities.java +++ b/java/integTest/src/test/java/glide/TestUtilities.java @@ -3,6 +3,7 @@ import static glide.TestConfiguration.CLUSTER_PORTS; import static glide.TestConfiguration.STANDALONE_PORTS; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -164,6 +165,40 @@ public static void checkFunctionListResponse( assertTrue(hasLib); } + /** + * Validate whether `FUNCTION STATS` response contains required info. + * + * @param response The response from server. + * @param runningFunction Command line of running function expected. Empty, if nothing expected. + * @param libCount Expected libraries count. + * @param functionCount Expected functions count. + */ + public static void checkFunctionStatsResponse( + Map> response, + String[] runningFunction, + long libCount, + long functionCount) { + Map runningScriptInfo = response.get("running_script"); + if (runningScriptInfo == null && runningFunction.length != 0) { + fail("No running function info"); + } + if (runningScriptInfo != null && runningFunction.length == 0) { + String[] command = (String[]) runningScriptInfo.get("command"); + fail("Unexpected running function info: " + String.join(" ", command)); + } + + if (runningScriptInfo != null) { + String[] command = (String[]) runningScriptInfo.get("command"); + assertArrayEquals(runningFunction, command); + // command line format is: + // fcall|fcall_ro * * + assertEquals(runningFunction[1], runningScriptInfo.get("name")); + } + var expected = + Map.of("LUA", Map.of("libraries_count", libCount, "functions_count", functionCount)); + assertEquals(expected, response.get("engines")); + } + /** Generate a LUA library code. */ public static String generateLuaLibCode( String libName, Map functions, boolean readonly) { diff --git a/java/integTest/src/test/java/glide/TransactionTestUtilities.java b/java/integTest/src/test/java/glide/TransactionTestUtilities.java index 9ed11d12aa..0f3008dbec 100644 --- a/java/integTest/src/test/java/glide/TransactionTestUtilities.java +++ b/java/integTest/src/test/java/glide/TransactionTestUtilities.java @@ -770,10 +770,15 @@ private static Object[] scriptingAndFunctionsCommands(BaseTransaction transac return new Object[0]; } + final String libName = "mylib1T"; + final String funcName = "myfunc1T"; + final String code = - "#!lua name=mylib1T\n" - + "redis.register_function('myfunc1T'," - + "function(keys, args) return args[1] end)"; // function returns first argument + "#!lua name=" + + libName + + "\n redis.register_function('" + + funcName + + "', function(keys, args) return args[1] end)"; // function returns first argument var expectedFuncData = new HashMap() { @@ -797,29 +802,48 @@ private static Object[] scriptingAndFunctionsCommands(BaseTransaction transac code) }; + var expectedFunctionStatsNonEmpty = + new HashMap>() { + { + put("running_script", null); + put("engines", Map.of("LUA", Map.of("libraries_count", 1L, "functions_count", 1L))); + } + }; + var expectedFunctionStatsEmpty = + new HashMap>() { + { + put("running_script", null); + put("engines", Map.of("LUA", Map.of("libraries_count", 0L, "functions_count", 0L))); + } + }; + transaction .functionFlush(SYNC) .functionList(false) .functionLoad(code, false) .functionLoad(code, true) + .functionStats() .fcall("myfunc1T", new String[0], new String[] {"a", "b"}) .fcall("myfunc1T", new String[] {"a", "b"}) .functionList("otherLib", false) .functionList("mylib1T", true) .functionDelete("mylib1T") - .functionList(true); + .functionList(true) + .functionStats(); return new Object[] { OK, // functionFlush(SYNC) new Map[0], // functionList(false) "mylib1T", // functionLoad(code, false) "mylib1T", // functionLoad(code, true) + expectedFunctionStatsNonEmpty, // functionStats() "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") new Map[0], // functionList(true) + expectedFunctionStatsEmpty, // functionStats() }; } diff --git a/java/integTest/src/test/java/glide/cluster/CommandTests.java b/java/integTest/src/test/java/glide/cluster/CommandTests.java index 2ff5664c1a..952791193c 100644 --- a/java/integTest/src/test/java/glide/cluster/CommandTests.java +++ b/java/integTest/src/test/java/glide/cluster/CommandTests.java @@ -4,6 +4,7 @@ import static glide.TestConfiguration.REDIS_VERSION; import static glide.TestUtilities.assertDeepEquals; import static glide.TestUtilities.checkFunctionListResponse; +import static glide.TestUtilities.checkFunctionStatsResponse; import static glide.TestUtilities.commonClusterClientConfig; import static glide.TestUtilities.createLuaLibWithLongRunningFunction; import static glide.TestUtilities.generateLuaLibCode; @@ -1105,6 +1106,8 @@ public void functionStats_and_functionKill_without_route() { String code = createLuaLibWithLongRunningFunction(libName, funcName, 15, true); String error = ""; + assertEquals(OK, clusterClient.functionFlush(SYNC).get()); + try { // nothing to kill var exception = @@ -1126,11 +1129,12 @@ public void functionStats_and_functionKill_without_route() { int timeout = 5200; // ms while (timeout > 0) { - var stats = clusterClient.customCommand(new String[] {"FUNCTION", "STATS"}).get(); + var response = clusterClient.functionStats().get().getMultiValue(); boolean found = false; - for (var response : stats.getMultiValue().values()) { - if (((Map) response).get("running_script") != null) { + for (var stats : response.values()) { + if (stats.get("running_script") != null) { found = true; + checkFunctionStatsResponse(stats, new String[] {"FCALL", funcName, "0"}, 1, 1); break; } } @@ -1166,8 +1170,6 @@ public void functionStats_and_functionKill_without_route() { } } - assertEquals(OK, clusterClient.functionDelete(libName).get()); - assertTrue(error.isEmpty(), "Something went wrong during the test"); } @@ -1184,6 +1186,8 @@ public void functionStats_and_functionKill_with_route(boolean singleNodeRoute) { singleNodeRoute ? new SlotKeyRoute(UUID.randomUUID().toString(), PRIMARY) : ALL_PRIMARIES; String error = ""; + assertEquals(OK, clusterClient.functionFlush(SYNC, route).get()); + try { // nothing to kill var exception = @@ -1202,17 +1206,19 @@ public void functionStats_and_functionKill_with_route(boolean singleNodeRoute) { int timeout = 5200; // ms while (timeout > 0) { - var stats = clusterClient.customCommand(new String[] {"FUNCTION", "STATS"}, route).get(); + var response = clusterClient.functionStats(route).get(); if (singleNodeRoute) { - var response = stats.getSingleValue(); - if (((Map) response).get("running_script") != null) { + var stats = response.getSingleValue(); + if (stats.get("running_script") != null) { + checkFunctionStatsResponse(stats, new String[] {"FCALL", funcName, "0"}, 1, 1); break; } } else { boolean found = false; - for (var response : stats.getMultiValue().values()) { - if (((Map) response).get("running_script") != null) { + for (var stats : response.getMultiValue().values()) { + if (stats.get("running_script") != null) { found = true; + checkFunctionStatsResponse(stats, new String[] {"FCALL", funcName, "0"}, 1, 1); break; } } @@ -1251,8 +1257,6 @@ public void functionStats_and_functionKill_with_route(boolean singleNodeRoute) { } } - assertEquals(OK, clusterClient.functionDelete(libName, route).get()); - assertTrue(error.isEmpty(), "Something went wrong during the test"); } @@ -1268,6 +1272,8 @@ public void functionStats_and_functionKill_with_key_based_route() { Route route = new SlotKeyRoute(key, PRIMARY); String error = ""; + assertEquals(OK, clusterClient.functionFlush(SYNC, route).get()); + try { // nothing to kill var exception = @@ -1286,9 +1292,9 @@ public void functionStats_and_functionKill_with_key_based_route() { int timeout = 5200; // ms while (timeout > 0) { - var stats = clusterClient.customCommand(new String[] {"FUNCTION", "STATS"}, route).get(); - var response = stats.getSingleValue(); - if (((Map) response).get("running_script") != null) { + var stats = clusterClient.functionStats(route).get().getSingleValue(); + if (stats.get("running_script") != null) { + checkFunctionStatsResponse(stats, new String[] {"FCALL", funcName, "1", key}, 1, 1); break; } Thread.sleep(100); @@ -1321,8 +1327,6 @@ public void functionStats_and_functionKill_with_key_based_route() { } } - assertEquals(OK, clusterClient.functionDelete(libName, route).get()); - assertTrue(error.isEmpty(), "Something went wrong during the test"); } @@ -1338,6 +1342,8 @@ public void functionStats_and_functionKill_write_function() { Route route = new SlotKeyRoute(key, PRIMARY); String error = ""; + assertEquals(OK, clusterClient.functionFlush(SYNC, route).get()); + try { // nothing to kill var exception = @@ -1356,9 +1362,9 @@ public void functionStats_and_functionKill_write_function() { int timeout = 5200; // ms while (timeout > 0) { - var stats = clusterClient.customCommand(new String[] {"FUNCTION", "STATS"}, route).get(); - var response = stats.getSingleValue(); - if (((Map) response).get("running_script") != null) { + var stats = clusterClient.functionStats(route).get().getSingleValue(); + if (stats.get("running_script") != null) { + checkFunctionStatsResponse(stats, new String[] {"FCALL", funcName, "1", key}, 1, 1); break; } Thread.sleep(100); @@ -1392,8 +1398,97 @@ public void functionStats_and_functionKill_write_function() { } } - assertEquals(OK, clusterClient.functionDelete(libName, route).get()); - assertTrue(error.isEmpty(), "Something went wrong during the test"); } + + @Test + @SneakyThrows + public void functionStats_without_route() { + assumeTrue(REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0"), "This feature added in redis 7"); + + String libName = "functionStats_without_route"; + String funcName = libName; + assertEquals(OK, clusterClient.functionFlush(SYNC).get()); + + // function $funcName returns first argument + String code = generateLuaLibCode(libName, Map.of(funcName, "return args[1]"), false); + assertEquals(libName, clusterClient.functionLoad(code, true).get()); + + var response = clusterClient.functionStats().get().getMultiValue(); + for (var nodeResponse : response.values()) { + checkFunctionStatsResponse(nodeResponse, new String[0], 1, 1); + } + + code = + generateLuaLibCode( + libName + "_2", + Map.of(funcName + "_2", "return 'OK'", funcName + "_3", "return 42"), + false); + assertEquals(libName + "_2", clusterClient.functionLoad(code, true).get()); + + response = clusterClient.functionStats().get().getMultiValue(); + for (var nodeResponse : response.values()) { + checkFunctionStatsResponse(nodeResponse, new String[0], 2, 3); + } + + assertEquals(OK, clusterClient.functionFlush(SYNC).get()); + + response = clusterClient.functionStats().get().getMultiValue(); + for (var nodeResponse : response.values()) { + checkFunctionStatsResponse(nodeResponse, new String[0], 0, 0); + } + } + + @ParameterizedTest(name = "single node route = {0}") + @ValueSource(booleans = {true, false}) + @SneakyThrows + public void functionStats_with_route(boolean singleNodeRoute) { + assumeTrue(REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0"), "This feature added in redis 7"); + Route route = + singleNodeRoute ? new SlotKeyRoute(UUID.randomUUID().toString(), PRIMARY) : ALL_PRIMARIES; + String libName = "functionStats_with_route_" + singleNodeRoute; + String funcName = libName; + + assertEquals(OK, clusterClient.functionFlush(SYNC, route).get()); + + // function $funcName returns first argument + String code = generateLuaLibCode(libName, Map.of(funcName, "return args[1]"), false); + assertEquals(libName, clusterClient.functionLoad(code, true, route).get()); + + var response = clusterClient.functionStats(route).get(); + if (singleNodeRoute) { + checkFunctionStatsResponse(response.getSingleValue(), new String[0], 1, 1); + } else { + for (var nodeResponse : response.getMultiValue().values()) { + checkFunctionStatsResponse(nodeResponse, new String[0], 1, 1); + } + } + + code = + generateLuaLibCode( + libName + "_2", + Map.of(funcName + "_2", "return 'OK'", funcName + "_3", "return 42"), + false); + assertEquals(libName + "_2", clusterClient.functionLoad(code, true, route).get()); + + response = clusterClient.functionStats(route).get(); + if (singleNodeRoute) { + checkFunctionStatsResponse(response.getSingleValue(), new String[0], 2, 3); + } else { + for (var nodeResponse : response.getMultiValue().values()) { + checkFunctionStatsResponse(nodeResponse, new String[0], 2, 3); + } + } + + assertEquals(OK, clusterClient.functionFlush(SYNC, route).get()); + + response = clusterClient.functionStats(route).get(); + if (singleNodeRoute) { + checkFunctionStatsResponse(response.getSingleValue(), new String[0], 0, 0); + } else { + for (var nodeResponse : response.getMultiValue().values()) { + checkFunctionStatsResponse(nodeResponse, new String[0], 0, 0); + } + } + } } diff --git a/java/integTest/src/test/java/glide/standalone/CommandTests.java b/java/integTest/src/test/java/glide/standalone/CommandTests.java index 432be4e89d..a9d384b886 100644 --- a/java/integTest/src/test/java/glide/standalone/CommandTests.java +++ b/java/integTest/src/test/java/glide/standalone/CommandTests.java @@ -3,6 +3,7 @@ import static glide.TestConfiguration.REDIS_VERSION; import static glide.TestUtilities.checkFunctionListResponse; +import static glide.TestUtilities.checkFunctionStatsResponse; import static glide.TestUtilities.commonClientConfig; import static glide.TestUtilities.createLuaLibWithLongRunningFunction; import static glide.TestUtilities.generateLuaLibCode; @@ -514,7 +515,7 @@ public void copy() { } } - @Test + // @Test @SneakyThrows public void functionStats_and_functionKill() { assumeTrue(REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0"), "This feature added in redis 7"); @@ -524,6 +525,8 @@ public void functionStats_and_functionKill() { String code = createLuaLibWithLongRunningFunction(libName, funcName, 15, true); String error = ""; + assertEquals(OK, regularClient.functionFlush(SYNC).get()); + try { // nothing to kill var exception = @@ -541,8 +544,9 @@ public void functionStats_and_functionKill() { int timeout = 5200; // ms while (timeout > 0) { - var response = regularClient.customCommand(new String[] {"FUNCTION", "STATS"}).get(); - if (((Map) response).get("running_script") != null) { + var stats = regularClient.functionStats().get(); + if (stats.get("running_script") != null) { + checkFunctionStatsResponse(stats, new String[] {"FCALL", funcName, "0"}, 1, 1); break; } Thread.sleep(100); @@ -587,9 +591,12 @@ public void functionStats_and_functionKill_write_function() { String libName = "functionStats_and_functionKill_write_function"; String funcName = "deadlock_write_function"; + String key = libName; String code = createLuaLibWithLongRunningFunction(libName, funcName, 6, false); String error = ""; + assertEquals(OK, regularClient.functionFlush(SYNC).get()); + try { // nothing to kill var exception = @@ -603,12 +610,13 @@ public void functionStats_and_functionKill_write_function() { try (var testClient = RedisClient.CreateClient(commonClientConfig().requestTimeout(7000).build()).get()) { // call the function without await - var promise = testClient.fcall(funcName, new String[] {libName}, new String[0]); + var promise = testClient.fcall(funcName, new String[] {key}, new String[0]); int timeout = 5200; // ms while (timeout > 0) { - var response = regularClient.customCommand(new String[] {"FUNCTION", "STATS"}).get(); - if (((Map) response).get("running_script") != null) { + var stats = regularClient.functionStats().get(); + if (stats.get("running_script") != null) { + checkFunctionStatsResponse(stats, new String[] {"FCALL", funcName, "1", key}, 1, 1); break; } Thread.sleep(100); @@ -642,8 +650,38 @@ public void functionStats_and_functionKill_write_function() { } } - assertEquals(OK, regularClient.functionDelete(libName).get()); - assertTrue(error.isEmpty(), "Something went wrong during the test"); } + + @Test + @SneakyThrows + public void functionStats() { + assumeTrue(REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0"), "This feature added in redis 7"); + + String libName = "functionStats"; + String funcName = libName; + assertEquals(OK, regularClient.functionFlush(SYNC).get()); + + // function $funcName returns first argument + String code = generateLuaLibCode(libName, Map.of(funcName, "return args[1]"), false); + assertEquals(libName, regularClient.functionLoad(code, true).get()); + + var response = regularClient.functionStats().get(); + checkFunctionStatsResponse(response, new String[0], 1, 1); + + code = + generateLuaLibCode( + libName + "_2", + Map.of(funcName + "_2", "return 'OK'", funcName + "_3", "return 42"), + false); + assertEquals(libName + "_2", regularClient.functionLoad(code, true).get()); + + response = regularClient.functionStats().get(); + checkFunctionStatsResponse(response, new String[0], 2, 3); + + assertEquals(OK, regularClient.functionFlush(SYNC).get()); + + response = regularClient.functionStats().get(); + checkFunctionStatsResponse(response, new String[0], 0, 0); + } }