From 4798b73bc6c934866cd588ae91966426aafdcf7d Mon Sep 17 00:00:00 2001 From: f1v3-dev Date: Tue, 31 Mar 2026 20:09:13 +0900 Subject: [PATCH] FEATURE: Add CompletableFuture List APIs --- .../spy/memcached/v2/AsyncArcusCommands.java | 124 ++++++ .../memcached/v2/AsyncArcusCommandsIF.java | 89 ++++- .../java/net/spy/memcached/v2/vo/GetArgs.java | 42 ++ .../v2/ListAsyncArcusCommandsTest.java | 368 ++++++++++++++++++ 4 files changed, 618 insertions(+), 5 deletions(-) create mode 100644 src/main/java/net/spy/memcached/v2/vo/GetArgs.java create mode 100644 src/test/java/net/spy/memcached/v2/ListAsyncArcusCommandsTest.java diff --git a/src/main/java/net/spy/memcached/v2/AsyncArcusCommands.java b/src/main/java/net/spy/memcached/v2/AsyncArcusCommands.java index faef478c5..61666ce7b 100644 --- a/src/main/java/net/spy/memcached/v2/AsyncArcusCommands.java +++ b/src/main/java/net/spy/memcached/v2/AsyncArcusCommands.java @@ -58,6 +58,10 @@ import net.spy.memcached.collection.CollectionUpdate; import net.spy.memcached.collection.ElementFlagFilter; import net.spy.memcached.collection.ElementValueType; +import net.spy.memcached.collection.ListCreate; +import net.spy.memcached.collection.ListDelete; +import net.spy.memcached.collection.ListGet; +import net.spy.memcached.collection.ListInsert; import net.spy.memcached.internal.result.GetsResultImpl; import net.spy.memcached.ops.APIType; import net.spy.memcached.ops.BTreeGetBulkOperation; @@ -83,6 +87,7 @@ import net.spy.memcached.v2.vo.BTreeUpdateElement; import net.spy.memcached.v2.vo.BopDeleteArgs; import net.spy.memcached.v2.vo.BopGetArgs; +import net.spy.memcached.v2.vo.GetArgs; import net.spy.memcached.v2.vo.SMGetElements; public class AsyncArcusCommands implements AsyncArcusCommandsIF { @@ -1536,4 +1541,123 @@ public void complete() { return future; } + + public ArcusFuture lopCreate(String key, ElementValueType type, + CollectionAttributes attributes) { + if (attributes == null) { + throw new IllegalArgumentException("CollectionAttributes cannot be null"); + } + + ListCreate create = new ListCreate(TranscoderUtils.examineFlags(type), + attributes.getExpireTime(), attributes.getMaxCount(), + attributes.getOverflowAction(), attributes.getReadable(), false); + return collectionCreate(key, create); + } + + public ArcusFuture lopInsert(String key, int index, T value) { + return lopInsert(key, index, value, null); + } + + public ArcusFuture lopInsert(String key, int index, T value, + CollectionAttributes attributes) { + ListInsert insert = new ListInsert<>(value, null, attributes); + return collectionInsert(key, String.valueOf(index), insert); + } + + public ArcusFuture lopGet(String key, int index, GetArgs args) { + AbstractArcusResult result = new AbstractArcusResult<>(new AtomicReference<>()); + ArcusFutureImpl future = new ArcusFutureImpl<>(result); + ListGet get = new ListGet(index, args.isWithDelete(), args.isDropIfEmpty()); + ArcusClient client = arcusClientSupplier.get(); + + CollectionGetOperation.Callback cb = new CollectionGetOperation.Callback() { + @Override + public void receivedStatus(OperationStatus status) { + switch (status.getStatusCode()) { + case SUCCESS: + break; + case ERR_NOT_FOUND: + case ERR_NOT_FOUND_ELEMENT: + result.set(null); + break; + case CANCELLED: + future.internalCancel(); + break; + default: + /* TYPE_MISMATCH / UNREADABLE / NOT_SUPPORTED or unknown statement */ + result.addError(key, status); + } + } + + @Override + public void complete() { + future.complete(); + } + + @Override + public void gotData(String subKey, int flags, byte[] data, byte[] eFlag) { + CachedData cachedData = new CachedData(flags, data, tcForCollection.getMaxSize()); + result.set(tcForCollection.decode(cachedData)); + } + }; + Operation op = client.getOpFact().collectionGet(key, get, cb); + future.setOp(op); + client.addOp(key, op); + + return future; + } + + public ArcusFuture> lopGet(String key, int from, int to, GetArgs args) { + AbstractArcusResult> result = + new AbstractArcusResult<>(new AtomicReference<>(new ArrayList<>())); + ArcusFutureImpl> future = new ArcusFutureImpl<>(result); + ListGet get = new ListGet(from, to, args.isWithDelete(), args.isDropIfEmpty()); + ArcusClient client = arcusClientSupplier.get(); + + CollectionGetOperation.Callback cb = new CollectionGetOperation.Callback() { + @Override + public void receivedStatus(OperationStatus status) { + switch (status.getStatusCode()) { + case SUCCESS: + case ERR_NOT_FOUND_ELEMENT: + break; + case ERR_NOT_FOUND: + result.set(null); + break; + case CANCELLED: + future.internalCancel(); + break; + default: + /* TYPE_MISMATCH / UNREADABLE / NOT_SUPPORTED or unknown statement */ + result.addError(key, status); + } + } + + @Override + public void complete() { + future.complete(); + } + + @Override + public void gotData(String subKey, int flags, byte[] data, byte[] eFlag) { + CachedData cachedData = new CachedData(flags, data, tcForCollection.getMaxSize()); + result.get().add(tcForCollection.decode(cachedData)); + } + }; + Operation op = client.getOpFact().collectionGet(key, get, cb); + future.setOp(op); + client.addOp(key, op); + + return future; + } + + public ArcusFuture lopDelete(String key, int index, boolean dropIfEmpty) { + ListDelete delete = new ListDelete(index, dropIfEmpty, false); + return collectionDelete(key, delete); + } + + public ArcusFuture lopDelete(String key, int from, int to, boolean dropIfEmpty) { + ListDelete delete = new ListDelete(from, to, dropIfEmpty, false); + return collectionDelete(key, delete); + } } diff --git a/src/main/java/net/spy/memcached/v2/AsyncArcusCommandsIF.java b/src/main/java/net/spy/memcached/v2/AsyncArcusCommandsIF.java index 2326a3c3c..ba5b1268f 100644 --- a/src/main/java/net/spy/memcached/v2/AsyncArcusCommandsIF.java +++ b/src/main/java/net/spy/memcached/v2/AsyncArcusCommandsIF.java @@ -30,6 +30,7 @@ import net.spy.memcached.v2.vo.BTreeUpdateElement; import net.spy.memcached.v2.vo.BopDeleteArgs; import net.spy.memcached.v2.vo.BopGetArgs; +import net.spy.memcached.v2.vo.GetArgs; import net.spy.memcached.v2.vo.SMGetElements; public interface AsyncArcusCommandsIF { @@ -73,7 +74,7 @@ public interface AsyncArcusCommandsIF { * @param value the new value to set if the CAS ID matches * @param casId the CAS ID obtained from {@link #gets(String)} * @return {@code Boolean.True} if compared and set successfully, - * {@code Boolean.False} if the key does not exist or CAS ID does not match + * {@code Boolean.False} if the key does not exist or CAS ID does not match */ ArcusFuture cas(String key, int exp, T value, long casId); @@ -99,7 +100,7 @@ public interface AsyncArcusCommandsIF { * Sets multiple key-value pairs. * * @param items map of keys and values to store - * @param exp expiration time in seconds + * @param exp expiration time in seconds * @return Map of key to Boolean result */ ArcusFuture> multiSet(Map items, int exp); @@ -108,7 +109,7 @@ public interface AsyncArcusCommandsIF { * Add multiple key-value pairs if they do not exist. * * @param items map of keys and values to store - * @param exp expiration time in seconds + * @param exp expiration time in seconds * @return Map of key to Boolean result */ ArcusFuture> multiAdd(Map items, int exp); @@ -117,7 +118,7 @@ public interface AsyncArcusCommandsIF { * Replace multiple key-value pairs if they exist. * * @param items map of keys and values to store - * @param exp expiration time in seconds + * @param exp expiration time in seconds * @return Map of key to Boolean result */ ArcusFuture> multiReplace(Map items, int exp); @@ -431,7 +432,7 @@ ArcusFuture> bopSortMergeGet(List keys, BKey from, BKey * ({@code delta} is ignored) (≥ 0) * @param eFlag eFlag of the element to create, or {@code null} if not needed * @return the new value after decrement, - * or {@code initial} if the element did not exist. + * or {@code initial} if the element did not exist. */ ArcusFuture bopDecr(String key, BKey bKey, int delta, long initial, byte[] eFlag); @@ -475,4 +476,82 @@ ArcusFuture> bopSortMergeGet(List keys, BKey from, BKey * or {@code null} if the key is not found. */ ArcusFuture bopCount(String key, BKey from, BKey to, ElementFlagFilter eFlagFilter); + + /** + * Create a list with the given attributes. + * + * @param key key of the list to create + * @param type element value type + * @param attributes initial attributes of the list + * @return {@code true} if created, {@code false} if the key already exists. + */ + ArcusFuture lopCreate(String key, ElementValueType type, + CollectionAttributes attributes); + + /** + * Insert an element at the given index into a list. + * + * @param key key of the list + * @param index index at which to insert the element + * @param value the value to insert + * @return {@code true} if the element was inserted, {@code null} if the key is not found. + */ + ArcusFuture lopInsert(String key, int index, T value); + + /** + * Insert an element at the given index into a list. + * If the list does not exist, it is created with the given attributes. + * + * @param key key of the list + * @param index index at which to insert the element + * @param value the value to insert + * @param attributes attributes to use when creating the list, or {@code null} to not create + * @return {@code true} if the element was inserted, {@code null} if the key is not found. + */ + ArcusFuture lopInsert(String key, int index, T value, CollectionAttributes attributes); + + /** + * Get an element at the given index from a list. + * + * @param key key of the list + * @param index index of the element to get + * @param args arguments for get operation + * @return the element value, {@code null} if the key or element is not found. + */ + ArcusFuture lopGet(String key, int index, GetArgs args); + + /** + * Get elements in an index range from a list. + * + * @param key key of the list + * @param from index range start (inclusive) + * @param to index range end (inclusive) + * @param args arguments for get operation + * @return list of element values in order, an empty list if no elements are found in the range, + * {@code null} if the key is not found. + */ + ArcusFuture> lopGet(String key, int from, int to, GetArgs args); + + /** + * Delete an element at the given index from a list. + * + * @param key key of the list + * @param index index of the element to delete + * @param dropIfEmpty whether to delete the list if it becomes empty after deletion + * @return {@code true} if the element was deleted, {@code null} if the key is not found, + * {@code false} if the element is not found. + */ + ArcusFuture lopDelete(String key, int index, boolean dropIfEmpty); + + /** + * Delete elements in an index range from a list. + * + * @param key key of the list + * @param from index range start (inclusive) + * @param to index range end (inclusive) + * @param dropIfEmpty whether to delete the list if it becomes empty after deletion + * @return {@code true} if at least one element was deleted, {@code null} if the key is not found, + * {@code false} if no elements are found in the range. + */ + ArcusFuture lopDelete(String key, int from, int to, boolean dropIfEmpty); } diff --git a/src/main/java/net/spy/memcached/v2/vo/GetArgs.java b/src/main/java/net/spy/memcached/v2/vo/GetArgs.java new file mode 100644 index 000000000..f5e1f1d52 --- /dev/null +++ b/src/main/java/net/spy/memcached/v2/vo/GetArgs.java @@ -0,0 +1,42 @@ +package net.spy.memcached.v2.vo; + +public final class GetArgs { + + public static final GetArgs DEFAULT = new GetArgs.Builder().build(); + + private final boolean withDelete; + private final boolean dropIfEmpty; + + public boolean isWithDelete() { + return withDelete; + } + + public boolean isDropIfEmpty() { + return dropIfEmpty; + } + + private GetArgs(boolean withDelete, boolean dropIfEmpty) { + this.withDelete = withDelete; + this.dropIfEmpty = dropIfEmpty; + } + + public static class Builder { + private boolean withDelete = false; + private boolean dropIfEmpty = false; + + public Builder withDelete() { + this.withDelete = true; + return this; + } + + public Builder dropIfEmpty() { + this.dropIfEmpty = true; + return this; + } + + public GetArgs build() { + return new GetArgs(withDelete, dropIfEmpty); + } + } + +} diff --git a/src/test/java/net/spy/memcached/v2/ListAsyncArcusCommandsTest.java b/src/test/java/net/spy/memcached/v2/ListAsyncArcusCommandsTest.java new file mode 100644 index 000000000..6db6b67b6 --- /dev/null +++ b/src/test/java/net/spy/memcached/v2/ListAsyncArcusCommandsTest.java @@ -0,0 +1,368 @@ +package net.spy.memcached.v2; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import net.spy.memcached.collection.CollectionAttributes; +import net.spy.memcached.collection.ElementValueType; +import net.spy.memcached.ops.OperationException; +import net.spy.memcached.v2.vo.GetArgs; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ListAsyncArcusCommandsTest extends AsyncArcusCommandsTest { + + private static final List VALUES = Arrays.asList("v0", "v1", "v2", "v3", "v4"); + + @Test + void lopCreate() throws Exception { + // given + String key = keys.get(0); + + // when + async.lopCreate(key, ElementValueType.STRING, new CollectionAttributes()) + // then + .thenAccept(Assertions::assertTrue) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopCreateAlreadyExists() throws Exception { + // given + String key = keys.get(0); + + async.lopCreate(key, ElementValueType.STRING, new CollectionAttributes()) + .thenAccept(Assertions::assertTrue) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopCreate(key, ElementValueType.STRING, new CollectionAttributes()) + // then + .thenAccept(Assertions::assertFalse) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopInsert() throws Exception { + // given + String key = keys.get(0); + + async.lopCreate(key, ElementValueType.STRING, new CollectionAttributes()) + .thenAccept(Assertions::assertTrue) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopInsert(key, 0, VALUES.get(0)) + // then + .thenAccept(Assertions::assertTrue) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopInsertNotFound() throws Exception { + // given + // when + async.lopInsert(keys.get(0), 0, VALUES.get(0)) + // then + .thenAccept(Assertions::assertNull) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopInsertWithAttributes() throws Exception { + // given + String key = keys.get(0); + + // when + async.lopInsert(key, 0, VALUES.get(0), new CollectionAttributes()) + // then + .thenCompose(result -> { + assertTrue(result); + return async.lopGet(key, 0, GetArgs.DEFAULT); + }) + .thenAccept(result -> assertEquals(VALUES.get(0), result)) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopInsertTypeMismatch() throws Exception { + // given + String key = keys.get(0); + + async.set(key, 0, VALUE) + .thenAccept(Assertions::assertTrue) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopInsert(key, 0, VALUES.get(0), new CollectionAttributes()) + // then + .handle((result, ex) -> { + assertInstanceOf(OperationException.class, ex); + assertTrue(ex.getMessage().contains("TYPE_MISMATCH")); + return result; + }) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopGetSingle() throws Exception { + // given + String key = keys.get(0); + + async.lopInsert(key, 0, VALUES.get(0), new CollectionAttributes()) + .thenAccept(Assertions::assertTrue) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopGet(key, 0, GetArgs.DEFAULT) + // then + .thenAccept(result -> assertEquals(VALUES.get(0), result)) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopGetSingleNotFound() throws Exception { + // given + // when + async.lopGet(keys.get(0), 0, GetArgs.DEFAULT) + // then + .thenAccept(Assertions::assertNull) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopGetSingleNotFoundElement() throws Exception { + // given + String key = keys.get(0); + + async.lopCreate(key, ElementValueType.STRING, new CollectionAttributes()) + .thenAccept(Assertions::assertTrue) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopGet(key, 5, GetArgs.DEFAULT) + // then + .thenAccept(Assertions::assertNull) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopGetSingleWithDelete() throws Exception { + // given + String key = keys.get(0); + GetArgs args = new GetArgs.Builder() + .withDelete() + .dropIfEmpty() + .build(); + + async.lopInsert(key, 0, VALUES.get(0), new CollectionAttributes()) + .thenAccept(Assertions::assertTrue) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopGet(key, 0, args) + // then + .thenCompose(result -> { + assertEquals(VALUES.get(0), result); + return async.lopGet(key, 0, GetArgs.DEFAULT); + }) + .thenAccept(Assertions::assertNull) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopGetRange() throws Exception { + // given + String key = keys.get(0); + + async.lopInsert(key, 0, VALUES.get(0), new CollectionAttributes()) + .thenCompose(result -> async.lopInsert(key, 1, VALUES.get(1))) + .thenCompose(result -> async.lopInsert(key, 2, VALUES.get(2))) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopGet(key, 0, 2, GetArgs.DEFAULT) + // then + .thenAccept(result -> { + result.forEach(Assertions::assertNotNull); + assertIterableEquals(VALUES.subList(0, 3), result); + }) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopGetRangeNotFound() throws Exception { + // given + // when + async.lopGet(keys.get(0), 0, 2, GetArgs.DEFAULT) + // then + .thenAccept(Assertions::assertNull) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopGetRangeNotFoundElement() throws Exception { + // given + String key = keys.get(0); + + async.lopCreate(key, ElementValueType.STRING, new CollectionAttributes()) + .thenAccept(Assertions::assertTrue) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopGet(key, 5, 10, GetArgs.DEFAULT) + // then + .thenAccept(result -> assertTrue(result.isEmpty())) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopGetRangeWithDeleteAndDropIfEmpty() throws Exception { + // given + String key = keys.get(0); + GetArgs args = new GetArgs.Builder() + .withDelete() + .dropIfEmpty() + .build(); + + async.lopInsert(key, 0, VALUES.get(0), new CollectionAttributes()) + .thenCompose(result -> async.lopInsert(key, 1, VALUES.get(1))) + .thenCompose(result -> async.lopInsert(key, 2, VALUES.get(2))) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopGet(key, 0, 2, args) + .thenCompose(result -> { + assertNotNull(result); + assertIterableEquals(VALUES.subList(0, 3), result); + return async.lopGet(key, 0, 2, GetArgs.DEFAULT); + }) + .thenAccept(Assertions::assertNull) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopDeleteSuccess() throws Exception { + // given + String key = keys.get(0); + + async.lopInsert(key, 0, VALUES.get(0), new CollectionAttributes()) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopDelete(key, 0, false) + // then + .thenCompose(result -> { + assertTrue(result); + return async.lopGet(key, 0, GetArgs.DEFAULT); + }) + .thenAccept(Assertions::assertNull) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopDeleteRangeSuccess() throws Exception { + // given + String key = keys.get(0); + + async.lopInsert(key, 0, VALUES.get(0), new CollectionAttributes()) + .thenCompose(result -> async.lopInsert(key, 1, VALUES.get(1))) + .thenCompose(result -> async.lopInsert(key, 2, VALUES.get(2))) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopDelete(key, 0, 2, false) + // then + .thenCompose(result -> { + assertTrue(result); + return async.lopGet(key, 0, 2, GetArgs.DEFAULT); + }) + .thenAccept(result -> assertTrue(result.isEmpty())) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopDeleteNotFoundElement() throws Exception { + // given + String key = keys.get(0); + + async.lopCreate(key, ElementValueType.STRING, new CollectionAttributes()) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopDelete(key, 0, false) + // then + .thenAccept(Assertions::assertFalse) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopDeleteNotFound() throws Exception { + // when + async.lopDelete(keys.get(0), 0, false) + // then + .thenAccept(Assertions::assertNull) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + + @Test + void lopDeleteTypeMismatch() throws Exception { + // given + String key = keys.get(0); + + async.set(key, 0, VALUE) + .thenAccept(Assertions::assertTrue) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + + // when + async.lopDelete(key, 0, false) + // then + .handle((result, ex) -> { + assertInstanceOf(OperationException.class, ex); + assertTrue(ex.getMessage().contains("TYPE_MISMATCH")); + return result; + }) + .toCompletableFuture() + .get(300L, TimeUnit.MILLISECONDS); + } + +}