From 624143711814776a8e92a3afec50ff9c821cba6b Mon Sep 17 00:00:00 2001 From: Michael Landis Date: Wed, 4 Sep 2024 11:00:44 -0700 Subject: [PATCH] feat: implement lpush and lrange (#7) Implements the list commands `lpush` and `lrange`. A limitation on `lrange` is that while Redis allows for `long` offsets into the list, Momento expects integer offsets. Therefore we test if the offset is out of range and throw an error in that event. --- build.gradle.kts | 2 +- .../lettuce/MomentoRedisReactiveClient.java | 65 ++++++++- .../lettuce/MomentoRedisReactiveCommands.java | 5 + .../MomentoToLettuceExceptionMapper.java | 21 +++ .../momento/lettuce/utils/ValidatorUtils.java | 9 ++ src/test/java/momento/lettuce/ListTest.java | 132 ++++++++++++++++++ src/test/java/momento/lettuce/TestUtils.java | 7 + 7 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 src/main/java/momento/lettuce/utils/ValidatorUtils.java create mode 100644 src/test/java/momento/lettuce/ListTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 4be0ce4..5eb49ae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,7 +13,7 @@ repositories { dependencies { implementation("io.lettuce:lettuce-core:6.4.0.RELEASE") - implementation("software.momento.java:sdk:1.14.1") + implementation("software.momento.java:sdk:1.15.0") testImplementation(platform("org.junit:junit-bom:5.10.0")) testImplementation("org.junit.jupiter:junit-jupiter") diff --git a/src/main/java/momento/lettuce/MomentoRedisReactiveClient.java b/src/main/java/momento/lettuce/MomentoRedisReactiveClient.java index 8fbc763..be6027a 100644 --- a/src/main/java/momento/lettuce/MomentoRedisReactiveClient.java +++ b/src/main/java/momento/lettuce/MomentoRedisReactiveClient.java @@ -77,6 +77,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; @@ -87,10 +88,13 @@ import momento.lettuce.utils.MomentoToLettuceExceptionMapper; import momento.lettuce.utils.RedisCodecByteArrayConverter; import momento.lettuce.utils.RedisResponse; +import momento.lettuce.utils.ValidatorUtils; import momento.sdk.CacheClient; import momento.sdk.responses.cache.DeleteResponse; import momento.sdk.responses.cache.GetResponse; import momento.sdk.responses.cache.SetResponse; +import momento.sdk.responses.cache.list.ListConcatenateFrontResponse; +import momento.sdk.responses.cache.list.ListFetchResponse; import momento.sdk.responses.cache.ttl.UpdateTtlResponse; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -1493,7 +1497,29 @@ public Flux lpos(K k, V v, int i, LPosArgs lPosArgs) { @Override public Mono lpush(K k, V... vs) { - return null; + var encodedValues = + Arrays.stream(vs).map(codec::encodeValueToBytes).collect(Collectors.toList()); + + // Because Redis implements lpush as a reduction over left push, we need to reverse the order of + // the values + // before concatenating. + Collections.reverse(encodedValues); + + var responseFuture = + client.listConcatenateFrontByteArray(cacheName, codec.encodeKeyToBytes(k), encodedValues); + return Mono.fromFuture(responseFuture) + .flatMap( + response -> { + if (response instanceof ListConcatenateFrontResponse.Success success) { + return Mono.just((long) success.getListLength()); + } else if (response instanceof ListConcatenateFrontResponse.Error error) { + return Mono.error(MomentoToLettuceExceptionMapper.mapException(error)); + } else { + return Mono.error( + MomentoToLettuceExceptionMapper.createUnexpectedResponseException( + response.toString())); + } + }); } @Override @@ -1503,7 +1529,42 @@ public Mono lpushx(K k, V... vs) { @Override public Flux lrange(K k, long l, long l1) { - return null; + ValidatorUtils.ensureInIntegerRange(l, "l"); + ValidatorUtils.ensureInIntegerRange(l1, "l1"); + Integer start = (int) l; + Integer end = (int) l1; + + // Since the Redis end offset is inclusive, we need to increment it by 1. + // That is, unless it refers to "end of list" (-1), in which case we pass null to Momento. + if (end == -1) { + end = null; + } else { + end++; + } + + var responseFuture = client.listFetch(cacheName, codec.encodeKeyToBytes(k), start, end); + Mono> mono = + Mono.fromFuture(responseFuture) + .flatMap( + response -> { + if (response instanceof ListFetchResponse.Hit hit) { + List result = + hit.valueListByteArray().stream() + .map(codec::decodeValueFromBytes) + .collect(Collectors.toList()); + return Mono.just(result); + + } else if (response instanceof ListFetchResponse.Miss) { + return Mono.just(Collections.emptyList()); + } else if (response instanceof ListFetchResponse.Error error) { + return Mono.error(MomentoToLettuceExceptionMapper.mapException(error)); + } else { + return Mono.error( + MomentoToLettuceExceptionMapper.createUnexpectedResponseException( + response.toString())); + } + }); + return mono.flatMapMany(Flux::fromIterable); } @Override diff --git a/src/main/java/momento/lettuce/MomentoRedisReactiveCommands.java b/src/main/java/momento/lettuce/MomentoRedisReactiveCommands.java index 1a39fa8..fc409a1 100644 --- a/src/main/java/momento/lettuce/MomentoRedisReactiveCommands.java +++ b/src/main/java/momento/lettuce/MomentoRedisReactiveCommands.java @@ -3,6 +3,7 @@ import io.lettuce.core.ExpireArgs; import io.lettuce.core.api.reactive.RedisReactiveCommands; import java.time.Duration; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** @@ -15,6 +16,10 @@ public interface MomentoRedisReactiveCommands { Mono get(K k); + Mono lpush(K k, V... vs); + + Flux lrange(K k, long l, long l1); + Mono set(K k, V v); Mono pexpire(K k, long l); diff --git a/src/main/java/momento/lettuce/utils/MomentoToLettuceExceptionMapper.java b/src/main/java/momento/lettuce/utils/MomentoToLettuceExceptionMapper.java index 98f4736..c712e52 100644 --- a/src/main/java/momento/lettuce/utils/MomentoToLettuceExceptionMapper.java +++ b/src/main/java/momento/lettuce/utils/MomentoToLettuceExceptionMapper.java @@ -3,6 +3,7 @@ import io.lettuce.core.RedisCommandExecutionException; import io.lettuce.core.RedisCommandTimeoutException; import io.lettuce.core.RedisException; +import momento.sdk.exceptions.InvalidArgumentException; import momento.sdk.exceptions.SdkException; /** Maps Momento SDK exceptions to Lettuce exceptions. */ @@ -60,4 +61,24 @@ public static UnsupportedOperationException createArgumentNotSupportedException( return new UnsupportedOperationException( "Argument not supported for command " + commandName + ": " + argumentName); } + + /** + * Creates a Lettuce exception in the event an argument is out of range. + * + * @param argumentName The name of the parameter. + * @param value The value that was out of range. + * @return The Lettuce exception. + */ + public static InvalidArgumentException createIntegerOutOfRangeException( + String argumentName, long value) { + return new InvalidArgumentException( + "Argument out of range: " + + argumentName + + " must be between " + + Integer.MIN_VALUE + + " and " + + Integer.MAX_VALUE + + ", but was " + + value); + } } diff --git a/src/main/java/momento/lettuce/utils/ValidatorUtils.java b/src/main/java/momento/lettuce/utils/ValidatorUtils.java new file mode 100644 index 0000000..6e91658 --- /dev/null +++ b/src/main/java/momento/lettuce/utils/ValidatorUtils.java @@ -0,0 +1,9 @@ +package momento.lettuce.utils; + +public class ValidatorUtils { + public static void ensureInIntegerRange(long value, String argumentName) { + if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + throw MomentoToLettuceExceptionMapper.createIntegerOutOfRangeException(argumentName, value); + } + } +} diff --git a/src/test/java/momento/lettuce/ListTest.java b/src/test/java/momento/lettuce/ListTest.java new file mode 100644 index 0000000..f4b764e --- /dev/null +++ b/src/test/java/momento/lettuce/ListTest.java @@ -0,0 +1,132 @@ +package momento.lettuce; + +import static momento.lettuce.TestUtils.generateListOfRandomStrings; +import static momento.lettuce.TestUtils.randomString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import momento.sdk.exceptions.InvalidArgumentException; +import org.junit.jupiter.api.Test; + +final class ListTest extends BaseTestClass { + private static List reverseList(List list) { + List reversedList = new ArrayList<>(list); + Collections.reverse(reversedList); + return reversedList; + } + + @Test + public void testLPushHappyPath() { + var key = randomString(); + var values = generateListOfRandomStrings(3); + var lPushResponse = client.lpush(key, values.toArray(new String[0])).block(); + assertEquals(3, lPushResponse); + } + + @Test + public void testLRangeHappyPath() { + var key = randomString(); + var values = generateListOfRandomStrings(5); + var valuesReversed = reverseList(values); + + var lPushResponse = client.lpush(key, values.toArray(new String[0])).block(); + assertEquals(5, lPushResponse); + + // Fetch the whole list + var lRangeResponse = client.lrange(key, 0, -1).collectList().block(); + assertEquals(5, lRangeResponse.size()); + + // The values backwards should be the same as the original values due to the reduce semantics of + // LPUSH + assertEquals(valuesReversed, lRangeResponse); + + // Test positive offsets + lRangeResponse = client.lrange(key, 2, 4).collectList().block(); + assertEquals(3, lRangeResponse.size()); + assertEquals(valuesReversed.subList(2, 5), lRangeResponse); + + // Test negative offsets + lRangeResponse = client.lrange(key, -3, -1).collectList().block(); + assertEquals(3, lRangeResponse.size()); + assertEquals(valuesReversed.subList(2, 5), lRangeResponse); + } + + @Test + public void LRangeOffsetsNotInIntegerRangeTest() { + // While Lettuce accepts longs for the offsets, Momento only supports integers. + // Thus we should throw an exception if the offsets are out of integer range. + if (isRedisTest()) { + return; + } + + // Test exception thrown when the offsets are out of integer range + var key = randomString(); + var values = generateListOfRandomStrings(5); + + var lPushResponse = client.lpush(key, values.toArray(new String[0])).block(); + assertEquals(5, lPushResponse); + + long lessThanIntegerMin = (long) Integer.MIN_VALUE - 1; + long moreThanIntegerMax = (long) Integer.MAX_VALUE + 1; + assertThrows( + InvalidArgumentException.class, + () -> client.lrange(key, lessThanIntegerMin, 1).collectList().block()); + assertThrows( + InvalidArgumentException.class, + () -> client.lrange(key, 1, moreThanIntegerMax).collectList().block()); + } + + @Test + public void testLPushMultipleTimes() { + var key = randomString(); + var values = generateListOfRandomStrings(3); + var valuesReversed = reverseList(values); + var lPushResponse = client.lpush(key, values.toArray(new String[0])).block(); + assertEquals(3, lPushResponse); + + // Push a new list of values + var newValues = generateListOfRandomStrings(3); + var newValuesReversed = reverseList(newValues); + lPushResponse = client.lpush(key, newValues.toArray(new String[0])).block(); + assertEquals(6, lPushResponse); + + // Verify the list is the concatenation of the two lists in reverse order + var lRangeResponse = client.lrange(key, 0, -1).collectList().block(); + assertEquals(6, lRangeResponse.size()); + // should be newValuesReversed + valuesReversed; make a new list with this order + var expectedValues = new ArrayList<>(newValuesReversed); + expectedValues.addAll(valuesReversed); + assertEquals(expectedValues, lRangeResponse); + } + + @Test + public void pExpireWorksOnListValues() { + // Add a list to the cache + var key = randomString(); + var values = generateListOfRandomStrings(3); + var lPushResponse = client.lpush(key, values.toArray(new String[0])).block(); + assertEquals(3, lPushResponse); + + // Verify it's there with lrange + var lRangeResponse = client.lrange(key, 0, -1).collectList().block(); + assertEquals(3, lRangeResponse.size()); + + // Set the expiry so low it will expire before we can check it + var pExpireResponse = client.pexpire(key, 1).block(); + assertEquals(true, pExpireResponse); + + // Wait for the key to expire + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + // Verify it's gone + lRangeResponse = client.lrange(key, 0, -1).collectList().block(); + assertEquals(0, lRangeResponse.size()); + } +} diff --git a/src/test/java/momento/lettuce/TestUtils.java b/src/test/java/momento/lettuce/TestUtils.java index 0fa4bff..27c3b0b 100644 --- a/src/test/java/momento/lettuce/TestUtils.java +++ b/src/test/java/momento/lettuce/TestUtils.java @@ -1,9 +1,16 @@ package momento.lettuce; +import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.IntStream; public class TestUtils { public static String randomString() { return UUID.randomUUID().toString(); } + + public static List generateListOfRandomStrings(int size) { + return IntStream.range(0, size).mapToObj(i -> randomString()).collect(Collectors.toList()); + } }