item, String idempotencyKey) {
+ String hashKey = getKey(idempotencyKey);
+ String prependedInProgressExpiryAttr = item.get(prependField(hashKey, this.inProgressExpiryAttr));
+ return new DataRecord(item.get(getKey(idempotencyKey)),
+ DataRecord.Status.valueOf(item.get(prependField(hashKey, this.statusAttr))),
+ Long.parseLong(item.get(prependField(hashKey, this.expiryAttr))),
+ item.get(prependField(hashKey, this.dataAttr)),
+ item.get(prependField(hashKey, this.validationAttr)),
+ prependedInProgressExpiryAttr != null && !prependedInProgressExpiryAttr.isEmpty() ?
+ OptionalLong.of(Long.parseLong(prependedInProgressExpiryAttr)) :
+ OptionalLong.empty());
+ }
+
+ /**
+ * Use this builder to get an instance of {@link RedisPersistenceStore}.
+ * With this builder you can configure the characteristics of the Redis hash fields.
+ * You can also set a custom {@link UnifiedJedis} client.
+ */
+ public static class Builder {
+
+ private JedisConfig jedisConfig = JedisConfig.Builder.builder().build();
+ private String keyPrefixName = "idempotency";
+ private String keyAttr = "id";
+ private String expiryAttr = "expiration";
+ private String inProgressExpiryAttr = "in-progress-expiration";
+ private String statusAttr = "status";
+ private String dataAttr = "data";
+ private String validationAttr = "validation";
+ private UnifiedJedis jedisClient;
+
+ /**
+ * Initialize and return a new instance of {@link RedisPersistenceStore}.
+ * Example:
+ *
+ * RedisPersistenceStore.builder().withKeyAttr("uuid").build();
+ *
+ *
+ * @return an instance of the {@link RedisPersistenceStore}
+ */
+ public RedisPersistenceStore build() {
+ return new RedisPersistenceStore(jedisConfig, keyPrefixName, keyAttr, expiryAttr,
+ inProgressExpiryAttr, statusAttr, dataAttr, validationAttr, jedisClient);
+ }
+
+ /**
+ * Redis prefix for the hash key (optional), by default "idempotency"
+ *
+ * @param keyPrefixName name of the key prefix
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withKeyPrefixName(String keyPrefixName) {
+ this.keyPrefixName = keyPrefixName;
+ return this;
+ }
+
+ /**
+ * Redis name for hash key (optional), by default "id"
+ *
+ * @param keyAttr name of the key field of the hash
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withKeyAttr(String keyAttr) {
+ this.keyAttr = keyAttr;
+ return this;
+ }
+
+ /**
+ * Redis field name for expiry timestamp (optional), by default "expiration"
+ *
+ * @param expiryAttr name of the expiry field in the hash
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withExpiryAttr(String expiryAttr) {
+ this.expiryAttr = expiryAttr;
+ return this;
+ }
+
+ /**
+ * Redis field name for in progress expiry timestamp (optional), by default "in-progress-expiration"
+ *
+ * @param inProgressExpiryAttr name of the field in the hash
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withInProgressExpiryAttr(String inProgressExpiryAttr) {
+ this.inProgressExpiryAttr = inProgressExpiryAttr;
+ return this;
+ }
+
+ /**
+ * Redis field name for status (optional), by default "status"
+ *
+ * @param statusAttr name of the status field in the hash
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withStatusAttr(String statusAttr) {
+ this.statusAttr = statusAttr;
+ return this;
+ }
+
+ /**
+ * Redis field name for response data (optional), by default "data"
+ *
+ * @param dataAttr name of the data field in the hash
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withDataAttr(String dataAttr) {
+ this.dataAttr = dataAttr;
+ return this;
+ }
+
+ /**
+ * Redis field name for validation (optional), by default "validation"
+ *
+ * @param validationAttr name of the validation field in the hash
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withValidationAttr(String validationAttr) {
+ this.validationAttr = validationAttr;
+ return this;
+ }
+
+ /**
+ * Custom {@link UnifiedJedis} used to query Redis (optional).
+ * This will be cast to either {@link JedisPool} or {@link JedisCluster}
+ * depending on the mode of the Redis deployment and instructed by
+ * the value of {@link Constants#REDIS_CLUSTER_MODE} environment variable.
+ *
+ * @param jedisClient the {@link UnifiedJedis} instance to use
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withJedisClient(UnifiedJedis jedisClient) {
+ this.jedisClient = jedisClient;
+ return this;
+ }
+
+
+ /**
+ * Custom {@link JedisConfig} used to configure the Redis client(optional)
+ *
+ * @param jedisConfig
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withJedisConfig(JedisConfig jedisConfig) {
+ this.jedisConfig = jedisConfig;
+ return this;
+ }
+ }
+}
diff --git a/powertools-idempotency/powertools-idempotency-redis/src/main/resources/putRecordOnCondition.lua b/powertools-idempotency/powertools-idempotency-redis/src/main/resources/putRecordOnCondition.lua
new file mode 100644
index 000000000..cbfd01ba0
--- /dev/null
+++ b/powertools-idempotency/powertools-idempotency-redis/src/main/resources/putRecordOnCondition.lua
@@ -0,0 +1,19 @@
+local hashKey = KEYS[1]
+local expiryKey = KEYS[2]
+local statusKey = KEYS[3]
+local inProgressExpiryKey = KEYS[4]
+local timeNowSeconds = ARGV[1]
+local timeNowMillis = ARGV[2]
+local inProgressValue = ARGV[3]
+local expiryValue = ARGV[4]
+local statusValue = ARGV[5]
+local inProgressExpiryValue = ''
+
+if ARGV[6] ~= nil then inProgressExpiryValue = ARGV[6] end;
+
+if redis.call('exists', hashKey) == 0
+ or redis.call('hget', hashKey, expiryKey) < timeNowSeconds
+ or (redis.call('hexists', hashKey, inProgressExpiryKey) ~= 0
+ and redis.call('hget', hashKey, inProgressExpiryKey) < timeNowMillis
+ and redis.call('hget', hashKey, statusKey) == inProgressValue)
+then return redis.call('hset', hashKey, expiryKey, expiryValue, statusKey, statusValue, inProgressExpiryKey, inProgressExpiryValue) end;
diff --git a/powertools-idempotency/powertools-idempotency-redis/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/redis/RedisPersistenceStoreTest.java b/powertools-idempotency/powertools-idempotency-redis/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/redis/RedisPersistenceStoreTest.java
new file mode 100644
index 000000000..e65a58014
--- /dev/null
+++ b/powertools-idempotency/powertools-idempotency-redis/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/redis/RedisPersistenceStoreTest.java
@@ -0,0 +1,448 @@
+/*
+ * Copyright 2023 Amazon.com, Inc. or its affiliates.
+ * Licensed under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package software.amazon.lambda.powertools.idempotency.persistence.redis;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.github.fppt.jedismock.server.ServiceOptions;
+import java.io.IOException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.OptionalLong;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junitpioneer.jupiter.SetEnvironmentVariable;
+import redis.clients.jedis.DefaultJedisClientConfig;
+import redis.clients.jedis.JedisClientConfig;
+import redis.clients.jedis.JedisCluster;
+import redis.clients.jedis.JedisPooled;
+import redis.embedded.RedisServer;
+import software.amazon.lambda.powertools.idempotency.IdempotencyConfig;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemAlreadyExistsException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemNotFoundException;
+import software.amazon.lambda.powertools.idempotency.persistence.DataRecord;
+
+@SetEnvironmentVariable(key = "REDIS_HOST", value = "localhost")
+@SetEnvironmentVariable(key = "REDIS_PORT", value = "6379")
+public class RedisPersistenceStoreTest {
+ static RedisServer redisServer;
+ private final JedisPooled jedisPool = new JedisPooled();
+ private final RedisPersistenceStore redisPersistenceStore = RedisPersistenceStore.builder().build();
+
+ @BeforeAll
+ public static void init() {
+
+ redisServer = RedisServer.builder().build();
+ redisServer.start();
+ }
+
+ @AfterAll
+ public static void stop() {
+ redisServer.stop();
+ }
+
+ @Test
+ void putRecord_shouldCreateItemInRedis() {
+ Instant now = Instant.now();
+ long ttl = 3600;
+ long expiry = now.plus(ttl, ChronoUnit.SECONDS).getEpochSecond();
+ redisPersistenceStore.putRecord(new DataRecord("key", DataRecord.Status.COMPLETED, expiry, null, null), now);
+
+ Map entry = jedisPool.hgetAll("{idempotency:id:key}");
+ long ttlInRedis = jedisPool.ttl("{idempotency:id:key}");
+
+ assertThat(entry).isNotNull();
+ assertThat(entry.get("{idempotency:id:key}:status")).isEqualTo("COMPLETED");
+ assertThat(entry.get("{idempotency:id:key}:expiration")).isEqualTo(String.valueOf(expiry));
+ assertThat(Math.round(ttlInRedis / 100.0) * 100).isEqualTo(ttl);
+ }
+
+ @Test
+ void putRecord_shouldCreateItemInRedisWithCustomJedisConfig() {
+
+ Instant now = Instant.now();
+ long ttl = 3600;
+ long expiry = now.plus(ttl, ChronoUnit.SECONDS).getEpochSecond();
+ RedisPersistenceStore store = new RedisPersistenceStore.Builder()
+ .withJedisConfig(JedisConfig.Builder.builder().withJedisClientConfig(
+ DefaultJedisClientConfig.builder().build()).build())
+ .build();
+
+ store.putRecord(new DataRecord("key", DataRecord.Status.COMPLETED, expiry, null, null), now);
+
+ Map entry = jedisPool.hgetAll("{idempotency:id:key}");
+ long ttlInRedis = jedisPool.ttl("{idempotency:id:key}");
+
+ assertThat(entry).isNotNull();
+ assertThat(entry.get("{idempotency:id:key}:status")).isEqualTo("COMPLETED");
+ assertThat(entry.get("{idempotency:id:key}:expiration")).isEqualTo(String.valueOf(expiry));
+ assertThat(Math.round(ttlInRedis / 100.0) * 100).isEqualTo(ttl);
+ }
+
+ @Test
+ void putRecord_shouldCreateItemInRedisClusterMode() throws IOException {
+ com.github.fppt.jedismock.RedisServer redisCluster = com.github.fppt.jedismock.RedisServer
+ .newRedisServer()
+ .setOptions(ServiceOptions.defaultOptions().withClusterModeEnabled())
+ .start();
+ Instant now = Instant.now();
+ long ttl = 3600;
+ long expiry = now.plus(ttl, ChronoUnit.SECONDS).getEpochSecond();
+ JedisPooled jp = new JedisPooled(redisCluster.getHost(), redisCluster.getBindPort());
+ RedisPersistenceStore store = new RedisPersistenceStore.Builder().withJedisClient(jp).build();
+
+ store.putRecord(new DataRecord("key", DataRecord.Status.COMPLETED, expiry, null, null), now);
+
+ Map entry = jp.hgetAll("{idempotency:id:key}");
+ long ttlInRedis = jp.ttl("{idempotency:id:key}");
+
+ assertThat(entry).isNotNull();
+ assertThat(entry.get("{idempotency:id:key}:status")).isEqualTo("COMPLETED");
+ assertThat(entry.get("{idempotency:id:key}:expiration")).isEqualTo(String.valueOf(expiry));
+ assertThat(Math.round(ttlInRedis / 100.0) * 100).isEqualTo(ttl);
+ }
+
+ @SetEnvironmentVariable(key = Constants.REDIS_CLUSTER_MODE, value = "true")
+ @Test
+ void putRecord_JedisClientInstanceOfJedisCluster() throws IOException {
+ com.github.fppt.jedismock.RedisServer redisCluster = com.github.fppt.jedismock.RedisServer
+ .newRedisServer()
+ .setOptions(ServiceOptions.defaultOptions().withClusterModeEnabled())
+ .start();
+ JedisConfig jedisConfig = JedisConfig.Builder.builder()
+ .withHost(redisCluster.getHost())
+ .withPort(redisCluster.getBindPort())
+ .withJedisClientConfig(DefaultJedisClientConfig.builder()
+ .user("default")
+ .password("")
+ .ssl(false)
+ .build())
+ .build();
+ assertThat(redisPersistenceStore.getJedisClient(jedisConfig) instanceof JedisCluster).isTrue();
+ redisCluster.stop();
+ }
+
+ @SetEnvironmentVariable(key = Constants.REDIS_CLUSTER_MODE, value = "false")
+ @Test
+ void putRecord_JedisClientInstanceOfJedisPooled() {
+ JedisConfig jedisConfig = JedisConfig.Builder.builder()
+ .withHost(System.getenv("REDIS_HOST"))
+ .withPort(Integer.parseInt(System.getenv("REDIS_PORT")))
+ .withJedisClientConfig(DefaultJedisClientConfig.builder().build())
+ .build();
+ assertThat(redisPersistenceStore.getJedisClient(jedisConfig) instanceof JedisCluster).isFalse();
+ }
+
+ @Test
+ void putRecord_shouldCreateItemInRedisWithInProgressExpiration() {
+ Instant now = Instant.now();
+ long ttl = 3600;
+ long expiry = now.plus(ttl, ChronoUnit.SECONDS).getEpochSecond();
+ OptionalLong progressExpiry = OptionalLong.of(now.minus(30, ChronoUnit.SECONDS).toEpochMilli());
+ redisPersistenceStore.putRecord(
+ new DataRecord("key", DataRecord.Status.COMPLETED, expiry, null, null, progressExpiry), now);
+
+ Map redisItem = jedisPool.hgetAll("{idempotency:id:key}");
+ long ttlInRedis = jedisPool.ttl("{idempotency:id:key}");
+
+ assertThat(redisItem).isNotNull();
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:status", "COMPLETED");
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:expiration", String.valueOf(expiry));
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:in-progress-expiration",
+ String.valueOf(progressExpiry.getAsLong()));
+ assertThat(Math.round(ttlInRedis / 100.0) * 100).isEqualTo(ttl);
+ }
+
+ @Test
+ void putRecord_shouldCreateItemInRedis_withExistingJedisClient() {
+ Instant now = Instant.now();
+ long expiry = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond();
+ RedisPersistenceStore store = new RedisPersistenceStore.Builder().withJedisClient(jedisPool).build();
+ store.putRecord(new DataRecord("key", DataRecord.Status.COMPLETED, expiry, null, null), now);
+
+ Map redisItem = jedisPool.hgetAll("{idempotency:id:key}");
+
+ assertThat(redisItem).isNotNull();
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:status", "COMPLETED");
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:expiration", String.valueOf(expiry));
+ }
+
+ @Test
+ void putRecord_shouldCreateItemInRedis_IfPreviousExpired() {
+
+ Map item = new HashMap<>();
+ Instant now = Instant.now();
+ long expiry = now.minus(30, ChronoUnit.SECONDS).getEpochSecond();
+ item.put("{idempotency:id:key}:expiration", String.valueOf(expiry));
+ item.put("{idempotency:id:key}:status", DataRecord.Status.COMPLETED.toString());
+ item.put("{idempotency:id:key}:data", "Fake Data");
+
+ long ttl = 3600;
+ long expiry2 = now.plus(ttl, ChronoUnit.SECONDS).getEpochSecond();
+ jedisPool.hset("{idempotency:id:key}", item);
+ redisPersistenceStore.putRecord(
+ new DataRecord("key",
+ DataRecord.Status.INPROGRESS,
+ expiry2,
+ null,
+ null
+ ), now);
+
+ Map redisItem = jedisPool.hgetAll("{idempotency:id:key}");
+ long ttlInRedis = jedisPool.ttl("{idempotency:id:key}");
+
+ assertThat(redisItem).isNotNull();
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:status", "INPROGRESS");
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:expiration", String.valueOf(expiry2));
+ assertThat(Math.round(ttlInRedis / 100.0) * 100).isEqualTo(ttl);
+ }
+
+ @Test
+ void putRecord_shouldCreateItemInRedis_IfLambdaWasInProgressAndTimedOut() {
+
+ Map item = new HashMap<>();
+ Instant now = Instant.now();
+ long expiry = now.plus(30, ChronoUnit.SECONDS).getEpochSecond();
+ long progressExpiry = now.minus(30, ChronoUnit.SECONDS).toEpochMilli();
+ item.put("{idempotency:id:key}:expiration", String.valueOf(expiry));
+ item.put("{idempotency:id:key}:status", DataRecord.Status.INPROGRESS.toString());
+ item.put("{idempotency:id:key}:data", "Fake Data");
+ item.put("{idempotency:id:key}:in-progress-expiration", String.valueOf(progressExpiry));
+ jedisPool.hset("{idempotency:id:key}", item);
+
+ long expiry2 = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond();
+ redisPersistenceStore.putRecord(
+ new DataRecord("key",
+ DataRecord.Status.INPROGRESS,
+ expiry2,
+ null,
+ null
+ ), now);
+
+ Map redisItem = jedisPool.hgetAll("{idempotency:id:key}");
+
+ assertThat(redisItem).isNotNull();
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:status", "INPROGRESS");
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:expiration", String.valueOf(expiry2));
+ }
+
+ @Test
+ void putRecord_shouldThrowIdempotencyItemAlreadyExistsException_IfRecordAlreadyExist() {
+
+ Map item = new HashMap<>();
+ Instant now = Instant.now();
+ long expiry = now.plus(30, ChronoUnit.SECONDS).getEpochSecond();
+ item.put("{idempotency:id:key}:expiration", String.valueOf(expiry)); // not expired
+ item.put("{idempotency:id:key}:status", DataRecord.Status.COMPLETED.toString());
+ item.put("{idempotency:id:key}:data", "Fake Data");
+
+ jedisPool.hset("{idempotency:id:key}", item);
+
+ long expiry2 = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond();
+ DataRecord dataRecord = new DataRecord("key",
+ DataRecord.Status.INPROGRESS,
+ expiry2,
+ null,
+ null
+ );
+ assertThatThrownBy(() -> {
+ redisPersistenceStore.putRecord(
+ dataRecord, now);
+ }
+ ).isInstanceOf(IdempotencyItemAlreadyExistsException.class);
+
+ Map entry = jedisPool.hgetAll("{idempotency:id:key}");
+
+ assertThat(entry).isNotNull();
+ assertThat(entry.get("{idempotency:id:key}:status")).isEqualTo("COMPLETED");
+ assertThat(entry.get("{idempotency:id:key}:expiration")).isEqualTo(String.valueOf(expiry));
+ assertThat(entry.get("{idempotency:id:key}:data")).isEqualTo("Fake Data");
+ }
+
+ @Test
+ void putRecord_shouldBlockUpdate_IfRecordAlreadyExistAndProgressNotExpiredAfterLambdaTimedOut() {
+
+ Map item = new HashMap<>();
+ Instant now = Instant.now();
+ long expiry = now.plus(30, ChronoUnit.SECONDS).getEpochSecond(); // not expired
+ long progressExpiry = now.plus(30, ChronoUnit.SECONDS).toEpochMilli(); // not expired
+ item.put("{idempotency:id:key}:expiration", String.valueOf(expiry));
+ item.put("{idempotency:id:key}:status", DataRecord.Status.INPROGRESS.toString());
+ item.put("{idempotency:id:key}:data", "Fake Data");
+ item.put("{idempotency:id:key}:in-progress-expiration", String.valueOf(progressExpiry));
+ jedisPool.hset("{idempotency:id:key}", item);
+
+ long expiry2 = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond();
+ DataRecord dataRecord = new DataRecord("key",
+ DataRecord.Status.INPROGRESS,
+ expiry2,
+ "Fake Data 2",
+ null
+ );
+ assertThatThrownBy(() -> redisPersistenceStore.putRecord(
+ dataRecord, now))
+ .isInstanceOf(IdempotencyItemAlreadyExistsException.class);
+
+ Map redisItem = jedisPool.hgetAll("{idempotency:id:key}");
+
+ assertThat(redisItem).isNotNull();
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:status", "INPROGRESS");
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:expiration", String.valueOf(expiry));
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:data", "Fake Data");
+ }
+
+ @Test
+ void getRecord_shouldReturnExistingRecord() throws IdempotencyItemNotFoundException {
+
+ Map item = new HashMap<>();
+ Instant now = Instant.now();
+ long expiry = now.plus(30, ChronoUnit.SECONDS).getEpochSecond();
+ item.put("{idempotency:id:key}:expiration", String.valueOf(expiry));
+ item.put("{idempotency:id:key}:status", DataRecord.Status.COMPLETED.toString());
+ item.put("{idempotency:id:key}:data", ("Fake Data"));
+ jedisPool.hset("{idempotency:id:key}", item);
+
+ DataRecord record = redisPersistenceStore.getRecord("key");
+
+ assertThat(record.getIdempotencyKey()).isEqualTo("key");
+ assertThat(record.getStatus()).isEqualTo(DataRecord.Status.COMPLETED);
+ assertThat(record.getResponseData()).isEqualTo("Fake Data");
+ assertThat(record.getExpiryTimestamp()).isEqualTo(expiry);
+ }
+
+ @Test
+ void getRecord_shouldThrowException_whenRecordIsAbsent() {
+ assertThatThrownBy(() -> redisPersistenceStore.getRecord("key")).isInstanceOf(
+ IdempotencyItemNotFoundException.class);
+ }
+
+ @Test
+ void updateRecord_shouldUpdateRecord() {
+ Map item = new HashMap<>();
+ Instant now = Instant.now();
+ long expiry = now.plus(360, ChronoUnit.SECONDS).getEpochSecond();
+ item.put("{idempotency:id:key}:expiration", String.valueOf(expiry));
+ item.put("{idempotency:id:key}:status", DataRecord.Status.INPROGRESS.toString());
+ jedisPool.hset("{idempotency:id:key}", item);
+ // enable payload validation
+ redisPersistenceStore.configure(IdempotencyConfig.builder().withPayloadValidationJMESPath("path").build(),
+ null);
+
+ long ttl = 3600;
+ expiry = now.plus(ttl, ChronoUnit.SECONDS).getEpochSecond();
+ DataRecord record = new DataRecord("key", DataRecord.Status.COMPLETED, expiry, "Fake result", "hash");
+ redisPersistenceStore.updateRecord(record);
+
+ Map redisItem = jedisPool.hgetAll("{idempotency:id:key}");
+ long ttlInRedis = jedisPool.ttl("{idempotency:id:key}");
+
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:status", "COMPLETED");
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:expiration", String.valueOf(expiry));
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:data", "Fake result");
+ assertThat(redisItem).containsEntry("{idempotency:id:key}:validation", "hash");
+ assertThat(Math.round(ttlInRedis / 100.0) * 100).isEqualTo(ttl);
+ }
+
+ @Test
+ void deleteRecord_shouldDeleteRecord() {
+ Map item = new HashMap<>();
+ Instant now = Instant.now();
+ long expiry = now.plus(360, ChronoUnit.SECONDS).getEpochSecond();
+ item.put("{idempotency:id:key}:expiration", String.valueOf(expiry));
+ item.put("{idempotency:id:key}:status", DataRecord.Status.INPROGRESS.toString());
+ jedisPool.hset("{idempotency:id:key}", item);
+
+ redisPersistenceStore.deleteRecord("key");
+
+ Map items = jedisPool.hgetAll("{idempotency:id:key}");
+
+ assertThat(items).isEmpty();
+ }
+
+
+ @Test
+ void endToEndWithCustomAttrNamesAndSortKey() throws IdempotencyItemNotFoundException {
+ try {
+ RedisPersistenceStore persistenceStore = RedisPersistenceStore.builder()
+ .withKeyPrefixName("items-idempotency")
+ .withJedisClient(jedisPool)
+ .withDataAttr("result")
+ .withExpiryAttr("expiry")
+ .withKeyAttr("key")
+ .withStatusAttr("state")
+ .withValidationAttr("valid")
+ .build();
+
+ Instant now = Instant.now();
+ DataRecord record = new DataRecord(
+ "mykey",
+ DataRecord.Status.INPROGRESS,
+ now.plus(400, ChronoUnit.SECONDS).getEpochSecond(),
+ null,
+ null
+ );
+ // PUT
+ persistenceStore.putRecord(record, now);
+
+ Map redisItem = jedisPool.hgetAll("{items-idempotency:key:mykey}");
+
+ // GET
+ DataRecord recordInDb = persistenceStore.getRecord("mykey");
+
+ assertThat(redisItem).isNotNull();
+ assertThat(redisItem).containsEntry("{items-idempotency:key:mykey}:state",
+ recordInDb.getStatus().toString());
+ assertThat(redisItem).containsEntry("{items-idempotency:key:mykey}:expiry",
+ String.valueOf(recordInDb.getExpiryTimestamp()));
+
+ // UPDATE
+ DataRecord updatedRecord = new DataRecord(
+ "mykey",
+ DataRecord.Status.COMPLETED,
+ now.plus(500, ChronoUnit.SECONDS).getEpochSecond(),
+ "response",
+ null
+ );
+ persistenceStore.updateRecord(updatedRecord);
+ recordInDb = persistenceStore.getRecord("mykey");
+ assertThat(recordInDb).isEqualTo(updatedRecord);
+
+ // DELETE
+ persistenceStore.deleteRecord("mykey");
+ assertThat(jedisPool.hgetAll("{items-idempotency:key:mykey}").size()).isZero();
+
+ } finally {
+ jedisPool.del("{items-idempotency:key:mykey}");
+ }
+ }
+
+ @Test
+ @SetEnvironmentVariable(key = software.amazon.lambda.powertools.idempotency.Constants.IDEMPOTENCY_DISABLED_ENV, value = "true")
+ void idempotencyDisabled_noClientShouldBeCreated() {
+ RedisPersistenceStore store = RedisPersistenceStore.builder().build();
+ assertThatThrownBy(() -> store.getRecord("key")).isInstanceOf(NullPointerException.class);
+ }
+
+ @AfterEach
+ void emptyDB() {
+ jedisPool.del("{idempotency:id:key}");
+ }
+
+}
diff --git a/powertools-large-messages/pom.xml b/powertools-large-messages/pom.xml
index 4206183de..1bd670054 100644
--- a/powertools-large-messages/pom.xml
+++ b/powertools-large-messages/pom.xml
@@ -117,6 +117,11 @@
log4j-slf4j2-impl
test