diff --git a/glide-core/src/protobuf/redis_request.proto b/glide-core/src/protobuf/redis_request.proto index 65a4066b9a..a7fd57ab1b 100644 --- a/glide-core/src/protobuf/redis_request.proto +++ b/glide-core/src/protobuf/redis_request.proto @@ -231,6 +231,8 @@ enum RequestType { XGroupDelConsumer = 190; RandomKey = 191; GetEx = 192; + Dump = 193; + Restore = 194; } message Command { diff --git a/glide-core/src/request_type.rs b/glide-core/src/request_type.rs index 2fa55807c1..3e3e917ce6 100644 --- a/glide-core/src/request_type.rs +++ b/glide-core/src/request_type.rs @@ -201,6 +201,8 @@ pub enum RequestType { XGroupDelConsumer = 190, RandomKey = 191, GetEx = 192, + Dump = 193, + Restore = 194, } fn get_two_word_command(first: &str, second: &str) -> Cmd { @@ -405,6 +407,8 @@ impl From<::protobuf::EnumOrUnknown> for RequestType { ProtobufRequestType::XGroupDelConsumer => RequestType::XGroupDelConsumer, ProtobufRequestType::RandomKey => RequestType::RandomKey, ProtobufRequestType::GetEx => RequestType::GetEx, + ProtobufRequestType::Dump => RequestType::Dump, + ProtobufRequestType::Restore => RequestType::Restore, } } } @@ -607,6 +611,8 @@ impl RequestType { RequestType::XGroupDelConsumer => Some(get_two_word_command("XGROUP", "DELCONSUMER")), RequestType::RandomKey => Some(cmd("RANDOMKEY")), RequestType::GetEx => Some(cmd("GETEX")), + RequestType::Dump => Some(cmd("DUMP")), + RequestType::Restore => Some(cmd("RESTORE")), } } } diff --git a/java/client/src/main/java/glide/api/BaseClient.java b/java/client/src/main/java/glide/api/BaseClient.java index 6050d4b275..4b89a72ff6 100644 --- a/java/client/src/main/java/glide/api/BaseClient.java +++ b/java/client/src/main/java/glide/api/BaseClient.java @@ -1,6 +1,7 @@ /** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide.api; +import static glide.api.models.GlideString.gs; import static glide.api.models.commands.bitmap.BitFieldOptions.BitFieldReadOnlySubCommands; import static glide.api.models.commands.bitmap.BitFieldOptions.BitFieldSubCommands; import static glide.api.models.commands.bitmap.BitFieldOptions.createBitFieldArgs; @@ -30,6 +31,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.Decr; import static redis_request.RedisRequestOuterClass.RequestType.DecrBy; import static redis_request.RedisRequestOuterClass.RequestType.Del; +import static redis_request.RedisRequestOuterClass.RequestType.Dump; import static redis_request.RedisRequestOuterClass.RequestType.Exists; import static redis_request.RedisRequestOuterClass.RequestType.Expire; import static redis_request.RedisRequestOuterClass.RequestType.ExpireAt; @@ -96,6 +98,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.RPushX; import static redis_request.RedisRequestOuterClass.RequestType.Rename; import static redis_request.RedisRequestOuterClass.RequestType.RenameNX; +import static redis_request.RedisRequestOuterClass.RequestType.Restore; import static redis_request.RedisRequestOuterClass.RequestType.SAdd; import static redis_request.RedisRequestOuterClass.RequestType.SCard; import static redis_request.RedisRequestOuterClass.RequestType.SDiff; @@ -185,6 +188,7 @@ import glide.api.models.commands.RangeOptions.RangeQuery; import glide.api.models.commands.RangeOptions.ScoreRange; import glide.api.models.commands.RangeOptions.ScoredRangeQuery; +import glide.api.models.commands.RestoreOptions; import glide.api.models.commands.ScoreFilter; import glide.api.models.commands.ScriptOptions; import glide.api.models.commands.SetOptions; @@ -370,7 +374,15 @@ protected String handleStringOrNullResponse(Response response) throws RedisExcep String.class, EnumSet.of(ResponseFlags.IS_NULLABLE, ResponseFlags.ENCODING_UTF8), response); } - protected GlideString handleBytesOrNullResponse(Response response) throws RedisException { + protected byte[] handleBytesOrNullResponse(Response response) throws RedisException { + var result = + handleRedisResponse(GlideString.class, EnumSet.of(ResponseFlags.IS_NULLABLE), response); + if (result == null) return null; + + return result.getBytes(); + } + + protected GlideString handleGlideStringOrNullResponse(Response response) throws RedisException { return handleRedisResponse(GlideString.class, EnumSet.of(ResponseFlags.IS_NULLABLE), response); } @@ -500,7 +512,7 @@ public CompletableFuture get(@NonNull String key) { @Override public CompletableFuture get(@NonNull GlideString key) { return commandManager.submitNewCommand( - Get, new GlideString[] {key}, this::handleBytesOrNullResponse); + Get, new GlideString[] {key}, this::handleGlideStringOrNullResponse); } @Override @@ -524,7 +536,7 @@ public CompletableFuture getex(@NonNull String key, @NonNull GetExOption @Override public CompletableFuture getdel(@NonNull GlideString key) { return commandManager.submitNewCommand( - GetDel, new GlideString[] {key}, this::handleBytesOrNullResponse); + GetDel, new GlideString[] {key}, this::handleGlideStringOrNullResponse); } @Override @@ -2045,4 +2057,27 @@ private Object convertByteArrayToGlideString(Object o) { } return o; } + + @Override + public CompletableFuture dump(@NonNull GlideString key) { + GlideString[] arguments = new GlideString[] {key}; + return commandManager.submitNewCommand(Dump, arguments, this::handleBytesOrNullResponse); + } + + @Override + public CompletableFuture restore( + @NonNull GlideString key, long ttl, @NonNull byte[] value) { + GlideString[] arguments = new GlideString[] {key, gs(Long.toString(ttl).getBytes()), gs(value)}; + return commandManager.submitNewCommand(Restore, arguments, this::handleStringResponse); + } + + @Override + public CompletableFuture restore( + @NonNull GlideString key, + long ttl, + @NonNull byte[] value, + @NonNull RestoreOptions restoreOptions) { + GlideString[] arguments = restoreOptions.toArgs(key, ttl, value); + return commandManager.submitNewCommand(Restore, arguments, this::handleStringResponse); + } } 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 18d07d8f0f..c778d28f93 100644 --- a/java/client/src/main/java/glide/api/commands/GenericBaseCommands.java +++ b/java/client/src/main/java/glide/api/commands/GenericBaseCommands.java @@ -1,8 +1,10 @@ /** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide.api.commands; +import glide.api.models.GlideString; import glide.api.models.Script; import glide.api.models.commands.ExpireOptions; +import glide.api.models.commands.RestoreOptions; import glide.api.models.commands.ScriptOptions; import java.util.concurrent.CompletableFuture; @@ -590,4 +592,64 @@ CompletableFuture pexpireAt( * } */ CompletableFuture copy(String source, String destination, boolean replace); + + /** + * Serialize the value stored at key in a Valkey-specific format and return it to the + * user. + * + * @see valkey.io for details. + * @param key The key of the set. + * @return The serialized value of a set.
+ * If key does not exist, null will be returned. + * @example + *
{@code
+     * byte[] result = client.dump("myKey").get();
+     *
+     * byte[] response = client.dump("nonExistingKey").get();
+     * assert response.equals(null);
+     * }
+ */ + CompletableFuture dump(GlideString key); + + /** + * Create a key associated with a value that is obtained by + * deserializing the provided serialized value (obtained via {@link #dump}). + * + * @see valkey.io for details. + * @param key The key of the set. + * @param ttl The expiry time (in milliseconds). If 0, the key will + * persist. + * @param value The serialized value. + * @return Return OK if successfully create a key with a value + * . + * @example + *
{@code
+     * String result = client.restore(gs("newKey"), 0, value).get();
+     * assert result.equals("OK");
+     * }
+ */ + CompletableFuture restore(GlideString key, long ttl, byte[] value); + + /** + * Create a key associated with a value that is obtained by + * deserializing the provided serialized value (obtained via {@link #dump}). + * + * @see valkey.io for details. + * @param key The key of the set. + * @param ttl The expiry time (in milliseconds). If 0, the key will + * persist. + * @param value The serialized value. + * @param restoreOptions The restore options. See {@link RestoreOptions}. + * @return Return OK if successfully create a key with a value + * . + * @example + *
{@code
+     * RestoreOptions options = RestoreOptions.builder().replace().absttl().idletime(10).frequency(10).build()).get();
+     * // Set restore options with replace and absolute TTL modifiers, object idletime and frequency to 10.
+     * String result = client.restore(gs("newKey"), 0, value, options).get();
+     * assert result.equals("OK");
+     * }
+ */ + CompletableFuture restore( + GlideString key, long ttl, byte[] value, RestoreOptions restoreOptions); } diff --git a/java/client/src/main/java/glide/api/models/commands/RestoreOptions.java b/java/client/src/main/java/glide/api/models/commands/RestoreOptions.java new file mode 100644 index 0000000000..a02dbc289f --- /dev/null +++ b/java/client/src/main/java/glide/api/models/commands/RestoreOptions.java @@ -0,0 +1,91 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.models.commands; + +import static glide.api.models.GlideString.gs; + +import glide.api.commands.GenericBaseCommands; +import glide.api.models.GlideString; +import java.util.ArrayList; +import java.util.List; +import lombok.*; + +/** + * Optional arguments to {@link GenericBaseCommands#restore(GlideString, long, byte[], + * RestoreOptions)} + * + * @see valkey.io + */ +@Getter +@Builder +public final class RestoreOptions { + /** REPLACE subcommand string to replace existing key */ + public static final String REPLACE_REDIS_API = "REPLACE"; + + /** + * ABSTTL subcommand string to represent absolute timestamp (in milliseconds) for TTL + */ + public static final String ABSTTL_REDIS_API = "ABSTTL"; + + /** IDELTIME subcommand string to set Object Idletime */ + public static final String IDLETIME_REDIS_API = "IDLETIME"; + + /** FREQ subcommand string to set Object Frequency */ + public static final String FREQ_REDIS_API = "FREQ"; + + /** When `true`, it represents REPLACE keyword has been used */ + @Builder.Default private boolean hasReplace = false; + + /** When `true`, it represents ABSTTL keyword has been used */ + @Builder.Default private boolean hasAbsttl = false; + + /** It represents the idletime of object */ + @Builder.Default private Long idletime = null; + + /** It represents the frequency of object */ + @Builder.Default private Long frequency = null; + + /** + * Creates the argument to be used in {@link GenericBaseCommands#restore(GlideString, long, + * byte[], RestoreOptions)} + * + * @return a GlideString array that holds the subcommands and their arguments. + */ + public GlideString[] toArgs(GlideString key, long ttl, byte[] value) { + List resultList = new ArrayList<>(); + + resultList.add(key); + resultList.add(gs(Long.toString(ttl))); + resultList.add(gs(value)); + + if (hasReplace) { + resultList.add(gs(REPLACE_REDIS_API)); + } + + if (hasAbsttl) { + resultList.add(gs(ABSTTL_REDIS_API)); + } + + if (idletime != null) { + resultList.add(gs(IDLETIME_REDIS_API)); + resultList.add(gs(Long.toString(idletime))); + } + + if (frequency != null) { + resultList.add(gs(FREQ_REDIS_API)); + resultList.add(gs(Long.toString(frequency))); + } + + return resultList.toArray(new GlideString[0]); + } + + /** Custom setter methods for replace and absttl */ + public static class RestoreOptionsBuilder { + public RestoreOptionsBuilder replace() { + return hasReplace(true); + } + + public RestoreOptionsBuilder absttl() { + return hasAbsttl(true); + } + } +} diff --git a/java/client/src/test/java/glide/api/RedisClientTest.java b/java/client/src/test/java/glide/api/RedisClientTest.java index 438af62eab..6cecdd1da1 100644 --- a/java/client/src/test/java/glide/api/RedisClientTest.java +++ b/java/client/src/test/java/glide/api/RedisClientTest.java @@ -12,6 +12,7 @@ import static glide.api.commands.SortedSetBaseCommands.WITH_SCORES_REDIS_API; import static glide.api.commands.SortedSetBaseCommands.WITH_SCORE_REDIS_API; import static glide.api.commands.StringBaseCommands.LEN_REDIS_API; +import static glide.api.models.GlideString.gs; import static glide.api.models.commands.FlushMode.ASYNC; import static glide.api.models.commands.FlushMode.SYNC; import static glide.api.models.commands.LInsertOptions.InsertPosition.BEFORE; @@ -81,6 +82,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.Decr; import static redis_request.RedisRequestOuterClass.RequestType.DecrBy; import static redis_request.RedisRequestOuterClass.RequestType.Del; +import static redis_request.RedisRequestOuterClass.RequestType.Dump; import static redis_request.RedisRequestOuterClass.RequestType.Echo; import static redis_request.RedisRequestOuterClass.RequestType.Exists; import static redis_request.RedisRequestOuterClass.RequestType.Expire; @@ -162,6 +164,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.RandomKey; import static redis_request.RedisRequestOuterClass.RequestType.Rename; import static redis_request.RedisRequestOuterClass.RequestType.RenameNX; +import static redis_request.RedisRequestOuterClass.RequestType.Restore; import static redis_request.RedisRequestOuterClass.RequestType.SAdd; import static redis_request.RedisRequestOuterClass.RequestType.SCard; import static redis_request.RedisRequestOuterClass.RequestType.SDiff; @@ -229,6 +232,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.ZUnion; import static redis_request.RedisRequestOuterClass.RequestType.ZUnionStore; +import glide.api.models.GlideString; import glide.api.models.Script; import glide.api.models.Transaction; import glide.api.models.commands.ConditionalChange; @@ -246,6 +250,7 @@ import glide.api.models.commands.RangeOptions.RangeByLex; import glide.api.models.commands.RangeOptions.RangeByScore; import glide.api.models.commands.RangeOptions.ScoreBoundary; +import glide.api.models.commands.RestoreOptions; import glide.api.models.commands.ScoreFilter; import glide.api.models.commands.ScriptOptions; import glide.api.models.commands.SetOptions; @@ -6704,4 +6709,96 @@ public void sunion_returns_success() { assertEquals(testResponse, response); assertEquals(value, payload); } + + @SneakyThrows + @Test + public void dump_returns_success() { + // setup + GlideString key = gs("testKey"); + byte[] value = "value".getBytes(); + GlideString[] arguments = new GlideString[] {key}; + + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.submitNewCommand(eq(Dump), eq(arguments), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = service.dump(key); + byte[] payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void restore_returns_success() { + // setup + GlideString key = gs("testKey"); + long ttl = 0L; + byte[] value = "value".getBytes(); + + GlideString[] arg = new GlideString[] {key, gs(Long.toString(ttl).getBytes()), gs(value)}; + + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(OK); + + // match on protobuf request + when(commandManager.submitNewCommand(eq(Restore), eq(arg), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = service.restore(key, ttl, value); + + // verify + assertEquals(testResponse, response); + assertEquals(OK, response.get()); + } + + @SneakyThrows + @Test + public void restore_with_restoreOptions_returns_success() { + // setup + GlideString key = gs("testKey"); + long ttl = 0L; + byte[] value = "value".getBytes(); + Long idletime = 10L; + Long frequency = 5L; + + GlideString[] arg = + new GlideString[] { + key, + gs(Long.toString(ttl)), + gs(value), + gs("REPLACE"), + gs("ABSTTL"), + gs("IDLETIME"), + gs("10"), + gs("FREQ"), + gs("5") + }; + + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(OK); + + // match on protobuf request + when(commandManager.submitNewCommand(eq(Restore), eq(arg), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = + service.restore( + key, + ttl, + value, + RestoreOptions.builder().replace().absttl().idletime(10L).frequency(5L).build()); + + // verify + assertEquals(testResponse, response); + assertEquals(OK, response.get()); + } } diff --git a/java/integTest/src/test/java/glide/SharedCommandTests.java b/java/integTest/src/test/java/glide/SharedCommandTests.java index d4ead60a7a..5842f63ddf 100644 --- a/java/integTest/src/test/java/glide/SharedCommandTests.java +++ b/java/integTest/src/test/java/glide/SharedCommandTests.java @@ -46,6 +46,7 @@ import glide.api.models.commands.RangeOptions.RangeByLex; import glide.api.models.commands.RangeOptions.RangeByScore; import glide.api.models.commands.RangeOptions.ScoreBoundary; +import glide.api.models.commands.RestoreOptions; import glide.api.models.commands.ScriptOptions; import glide.api.models.commands.SetOptions; import glide.api.models.commands.WeightAggregateOptions.Aggregate; @@ -5764,4 +5765,129 @@ public void sunion(BaseClient client) { ExecutionException.class, () -> client.sunion(new String[] {nonSetKey, key1}).get()); assertInstanceOf(RequestException.class, executionException.getCause()); } + + @SneakyThrows + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("getClients") + public void test_dump_restore(BaseClient client) { + String key = UUID.randomUUID().toString(); + String newKey1 = UUID.randomUUID().toString(); + String newKey2 = UUID.randomUUID().toString(); + String nonExistingKey = UUID.randomUUID().toString(); + String value = "oranges"; + + assertEquals(OK, client.set(key, value).get()); + + // Dump existing key + byte[] result = client.dump(gs(key)).get(); + assertNotNull(result); + + // Dump non-existing key + assertNull(client.dump(gs(nonExistingKey)).get()); + + // Restore to a new key + assertEquals(OK, client.restore(gs(newKey1), 0L, result).get()); + + // Restore to an existing key - Error: "Target key name already exists" + Exception executionException = + assertThrows(ExecutionException.class, () -> client.restore(gs(newKey1), 0L, result).get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + + // Restore with checksum error - Error: "payload version or checksum are wrong" + executionException = + assertThrows( + ExecutionException.class, + () -> client.restore(gs(newKey2), 0L, value.getBytes()).get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + } + + @SneakyThrows + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("getClients") + public void test_dump_restore_withOptions(BaseClient client) { + String key = UUID.randomUUID().toString(); + String key2 = UUID.randomUUID().toString(); + String newKey = UUID.randomUUID().toString(); + String value = "oranges"; + + assertEquals(OK, client.set(key, value).get()); + + // Dump existing key + byte[] data = client.dump(gs(key)).get(); + assertNotNull(data); + + // Restore without option + String result = client.restore(gs(newKey), 0L, data).get(); + assertEquals(OK, result); + + // Restore with REPLACE option + result = client.restore(gs(newKey), 0L, data, RestoreOptions.builder().replace().build()).get(); + assertEquals(OK, result); + + // Restore with REPLACE and existing key holding different value + assertEquals(1, client.sadd(key2, new String[] {"a"}).get()); + result = client.restore(gs(key2), 0L, data, RestoreOptions.builder().replace().build()).get(); + assertEquals(OK, result); + + // Restore with REPLACE, ABSTTL, and positive TTL + result = + client + .restore(gs(newKey), 1000L, data, RestoreOptions.builder().replace().absttl().build()) + .get(); + assertEquals(OK, result); + + // Restore with REPLACE, ABSTTL, and negative TTL + ExecutionException executionException = + assertThrows( + ExecutionException.class, + () -> + client + .restore( + gs(newKey), -10L, data, RestoreOptions.builder().replace().absttl().build()) + .get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + + // Restore with REPLACE and positive idletime + result = + client + .restore(gs(newKey), 0L, data, RestoreOptions.builder().replace().idletime(10L).build()) + .get(); + assertEquals(OK, result); + + // Restore with REPLACE and negative idletime + executionException = + assertThrows( + ExecutionException.class, + () -> + client + .restore( + gs(newKey), + 0L, + data, + RestoreOptions.builder().replace().idletime(-10L).build()) + .get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + + // Restore with REPLACE and positive frequency + result = + client + .restore( + gs(newKey), 0L, data, RestoreOptions.builder().replace().frequency(10L).build()) + .get(); + assertEquals(OK, result); + + // Restore with REPLACE and negative frequency + executionException = + assertThrows( + ExecutionException.class, + () -> + client + .restore( + gs(newKey), + 0L, + data, + RestoreOptions.builder().replace().frequency(-10L).build()) + .get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + } }