diff --git a/java/client/src/main/java/glide/api/BaseClient.java b/java/client/src/main/java/glide/api/BaseClient.java index dd530dfd8b..784f63f474 100644 --- a/java/client/src/main/java/glide/api/BaseClient.java +++ b/java/client/src/main/java/glide/api/BaseClient.java @@ -58,7 +58,9 @@ import glide.api.commands.SetBaseCommands; import glide.api.commands.SortedSetBaseCommands; import glide.api.commands.StringCommands; +import glide.api.models.Script; import glide.api.models.commands.ExpireOptions; +import glide.api.models.commands.ScriptOptions; import glide.api.models.commands.SetOptions; import glide.api.models.commands.ZaddOptions; import glide.api.models.configuration.BaseClientConfiguration; @@ -72,6 +74,7 @@ import glide.managers.BaseCommandResponseResolver; import glide.managers.CommandManager; import glide.managers.ConnectionManager; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -529,6 +532,19 @@ public CompletableFuture ttl(@NonNull String key) { return commandManager.submitNewCommand(TTL, new String[] {key}, this::handleLongResponse); } + @Override + public CompletableFuture invokeScript(@NonNull Script script) { + return commandManager.submitScript( + script, List.of(), List.of(), this::handleObjectOrNullResponse); + } + + @Override + public CompletableFuture invokeScript( + @NonNull Script script, @NonNull ScriptOptions options) { + return commandManager.submitScript( + script, options.getKeys(), options.getArgs(), this::handleObjectOrNullResponse); + } + @Override public CompletableFuture zadd( @NonNull String key, diff --git a/java/client/src/main/java/glide/api/commands/GenericBaseCommands.java b/java/client/src/main/java/glide/api/commands/GenericBaseCommands.java index 0edaa8e67a..a1aadc31ad 100644 --- a/java/client/src/main/java/glide/api/commands/GenericBaseCommands.java +++ b/java/client/src/main/java/glide/api/commands/GenericBaseCommands.java @@ -1,7 +1,9 @@ /** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide.api.commands; +import glide.api.models.Script; import glide.api.models.commands.ExpireOptions; +import glide.api.models.commands.ScriptOptions; import java.util.concurrent.CompletableFuture; /** @@ -270,6 +272,53 @@ CompletableFuture pexpireAt( */ CompletableFuture ttl(String key); + /** + * Invokes a Lua script.
+ * This method simplifies the process of invoking scripts on a Redis server by using an object + * that represents a Lua script. The script loading and execution will all be handled internally. + * If the script has not already been loaded, it will be loaded automatically using the Redis + * SCRIPT LOAD command. After that, it will be invoked using the Redis EVALSHA + * command. + * + * @see SCRIPT LOAD and EVALSHA for details. + * @param script The Lua script to execute. + * @return a value that depends on the script that was executed. + * @example + *
{@code
+     * try(Script luaScript = new Script("return 'Hello'")) {
+     *     String result = (String) client.invokeScript(luaScript).get();
+     *     assert result.equals("Hello");
+     * }
+     * }
+ */ + CompletableFuture invokeScript(Script script); + + /** + * Invokes a Lua script with its keys and arguments.
+ * This method simplifies the process of invoking scripts on a Redis server by using an object + * that represents a Lua script. The script loading, argument preparation, and execution will all + * be handled internally. If the script has not already been loaded, it will be loaded + * automatically using the Redis SCRIPT LOAD command. After that, it will be invoked + * using the Redis EVALSHA command. + * + * @see SCRIPT LOAD and EVALSHA for details. + * @param script The Lua script to execute. + * @param options The script option that contains keys and arguments for the script. + * @return a value that depends on the script that was executed. + * @example + *
{@code
+     * try(Script luaScript = new Script("return { KEYS[1], ARGV[1] }")) {
+     *     ScriptOptions scriptOptions = ScriptOptions.builder().key("foo").arg("bar").build();
+     *     Object[] result = (Object[]) client.invokeScript(luaScript, scriptOptions).get();
+     *     assert result[0].equals("foo");
+     *     assert result[1].equals("bar");
+     * }
+     * }
+ */ + CompletableFuture invokeScript(Script script, ScriptOptions options); + /** * Returns the remaining time to live of key that has a timeout, in milliseconds. * diff --git a/java/client/src/main/java/glide/api/models/Script.java b/java/client/src/main/java/glide/api/models/Script.java new file mode 100644 index 0000000000..80688ddd2f --- /dev/null +++ b/java/client/src/main/java/glide/api/models/Script.java @@ -0,0 +1,45 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.models; + +import static glide.ffi.resolvers.ScriptResolver.dropScript; +import static glide.ffi.resolvers.ScriptResolver.storeScript; + +import glide.api.commands.GenericBaseCommands; +import lombok.Getter; + +/** + * A wrapper for a Script object for {@link GenericBaseCommands#invokeScript(Script)} As long as + * this object is not closed, the script's code is saved in memory, and can be resent to the server. + * Script should be enclosed with a try-with-resource block or {@link Script#close()} must be called + * to invalidate the code hash. + */ +public class Script implements AutoCloseable { + + /** Hash string representing the code. */ + @Getter private final String hash; + + /** + * Wraps around creating a Script object from code. + * + * @param code To execute with a ScriptInvoke call. + */ + public Script(String code) { + hash = storeScript(code); + } + + /** Drop the linked script from glide-rs code. */ + @Override + public void close() throws Exception { + dropScript(hash); + } + + @Override + protected void finalize() throws Throwable { + try { + // Drop the linked script on garbage collection. + this.close(); + } finally { + super.finalize(); + } + } +} diff --git a/java/client/src/main/java/glide/api/models/commands/ScriptOptions.java b/java/client/src/main/java/glide/api/models/commands/ScriptOptions.java new file mode 100644 index 0000000000..6aef640569 --- /dev/null +++ b/java/client/src/main/java/glide/api/models/commands/ScriptOptions.java @@ -0,0 +1,24 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.models.commands; + +import glide.api.commands.GenericBaseCommands; +import glide.api.models.Script; +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.Singular; + +/** + * Optional arguments for {@link GenericBaseCommands#invokeScript(Script, ScriptOptions)} command. + * + * @see redis.io + */ +@Builder +public final class ScriptOptions { + + /** The keys that are used in the script. */ + @Singular @Getter private final List keys; + + /** The arguments for the script. */ + @Singular @Getter private final List args; +} diff --git a/java/client/src/main/java/glide/ffi/resolvers/ScriptResolver.java b/java/client/src/main/java/glide/ffi/resolvers/ScriptResolver.java new file mode 100644 index 0000000000..cf2b98daba --- /dev/null +++ b/java/client/src/main/java/glide/ffi/resolvers/ScriptResolver.java @@ -0,0 +1,25 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.ffi.resolvers; + +public class ScriptResolver { + + // TODO: consider lazy loading the glide_rs library + static { + System.loadLibrary("glide_rs"); + } + + /** + * Loads a Lua script into the scripts cache, without executing it. + * + * @param code The Lua script + * @return String representing the saved hash + */ + public static native String storeScript(String code); + + /** + * Unload or drop the stored Lua script from the script cache. + * + * @param hash + */ + public static native void dropScript(String hash); +} diff --git a/java/client/src/main/java/glide/managers/CommandManager.java b/java/client/src/main/java/glide/managers/CommandManager.java index bd6f17befc..240f60f271 100644 --- a/java/client/src/main/java/glide/managers/CommandManager.java +++ b/java/client/src/main/java/glide/managers/CommandManager.java @@ -2,6 +2,7 @@ package glide.managers; import glide.api.models.ClusterTransaction; +import glide.api.models.Script; import glide.api.models.Transaction; import glide.api.models.configuration.RequestRoutingConfiguration.ByAddressRoute; import glide.api.models.configuration.RequestRoutingConfiguration.Route; @@ -12,6 +13,7 @@ import glide.api.models.exceptions.RequestException; import glide.connectors.handlers.CallbackDispatcher; import glide.connectors.handlers.ChannelHandler; +import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; @@ -21,6 +23,7 @@ import redis_request.RedisRequestOuterClass.RedisRequest; import redis_request.RedisRequestOuterClass.RequestType; import redis_request.RedisRequestOuterClass.Routes; +import redis_request.RedisRequestOuterClass.ScriptInvocation; import redis_request.RedisRequestOuterClass.SimpleRoutes; import redis_request.RedisRequestOuterClass.SlotTypes; import response.ResponseOuterClass.Response; @@ -85,6 +88,25 @@ public CompletableFuture submitNewCommand( return submitCommandToChannel(command, responseHandler); } + /** + * Build a Script (by hash) request to send to Redis. + * + * @param script Lua script hash object + * @param keys The keys that are used in the script + * @param args The arguments for the script + * @param responseHandler The handler for the response object + * @return A result promise of type T + */ + public CompletableFuture submitScript( + Script script, + List keys, + List args, + RedisExceptionCheckedFunction responseHandler) { + + RedisRequest.Builder command = prepareRedisRequest(script, keys, args); + return submitCommandToChannel(command, responseHandler); + } + /** * Build a Transaction and send. * @@ -168,6 +190,29 @@ protected RedisRequest.Builder prepareRedisRequest(Transaction transaction) { return builder; } + /** + * Build a protobuf Script Invoke request. + * + * @param script Redis Script + * @param keys keys for the Script + * @param args args for the Script + * @return An uncompleted request. {@link CallbackDispatcher} is responsible to complete it by + * adding a callback id. + */ + protected RedisRequest.Builder prepareRedisRequest( + Script script, List keys, List args) { + RedisRequest.Builder builder = + RedisRequest.newBuilder() + .setScriptInvocation( + ScriptInvocation.newBuilder() + .setHash(script.getHash()) + .addAllKeys(keys) + .addAllArgs(args) + .build()); + + return builder; + } + /** * Build a protobuf transaction request object with routing options. * diff --git a/java/client/src/test/java/glide/api/RedisClientTest.java b/java/client/src/test/java/glide/api/RedisClientTest.java index a98ee3df55..8647d6a741 100644 --- a/java/client/src/test/java/glide/api/RedisClientTest.java +++ b/java/client/src/test/java/glide/api/RedisClientTest.java @@ -73,16 +73,20 @@ import static redis_request.RedisRequestOuterClass.RequestType.Zcard; import static redis_request.RedisRequestOuterClass.RequestType.Zrem; +import glide.api.models.Script; import glide.api.models.commands.ExpireOptions; import glide.api.models.commands.InfoOptions; +import glide.api.models.commands.ScriptOptions; import glide.api.models.commands.SetOptions; import glide.api.models.commands.SetOptions.Expiry; import glide.api.models.commands.ZaddOptions; import glide.managers.CommandManager; import glide.managers.ConnectionManager; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import lombok.SneakyThrows; import org.apache.commons.lang3.ArrayUtils; @@ -580,6 +584,58 @@ public void ttl_returns_success() { assertEquals(ttl, response.get()); } + @SneakyThrows + @Test + public void invokeScript_returns_success() { + // setup + Script script = mock(Script.class); + String hash = UUID.randomUUID().toString(); + when(script.getHash()).thenReturn(hash); + String payload = "hello"; + + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(payload); + + // match on protobuf request + when(commandManager.submitScript(eq(script), eq(List.of()), eq(List.of()), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = service.invokeScript(script); + + // verify + assertEquals(testResponse, response); + assertEquals(payload, response.get()); + } + + @SneakyThrows + @Test + public void invokeScript_with_ScriptOptions_returns_success() { + // setup + Script script = mock(Script.class); + String hash = UUID.randomUUID().toString(); + when(script.getHash()).thenReturn(hash); + String payload = "hello"; + + ScriptOptions options = + ScriptOptions.builder().key("key1").key("key2").arg("arg1").arg("arg2").build(); + + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(payload); + + // match on protobuf request + when(commandManager.submitScript( + eq(script), eq(List.of("key1", "key2")), eq(List.of("arg1", "arg2")), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = service.invokeScript(script, options); + + // verify + assertEquals(testResponse, response); + assertEquals(payload, response.get()); + } + @SneakyThrows @Test public void pttl_returns_success() { diff --git a/java/integTest/src/test/java/glide/SharedCommandTests.java b/java/integTest/src/test/java/glide/SharedCommandTests.java index 65c95df139..84d59a0fe5 100644 --- a/java/integTest/src/test/java/glide/SharedCommandTests.java +++ b/java/integTest/src/test/java/glide/SharedCommandTests.java @@ -19,7 +19,9 @@ import glide.api.BaseClient; import glide.api.RedisClient; import glide.api.RedisClusterClient; +import glide.api.models.Script; import glide.api.models.commands.ExpireOptions; +import glide.api.models.commands.ScriptOptions; import glide.api.models.commands.SetOptions; import glide.api.models.commands.ZaddOptions; import glide.api.models.configuration.NodeAddress; @@ -923,6 +925,43 @@ public void persist_on_existing_and_non_existing_key(BaseClient client) { assertEquals(-1L, client.ttl(key).get()); } + @SneakyThrows + @ParameterizedTest + @MethodSource("getClients") + public void invokeScript_test(BaseClient client) { + String key1 = UUID.randomUUID().toString(); + String key2 = UUID.randomUUID().toString(); + + try (Script script = new Script("return 'Hello'")) { + Object response = client.invokeScript(script).get(); + assertEquals("Hello", response); + } + + try (Script script = new Script("return redis.call('SET', KEYS[1], ARGV[1])")) { + Object setResponse1 = + client + .invokeScript(script, ScriptOptions.builder().key(key1).arg("value1").build()) + .get(); + assertEquals(OK, setResponse1); + + Object setResponse2 = + client + .invokeScript(script, ScriptOptions.builder().key(key2).arg("value2").build()) + .get(); + assertEquals(OK, setResponse2); + } + + try (Script script = new Script("return redis.call('GET', KEYS[1])")) { + Object getResponse1 = + client.invokeScript(script, ScriptOptions.builder().key(key1).build()).get(); + assertEquals("value1", getResponse1); + + Object getResponse2 = + client.invokeScript(script, ScriptOptions.builder().key(key2).build()).get(); + assertEquals("value2", getResponse2); + } + } + @SneakyThrows @ParameterizedTest @MethodSource("getClients") diff --git a/java/src/lib.rs b/java/src/lib.rs index 32b88969b2..9730b73cc4 100644 --- a/java/src/lib.rs +++ b/java/src/lib.rs @@ -3,7 +3,7 @@ */ use glide_core::start_socket_listener; -use jni::objects::{JClass, JObject, JObjectArray, JThrowable}; +use jni::objects::{JClass, JObject, JObjectArray, JString, JThrowable}; use jni::sys::jlong; use jni::JNIEnv; use log::error; @@ -153,3 +153,24 @@ fn throw_java_exception(mut env: JNIEnv, message: String) { } }; } + +#[no_mangle] +pub extern "system" fn Java_glide_ffi_resolvers_ScriptResolver_storeScript<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + code: JString, +) -> JObject<'local> { + let code_str: String = env.get_string(&code).unwrap().into(); + let hash = glide_core::scripts_container::add_script(&code_str); + JObject::from(env.new_string(hash).unwrap()) +} + +#[no_mangle] +pub extern "system" fn Java_glide_ffi_resolvers_ScriptResolver_dropScript<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + hash: JString, +) { + let hash_str: String = env.get_string(&hash).unwrap().into(); + glide_core::scripts_container::remove_script(&hash_str); +} diff --git a/node/src/BaseClient.ts b/node/src/BaseClient.ts index f609c7481c..c5b0201d9d 100644 --- a/node/src/BaseClient.ts +++ b/node/src/BaseClient.ts @@ -987,6 +987,7 @@ export class BaseClient { * This method simplifies the process of invoking scripts on a Redis server by using an object that represents a Lua script. * The script loading, argument preparation, and execution will all be handled internally. If the script has not already been loaded, * it will be loaded automatically using the Redis `SCRIPT LOAD` command. After that, it will be invoked using the Redis `EVALSHA` command + * See https://redis.io/commands/script-load/ and https://redis.io/commands/evalsha/ for details. * * @param script - The Lua script to execute. * @param options - The script option that contains keys and arguments for the script. diff --git a/python/python/glide/async_commands/core.py b/python/python/glide/async_commands/core.py index b0282f85f8..0d9ada155d 100644 --- a/python/python/glide/async_commands/core.py +++ b/python/python/glide/async_commands/core.py @@ -1746,6 +1746,8 @@ async def invoke_script( If the script has not already been loaded, it will be loaded automatically using the Redis `SCRIPT LOAD` command. After that, it will be invoked using the Redis `EVALSHA` command. + See https://redis.io/commands/script-load/ and https://redis.io/commands/evalsha/ for more details. + Args: script (Script): The Lua script to execute. keys (List[str]): The keys that are used in the script.