Skip to content

Commit

Permalink
feat: implement lpush and lrange (#7)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
malandis authored Sep 4, 2024
1 parent 402b0c2 commit 6241437
Show file tree
Hide file tree
Showing 7 changed files with 238 additions and 3 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
65 changes: 63 additions & 2 deletions src/main/java/momento/lettuce/MomentoRedisReactiveClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -1493,7 +1497,29 @@ public Flux<Long> lpos(K k, V v, int i, LPosArgs lPosArgs) {

@Override
public Mono<Long> 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
Expand All @@ -1503,7 +1529,42 @@ public Mono<Long> lpushx(K k, V... vs) {

@Override
public Flux<V> 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<List<V>> mono =
Mono.fromFuture(responseFuture)
.flatMap(
response -> {
if (response instanceof ListFetchResponse.Hit hit) {
List<V> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -15,6 +16,10 @@
public interface MomentoRedisReactiveCommands<K, V> {
Mono<V> get(K k);

Mono<Long> lpush(K k, V... vs);

Flux<V> lrange(K k, long l, long l1);

Mono<String> set(K k, V v);

Mono<Boolean> pexpire(K k, long l);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
}
}
9 changes: 9 additions & 0 deletions src/main/java/momento/lettuce/utils/ValidatorUtils.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
132 changes: 132 additions & 0 deletions src/test/java/momento/lettuce/ListTest.java
Original file line number Diff line number Diff line change
@@ -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<String> reverseList(List<String> list) {
List<String> 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());
}
}
7 changes: 7 additions & 0 deletions src/test/java/momento/lettuce/TestUtils.java
Original file line number Diff line number Diff line change
@@ -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<String> generateListOfRandomStrings(int size) {
return IntStream.range(0, size).mapToObj(i -> randomString()).collect(Collectors.toList());
}
}

0 comments on commit 6241437

Please sign in to comment.