diff --git a/powertools-e2e-tests/handlers/idempotency-functional/pom.xml b/powertools-e2e-tests/handlers/idempotency-functional/pom.xml new file mode 100644 index 000000000..c8893c6e9 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/pom.xml @@ -0,0 +1,60 @@ + + 4.0.0 + + + software.amazon.lambda + e2e-test-handlers-parent + 2.5.0 + + + e2e-test-handler-idempotency-functional + jar + E2E test handler – Idempotency Functional + + + + software.amazon.lambda + powertools-idempotency-dynamodb + + + software.amazon.lambda + powertools-logging-log4j + + + com.amazonaws + aws-lambda-java-events + + + com.amazonaws + aws-lambda-java-runtime-interface-client + + + com.amazonaws + aws-lambda-java-core + + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + + + + + native-image + + + + org.graalvm.buildtools + native-maven-plugin + + + + + + diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/java/software/amazon/lambda/powertools/e2e/Function.java b/powertools-e2e-tests/handlers/idempotency-functional/src/main/java/software/amazon/lambda/powertools/e2e/Function.java new file mode 100644 index 000000000..fec7459c1 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/java/software/amazon/lambda/powertools/e2e/Function.java @@ -0,0 +1,66 @@ +/* + * 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.e2e; + +import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.TimeZone; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.lambda.powertools.idempotency.Idempotency; +import software.amazon.lambda.powertools.idempotency.IdempotencyConfig; +import software.amazon.lambda.powertools.idempotency.persistence.dynamodb.DynamoDBPersistenceStore; + +public class Function implements RequestHandler { + + public Function() { + this(DynamoDbClient + .builder() + .httpClient(UrlConnectionHttpClient.builder().build()) + .region(Region.of(System.getenv("AWS_REGION"))) + .build()); + } + + public Function(DynamoDbClient client) { + Idempotency.config().withConfig( + IdempotencyConfig.builder() + .withExpiration(Duration.of(10, ChronoUnit.SECONDS)) + .build()) + .withPersistenceStore( + DynamoDBPersistenceStore.builder() + .withDynamoDbClient(client) + .withTableName(System.getenv("IDEMPOTENCY_TABLE")) + .build()) + .configure(); + } + + public String handleRequest(Input input, Context context) { + Idempotency.registerLambdaContext(context); + + return Idempotency.makeIdempotent(this::processRequest, input, String.class); + } + + private String processRequest(Input input) { + DateTimeFormatter dtf = DateTimeFormatter.ISO_DATE_TIME.withZone(TimeZone.getTimeZone("UTC").toZoneId()); + return dtf.format(Instant.now()); + } +} diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/java/software/amazon/lambda/powertools/e2e/Input.java b/powertools-e2e-tests/handlers/idempotency-functional/src/main/java/software/amazon/lambda/powertools/e2e/Input.java new file mode 100644 index 000000000..0d14b749e --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/java/software/amazon/lambda/powertools/e2e/Input.java @@ -0,0 +1,27 @@ +/* + * 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.e2e; + +public class Input { + private String message; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-core/reflect-config.json b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-core/reflect-config.json new file mode 100644 index 000000000..2780aca09 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-core/reflect-config.json @@ -0,0 +1,13 @@ +[ + { + "name":"com.amazonaws.services.lambda.runtime.LambdaRuntime", + "methods":[{"name":"","parameterTypes":[] }], + "fields":[{"name":"logger"}], + "allPublicMethods":true + }, + { + "name":"com.amazonaws.services.lambda.runtime.LambdaRuntimeInternal", + "methods":[{"name":"","parameterTypes":[] }], + "allPublicMethods":true + } +] \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-events/reflect-config.json b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-events/reflect-config.json new file mode 100644 index 000000000..ddda5d5f1 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-events/reflect-config.json @@ -0,0 +1,35 @@ +[ + { + "name": "com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent$ProxyRequestContext", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent$RequestIdentity", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + } +] \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/jni-config.json b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/jni-config.json new file mode 100644 index 000000000..91be72f7a --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/jni-config.json @@ -0,0 +1,11 @@ +[ + { + "name":"com.amazonaws.services.lambda.runtime.api.client.runtimeapi.LambdaRuntimeClientException", + "methods":[{"name":"","parameterTypes":["java.lang.String","int"] }] + }, + { + "name":"com.amazonaws.services.lambda.runtime.api.client.runtimeapi.dto.InvocationRequest", + "fields":[{"name":"id"}, {"name":"invokedFunctionArn"}, {"name":"deadlineTimeInMs"}, {"name":"xrayTraceId"}, {"name":"clientContext"}, {"name":"cognitoIdentity"}, {"name": "tenantId"}, {"name":"content"}], + "allPublicMethods":true + } +] \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/native-image.properties b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/native-image.properties new file mode 100644 index 000000000..20f8b7801 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/native-image.properties @@ -0,0 +1 @@ +Args = --initialize-at-build-time=jdk.xml.internal.SecuritySupport \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/reflect-config.json b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/reflect-config.json new file mode 100644 index 000000000..e69fa735c --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/reflect-config.json @@ -0,0 +1,61 @@ +[ + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.deser.Deserializers[]" + }, + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.ext.Java7SupportImpl", + "methods": [{ "name": "", "parameterTypes": [] }] + }, + { + "name": "com.amazonaws.services.lambda.runtime.LambdaRuntime", + "fields": [{ "name": "logger" }] + }, + { + "name": "com.amazonaws.services.lambda.runtime.logging.LogLevel", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "com.amazonaws.services.lambda.runtime.logging.LogFormat", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "java.lang.Void", + "methods": [{ "name": "", "parameterTypes": [] }] + }, + { + "name": "java.util.Collections$UnmodifiableMap", + "fields": [{ "name": "m" }] + }, + { + "name": "jdk.internal.module.IllegalAccessLogger", + "fields": [{ "name": "logger" }] + }, + { + "name": "sun.misc.Unsafe", + "fields": [{ "name": "theUnsafe" }] + }, + { + "name": "com.amazonaws.services.lambda.runtime.api.client.runtimeapi.dto.InvocationRequest", + "fields": [ + { "name": "id" }, + { "name": "invokedFunctionArn" }, + { "name": "deadlineTimeInMs" }, + { "name": "xrayTraceId" }, + { "name": "clientContext" }, + { "name": "cognitoIdentity" }, + { "name": "tenantId" }, + { "name": "content" } + ], + "allPublicMethods": true + } +] diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/resource-config.json b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/resource-config.json new file mode 100644 index 000000000..1062b4249 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/resource-config.json @@ -0,0 +1,19 @@ +{ + "resources": { + "includes": [ + { + "pattern": "\\Qjni/libaws-lambda-jni.linux-aarch_64.so\\E" + }, + { + "pattern": "\\Qjni/libaws-lambda-jni.linux-x86_64.so\\E" + }, + { + "pattern": "\\Qjni/libaws-lambda-jni.linux_musl-aarch_64.so\\E" + }, + { + "pattern": "\\Qjni/libaws-lambda-jni.linux_musl-x86_64.so\\E" + } + ] + }, + "bundles": [] +} \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-serialization/reflect-config.json b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-serialization/reflect-config.json new file mode 100644 index 000000000..9890688f9 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-serialization/reflect-config.json @@ -0,0 +1,25 @@ +[ + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.deser.Deserializers[]" + }, + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.ext.Java7HandlersImpl", + "methods": [{ "name": "", "parameterTypes": [] }] + }, + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.ext.Java7SupportImpl", + "methods": [{ "name": "", "parameterTypes": [] }] + }, + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.ser.Serializers[]" + }, + { + "name": "org.joda.time.DateTime", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + } +] diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/reflect-config.json b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/reflect-config.json new file mode 100644 index 000000000..9ddd235e2 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/reflect-config.json @@ -0,0 +1,20 @@ +[ + { + "name": "software.amazon.lambda.powertools.e2e.Function", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + }, + { + "name": "software.amazon.lambda.powertools.e2e.Input", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + } +] diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/resource-config.json b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/resource-config.json new file mode 100644 index 000000000..be6aac3f6 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/resource-config.json @@ -0,0 +1,7 @@ +{ + "resources":{ + "includes":[{ + "pattern":"\\Qlog4j2.xml\\E" + }]}, + "bundles":[] +} diff --git a/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/log4j2.xml b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/log4j2.xml new file mode 100644 index 000000000..8925f70b9 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-functional/src/main/resources/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/idempotency-generics/pom.xml b/powertools-e2e-tests/handlers/idempotency-generics/pom.xml new file mode 100644 index 000000000..5387d4059 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/pom.xml @@ -0,0 +1,60 @@ + + 4.0.0 + + + software.amazon.lambda + e2e-test-handlers-parent + 2.5.0 + + + e2e-test-handler-idempotency-generics + jar + E2E test handler – Idempotency Generics + + + + software.amazon.lambda + powertools-idempotency-dynamodb + + + software.amazon.lambda + powertools-logging-log4j + + + com.amazonaws + aws-lambda-java-events + + + com.amazonaws + aws-lambda-java-runtime-interface-client + + + com.amazonaws + aws-lambda-java-core + + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + + + + + native-image + + + + org.graalvm.buildtools + native-maven-plugin + + + + + + diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/java/software/amazon/lambda/powertools/e2e/Function.java b/powertools-e2e-tests/handlers/idempotency-generics/src/main/java/software/amazon/lambda/powertools/e2e/Function.java new file mode 100644 index 000000000..09e39d1eb --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/java/software/amazon/lambda/powertools/e2e/Function.java @@ -0,0 +1,78 @@ +/* + * 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.e2e; + +import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; +import java.util.TimeZone; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.fasterxml.jackson.core.type.TypeReference; + +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.lambda.powertools.idempotency.Idempotency; +import software.amazon.lambda.powertools.idempotency.IdempotencyConfig; +import software.amazon.lambda.powertools.idempotency.persistence.dynamodb.DynamoDBPersistenceStore; + +public class Function implements RequestHandler { + + public Function() { + this(DynamoDbClient + .builder() + .httpClient(UrlConnectionHttpClient.builder().build()) + .region(Region.of(System.getenv("AWS_REGION"))) + .build()); + } + + public Function(DynamoDbClient client) { + Idempotency.config().withConfig( + IdempotencyConfig.builder() + .withExpiration(Duration.of(10, ChronoUnit.SECONDS)) + .build()) + .withPersistenceStore( + DynamoDBPersistenceStore.builder() + .withDynamoDbClient(client) + .withTableName(System.getenv("IDEMPOTENCY_TABLE")) + .build()) + .configure(); + } + + public String handleRequest(Input input, Context context) { + Idempotency.registerLambdaContext(context); + + // This is just to test the generic type support using TypeReference. + // We return the same String to run the same assertions as other idempotency E2E handlers. + Map result = Idempotency.makeIdempotent( + this::processRequest, + input, + new TypeReference>() {}); + + return result.get("timestamp"); + } + + private Map processRequest(Input input) { + DateTimeFormatter dtf = DateTimeFormatter.ISO_DATE_TIME.withZone(TimeZone.getTimeZone("UTC").toZoneId()); + Map result = new HashMap<>(); + result.put("timestamp", dtf.format(Instant.now())); + return result; + } +} diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/java/software/amazon/lambda/powertools/e2e/Input.java b/powertools-e2e-tests/handlers/idempotency-generics/src/main/java/software/amazon/lambda/powertools/e2e/Input.java new file mode 100644 index 000000000..0d14b749e --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/java/software/amazon/lambda/powertools/e2e/Input.java @@ -0,0 +1,27 @@ +/* + * 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.e2e; + +public class Input { + private String message; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-core/reflect-config.json b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-core/reflect-config.json new file mode 100644 index 000000000..2780aca09 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-core/reflect-config.json @@ -0,0 +1,13 @@ +[ + { + "name":"com.amazonaws.services.lambda.runtime.LambdaRuntime", + "methods":[{"name":"","parameterTypes":[] }], + "fields":[{"name":"logger"}], + "allPublicMethods":true + }, + { + "name":"com.amazonaws.services.lambda.runtime.LambdaRuntimeInternal", + "methods":[{"name":"","parameterTypes":[] }], + "allPublicMethods":true + } +] \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-events/reflect-config.json b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-events/reflect-config.json new file mode 100644 index 000000000..ddda5d5f1 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-events/reflect-config.json @@ -0,0 +1,35 @@ +[ + { + "name": "com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent$ProxyRequestContext", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent$RequestIdentity", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + } +] \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/jni-config.json b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/jni-config.json new file mode 100644 index 000000000..91be72f7a --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/jni-config.json @@ -0,0 +1,11 @@ +[ + { + "name":"com.amazonaws.services.lambda.runtime.api.client.runtimeapi.LambdaRuntimeClientException", + "methods":[{"name":"","parameterTypes":["java.lang.String","int"] }] + }, + { + "name":"com.amazonaws.services.lambda.runtime.api.client.runtimeapi.dto.InvocationRequest", + "fields":[{"name":"id"}, {"name":"invokedFunctionArn"}, {"name":"deadlineTimeInMs"}, {"name":"xrayTraceId"}, {"name":"clientContext"}, {"name":"cognitoIdentity"}, {"name": "tenantId"}, {"name":"content"}], + "allPublicMethods":true + } +] \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/native-image.properties b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/native-image.properties new file mode 100644 index 000000000..20f8b7801 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/native-image.properties @@ -0,0 +1 @@ +Args = --initialize-at-build-time=jdk.xml.internal.SecuritySupport \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/reflect-config.json b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/reflect-config.json new file mode 100644 index 000000000..e69fa735c --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/reflect-config.json @@ -0,0 +1,61 @@ +[ + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.deser.Deserializers[]" + }, + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.ext.Java7SupportImpl", + "methods": [{ "name": "", "parameterTypes": [] }] + }, + { + "name": "com.amazonaws.services.lambda.runtime.LambdaRuntime", + "fields": [{ "name": "logger" }] + }, + { + "name": "com.amazonaws.services.lambda.runtime.logging.LogLevel", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "com.amazonaws.services.lambda.runtime.logging.LogFormat", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "java.lang.Void", + "methods": [{ "name": "", "parameterTypes": [] }] + }, + { + "name": "java.util.Collections$UnmodifiableMap", + "fields": [{ "name": "m" }] + }, + { + "name": "jdk.internal.module.IllegalAccessLogger", + "fields": [{ "name": "logger" }] + }, + { + "name": "sun.misc.Unsafe", + "fields": [{ "name": "theUnsafe" }] + }, + { + "name": "com.amazonaws.services.lambda.runtime.api.client.runtimeapi.dto.InvocationRequest", + "fields": [ + { "name": "id" }, + { "name": "invokedFunctionArn" }, + { "name": "deadlineTimeInMs" }, + { "name": "xrayTraceId" }, + { "name": "clientContext" }, + { "name": "cognitoIdentity" }, + { "name": "tenantId" }, + { "name": "content" } + ], + "allPublicMethods": true + } +] diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/resource-config.json b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/resource-config.json new file mode 100644 index 000000000..1062b4249 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-runtime-interface-client/resource-config.json @@ -0,0 +1,19 @@ +{ + "resources": { + "includes": [ + { + "pattern": "\\Qjni/libaws-lambda-jni.linux-aarch_64.so\\E" + }, + { + "pattern": "\\Qjni/libaws-lambda-jni.linux-x86_64.so\\E" + }, + { + "pattern": "\\Qjni/libaws-lambda-jni.linux_musl-aarch_64.so\\E" + }, + { + "pattern": "\\Qjni/libaws-lambda-jni.linux_musl-x86_64.so\\E" + } + ] + }, + "bundles": [] +} \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-serialization/reflect-config.json b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-serialization/reflect-config.json new file mode 100644 index 000000000..9890688f9 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/com.amazonaws/aws-lambda-java-serialization/reflect-config.json @@ -0,0 +1,25 @@ +[ + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.deser.Deserializers[]" + }, + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.ext.Java7HandlersImpl", + "methods": [{ "name": "", "parameterTypes": [] }] + }, + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.ext.Java7SupportImpl", + "methods": [{ "name": "", "parameterTypes": [] }] + }, + { + "name": "com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.ser.Serializers[]" + }, + { + "name": "org.joda.time.DateTime", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + } +] diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/reflect-config.json b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/reflect-config.json new file mode 100644 index 000000000..9ddd235e2 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/reflect-config.json @@ -0,0 +1,20 @@ +[ + { + "name": "software.amazon.lambda.powertools.e2e.Function", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + }, + { + "name": "software.amazon.lambda.powertools.e2e.Input", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + } +] diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/resource-config.json b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/resource-config.json new file mode 100644 index 000000000..be6aac3f6 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/META-INF/native-image/software.amazon.lambda.powertools.e2e/resource-config.json @@ -0,0 +1,7 @@ +{ + "resources":{ + "includes":[{ + "pattern":"\\Qlog4j2.xml\\E" + }]}, + "bundles":[] +} diff --git a/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/log4j2.xml b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/log4j2.xml new file mode 100644 index 000000000..8925f70b9 --- /dev/null +++ b/powertools-e2e-tests/handlers/idempotency-generics/src/main/resources/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/pom.xml b/powertools-e2e-tests/handlers/pom.xml index ae075b71d..7a7da9e02 100644 --- a/powertools-e2e-tests/handlers/pom.xml +++ b/powertools-e2e-tests/handlers/pom.xml @@ -34,6 +34,8 @@ tracing metrics idempotency + idempotency-functional + idempotency-generics parameters validation-alb-event validation-apigw-event diff --git a/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/IdempotencyE2ET.java b/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/IdempotencyE2ET.java index c73a6d761..9ced0e4fb 100644 --- a/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/IdempotencyE2ET.java +++ b/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/IdempotencyE2ET.java @@ -23,40 +23,43 @@ import java.util.concurrent.TimeUnit; import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import software.amazon.lambda.powertools.testutils.Infrastructure; import software.amazon.lambda.powertools.testutils.lambda.InvocationResult; +@TestInstance(TestInstance.Lifecycle.PER_CLASS) class IdempotencyE2ET { - private static Infrastructure infrastructure; - private static String functionName; + private Infrastructure infrastructure; + private String functionName; - @BeforeAll - @Timeout(value = 15, unit = TimeUnit.MINUTES) - static void setup() { + private void setupInfrastructure(String pathToFunction) { String random = UUID.randomUUID().toString().substring(0, 6); infrastructure = Infrastructure.builder() - .testName(IdempotencyE2ET.class.getSimpleName()) - .pathToFunction("idempotency") + .testName(IdempotencyE2ET.class.getSimpleName() + "-" + pathToFunction) + .pathToFunction(pathToFunction) .idempotencyTable("idempo" + random) .build(); Map outputs = infrastructure.deploy(); functionName = outputs.get(FUNCTION_NAME_OUTPUT); } - @AfterAll - static void tearDown() { + @AfterEach + void tearDown() { if (infrastructure != null) { infrastructure.destroy(); } } - @Test - void test_ttlNotExpired_sameResult_ttlExpired_differentResult() throws InterruptedException { + @ParameterizedTest + @ValueSource(strings = { "idempotency", "idempotency-functional", "idempotency-generics" }) + @Timeout(value = 15, unit = TimeUnit.MINUTES) + void test_ttlNotExpired_sameResult_ttlExpired_differentResult(String pathToFunction) throws InterruptedException { + setupInfrastructure(pathToFunction); // GIVEN String event = "{\"message\":\"TTL 10sec\"}"; diff --git a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/Idempotency.java b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/Idempotency.java index bd564caf8..4f73edea0 100644 --- a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/Idempotency.java +++ b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/Idempotency.java @@ -14,25 +14,87 @@ package software.amazon.lambda.powertools.idempotency; +import java.util.function.Function; +import java.util.function.Supplier; + import com.amazonaws.services.lambda.runtime.Context; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; + +import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyConfigurationException; +import software.amazon.lambda.powertools.idempotency.internal.IdempotencyHandler; import software.amazon.lambda.powertools.idempotency.persistence.BasePersistenceStore; +import software.amazon.lambda.powertools.utilities.JsonConfig; /** - * Holds the configuration for idempotency: - * - * The persistence layer to use for persisting the request and response of the function (mandatory). - * The general configuration for idempotency (optional, see {@link IdempotencyConfig.Builder} methods to see defaults values. - * - * - * Use it before the function handler ({@link com.amazonaws.services.lambda.runtime.RequestHandler#handleRequest(Object, Context)}) - * get called. - * - * Example: - * - * Idempotency.config().withPersistenceStore(...).configure(); - * + * Idempotency provides both a configuration and a functional API for implementing idempotent workloads. + * + * This class is thread-safe. All operations delegate to the underlying persistence store + * which handles concurrent access safely. + * + * Configuration + * Configure the persistence layer and idempotency settings before your handler executes (e.g. in constructor): + * {@code + * Idempotency.config() + * .withPersistenceStore(persistenceStore) + * .withConfig(idempotencyConfig) + * .configure(); + * } + * + * Functional API + * Make methods idempotent without AspectJ annotations. Generic return types (e.g., {@code Map}, + * {@code List}) are supported via Jackson {@link TypeReference}. + * + * Important: Always call {@link #registerLambdaContext(Context)} + * at the start of your handler to enable proper timeout handling. + * + * Example usage with Function (single parameter): + * {@code + * public Basket handleRequest(Product input, Context context) { + * Idempotency.registerLambdaContext(context); + * return Idempotency.makeIdempotent(this::processProduct, input, Basket.class); + * } + * + * private Basket processProduct(Product product) { + * // business logic + * } + * } + * + * Example usage with Supplier (multi-parameter methods): + * {@code + * public String handleRequest(SQSEvent event, Context context) { + * Idempotency.registerLambdaContext(context); + * return Idempotency.makeIdempotent( + * event.getRecords().get(0).getBody(), + * () -> processPayment(orderId, amount, currency), + * String.class + * ); + * } + * } + * + * When different methods use the same payload as idempotency key, use explicit function names + * to differentiate between them: + * {@code + * // Different methods, same payload + * Idempotency.makeIdempotent("processPayment", orderId, + * () -> processPayment(orderId), String.class); + * + * Idempotency.makeIdempotent("refundPayment", orderId, + * () -> refundPayment(orderId), String.class); + * } + * + * @see #config() + * @see #registerLambdaContext(Context) + * @see #makeIdempotent(Object, Supplier, Class) + * @see #makeIdempotent(String, Object, Supplier, Class) + * @see #makeIdempotent(Function, Object, Class) + * @see #makeIdempotent(Object, Supplier, TypeReference) + * @see #makeIdempotent(String, Object, Supplier, TypeReference) + * @see #makeIdempotent(Function, Object, TypeReference) */ -public class Idempotency { +public final class Idempotency { + private static final String DEFAULT_FUNCTION_NAME = "function"; + private IdempotencyConfig config; private BasePersistenceStore persistenceStore; @@ -81,7 +143,7 @@ private void setPersistenceStore(BasePersistenceStore persistenceStore) { this.persistenceStore = persistenceStore; } - private static class Holder { + private static final class Holder { private static final Idempotency instance = new Idempotency(); } @@ -116,5 +178,151 @@ public Config withConfig(IdempotencyConfig config) { } } + // Functional API methods + + /** + * Makes a function idempotent using the provided idempotency key. + * Uses a default function name for namespacing the idempotency key. + * + * This method is thread-safe and can be used in parallel processing scenarios + * such as batch processors. + * + * This method is suitable for making methods idempotent that have more than one parameter. + * For simple single-parameter methods, {@link #makeIdempotent(Function, Object, Class)} is more intuitive. + * + * Note: If you need to call different functions with the same payload, + * use {@link #makeIdempotent(String, Object, Supplier, Class)} to specify distinct function names. + * This ensures each function has its own idempotency scope. + * + * @param idempotencyKey the key used for idempotency (will be converted to JSON) + * @param function the function to make idempotent + * @param returnType the class of the return type for deserialization + * @param the return type of the function + * @return the result of the function execution (either fresh or cached) + */ + public static T makeIdempotent(Object idempotencyKey, Supplier function, Class returnType) { + return makeIdempotent(DEFAULT_FUNCTION_NAME, idempotencyKey, function, returnType); + } + + /** + * Makes a function idempotent using the provided function name and idempotency key. + * + * This method is thread-safe and can be used in parallel processing scenarios + * such as batch processors. + * + * @param functionName the name of the function (used for persistence store configuration) + * @param idempotencyKey the key used for idempotency (will be converted to JSON) + * @param function the function to make idempotent + * @param returnType the class of the return type for deserialization + * @param the return type of the function + * @return the result of the function execution (either fresh or cached) + */ + public static T makeIdempotent(String functionName, Object idempotencyKey, Supplier function, + Class returnType) { + return makeIdempotent(functionName, idempotencyKey, function, JsonConfig.toTypeReference(returnType)); + } + + /** + * Makes a function with one parameter idempotent. + * The parameter is used as the idempotency key. + * + * For functions with more than one parameter, use {@link #makeIdempotent(Object, Supplier, Class)} instead. + * + * Note: If you need to call different functions with the same payload, + * use {@link #makeIdempotent(String, Object, Supplier, Class)} to specify distinct function names. + * This ensures each function has its own idempotency scope. + * + * @param function the function to make idempotent (method reference) + * @param arg the argument to pass to the function (also used as idempotency key) + * @param returnType the class of the return type for deserialization + * @param the argument type + * @param the return type + * @return the result of the function execution (either fresh or cached) + */ + public static R makeIdempotent(Function function, T arg, Class returnType) { + return makeIdempotent(DEFAULT_FUNCTION_NAME, arg, () -> function.apply(arg), returnType); + } + + /** + * Makes a function idempotent using the provided idempotency key with support for generic return types. + * Uses a default function name for namespacing the idempotency key. + * + * Use this method when the return type contains generics (e.g., {@code Map}). + * + * Example usage: + * {@code + * Map result = Idempotency.makeIdempotent( + * payload, + * () -> processBaskets(), + * new TypeReference>() {} + * ); + * } + * + * @param idempotencyKey the key used for idempotency (will be converted to JSON) + * @param function the function to make idempotent + * @param typeRef the TypeReference for deserialization of generic types + * @param the return type of the function + * @return the result of the function execution (either fresh or cached) + */ + public static T makeIdempotent(Object idempotencyKey, Supplier function, TypeReference typeRef) { + return makeIdempotent(DEFAULT_FUNCTION_NAME, idempotencyKey, function, typeRef); + } + + /** + * Makes a function idempotent using the provided function name and idempotency key with support for generic return types. + * + * @param functionName the name of the function (used for persistence store configuration) + * @param idempotencyKey the key used for idempotency (will be converted to JSON) + * @param function the function to make idempotent + * @param typeRef the TypeReference for deserialization of generic types + * @param the return type of the function + * @return the result of the function execution (either fresh or cached) + */ + @SuppressWarnings("unchecked") + public static T makeIdempotent(String functionName, Object idempotencyKey, Supplier function, + TypeReference typeRef) { + try { + JsonNode payload = JsonConfig.get().getObjectMapper().valueToTree(idempotencyKey); + Context lambdaContext = Idempotency.getInstance().getConfig().getLambdaContext(); + + IdempotencyHandler handler = new IdempotencyHandler( + function::get, + typeRef, + functionName, + payload, + lambdaContext); + + Object result = handler.handle(); + return (T) result; + } catch (RuntimeException e) { + throw e; + } catch (Throwable e) { + throw new IdempotencyConfigurationException("Idempotency operation failed: " + e.getMessage()); + } + } + + /** + * Makes a function with one parameter idempotent with support for generic return types. + * The parameter is used as the idempotency key. + * + * Example usage: + * {@code + * Map result = Idempotency.makeIdempotent( + * this::processProduct, + * product, + * new TypeReference>() {} + * ); + * } + * + * @param function the function to make idempotent (method reference) + * @param arg the argument to pass to the function (also used as idempotency key) + * @param typeRef the TypeReference for deserialization of generic types + * @param the argument type + * @param the return type + * @return the result of the function execution (either fresh or cached) + */ + public static R makeIdempotent(Function function, T arg, TypeReference typeRef) { + return makeIdempotent(DEFAULT_FUNCTION_NAME, arg, () -> function.apply(arg), typeRef); + } } diff --git a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java index 9d5c66cac..0d9504483 100644 --- a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java +++ b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java @@ -25,7 +25,7 @@ /** * Configuration of the idempotency feature. Use the {@link Builder} to create an instance. */ -public class IdempotencyConfig { +public final class IdempotencyConfig { private final int localCacheMaxItems; private final boolean useLocalCache; private final long expirationInSeconds; @@ -34,7 +34,7 @@ public class IdempotencyConfig { private final boolean throwOnNoIdempotencyKey; private final String hashFunction; private final BiFunction responseHook; - private Context lambdaContext; + private final InheritableThreadLocal lambdaContext = new InheritableThreadLocal<>(); private IdempotencyConfig(String eventKeyJMESPath, String payloadValidationJMESPath, boolean throwOnNoIdempotencyKey, boolean useLocalCache, int localCacheMaxItems, @@ -87,11 +87,11 @@ public String getHashFunction() { } public Context getLambdaContext() { - return lambdaContext; + return lambdaContext.get(); } public void setLambdaContext(Context lambdaContext) { - this.lambdaContext = lambdaContext; + this.lambdaContext.set(lambdaContext); } public BiFunction getResponseHook() { diff --git a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyHandler.java b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyHandler.java index 0466f244f..77d3d49b0 100644 --- a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyHandler.java +++ b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyHandler.java @@ -21,12 +21,11 @@ import java.util.OptionalInt; import java.util.function.BiFunction; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.reflect.MethodSignature; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.amazonaws.services.lambda.runtime.Context; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import software.amazon.lambda.powertools.idempotency.Idempotency; @@ -50,13 +49,21 @@ public class IdempotencyHandler { private static final Logger LOG = LoggerFactory.getLogger(IdempotencyHandler.class); private static final int MAX_RETRIES = 2; - private final ProceedingJoinPoint pjp; + private final IdempotentFunction> function; + private final TypeReference> returnTypeRef; private final JsonNode data; private final BasePersistenceStore persistenceStore; private final Context lambdaContext; - public IdempotencyHandler(ProceedingJoinPoint pjp, String functionName, JsonNode payload, Context lambdaContext) { - this.pjp = pjp; + public IdempotencyHandler(IdempotentFunction> function, Class> returnType, String functionName, + JsonNode payload, Context lambdaContext) { + this(function, JsonConfig.toTypeReference(returnType), functionName, payload, lambdaContext); + } + + public IdempotencyHandler(IdempotentFunction> function, TypeReference> returnTypeRef, String functionName, + JsonNode payload, Context lambdaContext) { + this.function = function; + this.returnTypeRef = returnTypeRef; this.data = payload; this.lambdaContext = lambdaContext; persistenceStore = Idempotency.getInstance().getPersistenceStore(); @@ -171,7 +178,6 @@ private Object handleForStatus(DataRecord record) { "Execution already in progress with idempotency key: " + record.getIdempotencyKey()); } - Class> returnType = ((MethodSignature) pjp.getSignature()).getReturnType(); try { LOG.debug("Response for key '{}' retrieved from idempotency store, skipping the function", record.getIdempotencyKey()); @@ -180,12 +186,12 @@ private Object handleForStatus(DataRecord record) { .getResponseHook(); final Object responseData; - if (returnType.equals(String.class)) { + if (String.class.equals(returnTypeRef.getType())) { // Primitive String data will be returned raw and not de-serialized from JSON. responseData = record.getResponseData(); } else { - responseData = JsonConfig.get().getObjectMapper().reader().readValue(record.getResponseData(), - returnType); + responseData = JsonConfig.get().getObjectMapper().readValue(record.getResponseData(), + returnTypeRef); } if (responseHook != null) { @@ -196,14 +202,14 @@ private Object handleForStatus(DataRecord record) { return responseData; } catch (Exception e) { throw new IdempotencyPersistenceLayerException( - "Unable to get function response as " + returnType.getSimpleName(), e); + "Unable to get function response as " + returnTypeRef.getType().getTypeName(), e); } } private Object getFunctionResponse() throws Throwable { Object response; try { - response = pjp.proceed(pjp.getArgs()); + response = function.execute(); } catch (Throwable handlerException) { // We need these nested blocks to preserve function's exception in case the persistence store operation // also raises an exception diff --git a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotentAspect.java b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotentAspect.java index ea6d743f0..35c6dee40 100644 --- a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotentAspect.java +++ b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotentAspect.java @@ -14,14 +14,21 @@ package software.amazon.lambda.powertools.idempotency.internal; -import com.amazonaws.services.lambda.runtime.Context; -import com.fasterxml.jackson.databind.JsonNode; +import static software.amazon.lambda.powertools.common.internal.LambdaHandlerProcessor.placedOnRequestHandler; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; + import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.DeclarePrecedence; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; + +import com.amazonaws.services.lambda.runtime.Context; +import com.fasterxml.jackson.databind.JsonNode; + import software.amazon.lambda.powertools.idempotency.Constants; import software.amazon.lambda.powertools.idempotency.Idempotency; import software.amazon.lambda.powertools.idempotency.IdempotencyKey; @@ -29,11 +36,6 @@ import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyConfigurationException; import software.amazon.lambda.powertools.utilities.JsonConfig; -import java.lang.annotation.Annotation; -import java.lang.reflect.Method; - -import static software.amazon.lambda.powertools.common.internal.LambdaHandlerProcessor.placedOnRequestHandler; - /** * Aspect that handles the {@link Idempotent} annotation. * It uses the {@link IdempotencyHandler} to actually do the job. @@ -42,14 +44,15 @@ // Idempotency annotation should come first before large message @DeclarePrecedence("software.amazon.lambda.powertools.idempotency.internal.IdempotentAspect, *") public class IdempotentAspect { - @SuppressWarnings({"EmptyMethod"}) + @SuppressWarnings({ "EmptyMethod" }) @Pointcut("@annotation(idempotent)") public void callAt(Idempotent idempotent) { + // Pointcut method - body intentionally empty } @Around(value = "callAt(idempotent) && execution(@Idempotent * *.*(..))", argNames = "pjp,idempotent") public Object around(ProceedingJoinPoint pjp, - Idempotent idempotent) throws Throwable { + Idempotent idempotent) throws Throwable { String idempotencyDisabledEnv = System.getenv().get(Constants.IDEMPOTENCY_DISABLED_ENV); if (idempotencyDisabledEnv != null && !"false".equalsIgnoreCase(idempotencyDisabledEnv)) { @@ -76,7 +79,12 @@ public Object around(ProceedingJoinPoint pjp, lambdaContext = Idempotency.getInstance().getConfig().getLambdaContext(); } - IdempotencyHandler idempotencyHandler = new IdempotencyHandler(pjp, method.getName(), payload, lambdaContext); + IdempotencyHandler idempotencyHandler = new IdempotencyHandler( + () -> pjp.proceed(pjp.getArgs()), + method.getReturnType(), + method.getName(), + payload, + lambdaContext); return idempotencyHandler.handle(); } diff --git a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotentFunction.java b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotentFunction.java new file mode 100644 index 000000000..2ff2071b0 --- /dev/null +++ b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotentFunction.java @@ -0,0 +1,30 @@ +/* + * 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.internal; + +/** + * Functional interface for idempotent function execution. + * + * This interface is similar to {@link java.util.concurrent.Callable} but throws {@link Throwable} + * instead of {@link Exception}. This is necessary to support AspectJ's {@code ProceedingJoinPoint.proceed()} + * which throws {@code Throwable}, allowing exceptions to bubble up naturally without wrapping. + * + * @param the return type of the function + */ +@FunctionalInterface +public interface IdempotentFunction { + @SuppressWarnings("java:S112") // Throwable is required for AspectJ compatibility + T execute() throws Throwable; +} diff --git a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCache.java b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCache.java index b57ad2977..a3bf1c5ff 100644 --- a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCache.java +++ b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCache.java @@ -35,7 +35,7 @@ public LRUCache(int capacity) { } @Override - protected boolean removeEldestEntry(Map.Entry entry) { - return (size() > this.capacity); + protected boolean removeEldestEntry(Map.Entry entry) { + return size() > this.capacity; } } diff --git a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/IdempotencyTest.java b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/IdempotencyTest.java new file mode 100644 index 000000000..10664a25b --- /dev/null +++ b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/IdempotencyTest.java @@ -0,0 +1,311 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import java.util.HashMap; +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.amazonaws.services.lambda.runtime.Context; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; + +import software.amazon.lambda.powertools.common.stubs.TestLambdaContext; +import software.amazon.lambda.powertools.idempotency.handlers.IdempotencyFunctionalFunction; +import software.amazon.lambda.powertools.idempotency.handlers.IdempotencyMultiArgFunctionalFunction; +import software.amazon.lambda.powertools.idempotency.model.Basket; +import software.amazon.lambda.powertools.idempotency.model.Product; +import software.amazon.lambda.powertools.idempotency.persistence.BasePersistenceStore; +import software.amazon.lambda.powertools.idempotency.testutils.InMemoryPersistenceStore; + +@ExtendWith(MockitoExtension.class) +class IdempotencyTest { + + private Context context = new TestLambdaContext(); + + @Mock + private BasePersistenceStore store; + + @Test + void firstCall_shouldPutInStore() { + Idempotency.config() + .withPersistenceStore(store) + .withConfig(IdempotencyConfig.builder() + .withEventKeyJMESPath("id") + .build()) + .configure(); + + IdempotencyFunctionalFunction function = new IdempotencyFunctionalFunction(); + + Product p = new Product(42, "fake product", 12); + Basket basket = function.handleRequest(p, context); + + assertThat(function.processCalled()).isTrue(); + assertThat(basket.getProducts()).hasSize(1); + + ArgumentCaptor nodeCaptor = ArgumentCaptor.forClass(JsonNode.class); + ArgumentCaptor expiryCaptor = ArgumentCaptor.forClass(OptionalInt.class); + verify(store).saveInProgress(nodeCaptor.capture(), any(), expiryCaptor.capture()); + assertThat(nodeCaptor.getValue().get("id").asLong()).isEqualTo(p.getId()); + assertThat(nodeCaptor.getValue().get("name").asText()).isEqualTo(p.getName()); + assertThat(nodeCaptor.getValue().get("price").asDouble()).isEqualTo(p.getPrice()); + assertThat(expiryCaptor.getValue().orElse(-1)).isEqualTo(30000); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(Basket.class); + verify(store).saveSuccess(any(), resultCaptor.capture(), any()); + assertThat(resultCaptor.getValue()).isEqualTo(basket); + } + + @Test + void testMakeIdempotentWithFunctionName() throws Throwable { + BasePersistenceStore spyStore = spy(BasePersistenceStore.class); + Idempotency.config() + .withPersistenceStore(spyStore) + .configure(); + Idempotency.registerLambdaContext(context); + + String result = Idempotency.makeIdempotent("myFunction", "test-key", () -> "test-result", + String.class); + + assertThat(result).isEqualTo("test-result"); + + ArgumentCaptor functionNameCaptor = ArgumentCaptor.forClass(String.class); + verify(spyStore).configure(any(), functionNameCaptor.capture()); + assertThat(functionNameCaptor.getValue()).isEqualTo("myFunction"); + } + + @Test + void testMakeIdempotentWithMethodReferenceUsesDefaultName() throws Throwable { + BasePersistenceStore spyStore = spy(BasePersistenceStore.class); + Idempotency.config() + .withPersistenceStore(spyStore) + .configure(); + Idempotency.registerLambdaContext(context); + + String result = Idempotency.makeIdempotent("test-key", this::helperMethod, String.class); + + assertThat(result).isEqualTo("helper-result"); + + ArgumentCaptor functionNameCaptor = ArgumentCaptor.forClass(String.class); + verify(spyStore).configure(any(), functionNameCaptor.capture()); + assertThat(functionNameCaptor.getValue()).isEqualTo("function"); + } + + private String helperMethod() { + return "helper-result"; + } + + @Test + void testMakeIdempotentWithFunctionOverload() throws Throwable { + BasePersistenceStore spyStore = spy(BasePersistenceStore.class); + Idempotency.config() + .withPersistenceStore(spyStore) + .configure(); + Idempotency.registerLambdaContext(context); + + Product p = new Product(42, "test product", 10); + Basket result = Idempotency.makeIdempotent(this::processProduct, p, Basket.class); + + assertThat(result.getProducts()).hasSize(1); + assertThat(result.getProducts().get(0)).isEqualTo(p); + + ArgumentCaptor functionNameCaptor = ArgumentCaptor.forClass(String.class); + verify(spyStore).configure(any(), functionNameCaptor.capture()); + assertThat(functionNameCaptor.getValue()).isEqualTo("function"); + + ArgumentCaptor nodeCaptor = ArgumentCaptor.forClass(JsonNode.class); + verify(spyStore).saveInProgress(nodeCaptor.capture(), any(), any()); + assertThat(nodeCaptor.getValue().get("id").asLong()).isEqualTo(p.getId()); + } + + private Basket processProduct(Product product) { + Basket basket = new Basket(); + basket.add(product); + return basket; + } + + @Test + void firstCall_withExplicitIdempotencyKey_shouldPutInStore() { + Idempotency.config() + .withPersistenceStore(store) + .configure(); + + IdempotencyMultiArgFunctionalFunction function = new IdempotencyMultiArgFunctionalFunction(); + + Product p = new Product(42, "fake product", 12); + Basket basket = function.handleRequest(p, context); + + assertThat(function.processCalled()).isTrue(); + assertThat(function.getExtraData()).isEqualTo("extra-data"); + assertThat(basket.getProducts()).hasSize(1); + + ArgumentCaptor nodeCaptor = ArgumentCaptor.forClass(JsonNode.class); + verify(store).saveInProgress(nodeCaptor.capture(), any(), any()); + assertThat(nodeCaptor.getValue().asLong()).isEqualTo(p.getId()); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(Basket.class); + verify(store).saveSuccess(any(), resultCaptor.capture(), any()); + assertThat(resultCaptor.getValue()).isEqualTo(basket); + } + + @Test + void secondCall_shouldRetrieveFromCacheAndDeserialize() throws Throwable { + InMemoryPersistenceStore inMemoryStore = new InMemoryPersistenceStore(); + + Idempotency.config() + .withPersistenceStore(inMemoryStore) + .configure(); + Idempotency.registerLambdaContext(context); + + Product p = new Product(42, "test product", 10); + int[] callCount = { 0 }; + + // First call - executes function and stores result + Basket result1 = Idempotency.makeIdempotent(p, () -> { + callCount[0]++; + return processProduct(p); + }, Basket.class); + assertThat(result1.getProducts()).hasSize(1); + assertThat(callCount[0]).isEqualTo(1); + + // Second call - should retrieve from cache, deserialize, and NOT execute function + Basket result2 = Idempotency.makeIdempotent(p, () -> { + callCount[0]++; + return processProduct(p); + }, Basket.class); + assertThat(result2.getProducts()).hasSize(1); + assertThat(result2.getProducts().get(0).getId()).isEqualTo(42); + assertThat(result2.getProducts().get(0).getName()).isEqualTo("test product"); + assertThat(callCount[0]).isEqualTo(1); // Function should NOT be called again + } + + @Test + void concurrentInvocations_shouldNotLeakContext() throws Exception { + Idempotency.config() + .withPersistenceStore(store) + .configure(); + + IdempotencyMultiArgFunctionalFunction function = new IdempotencyMultiArgFunctionalFunction(); + + // GIVEN + int threadCount = 10; + Thread[] threads = new Thread[threadCount]; + Context[] capturedContexts = new Context[threadCount]; + int[] capturedRemainingTimes = new int[threadCount]; + boolean[] success = new boolean[threadCount]; + + // WHEN - Multiple threads call handleRequest with different contexts + for (int i = 0; i < threadCount; i++) { + final int threadIndex = i; + final int expectedTime = (i + 1) * 2000; // 2000, 4000, 6000, ..., 20000 + + final Context threadContext = new TestLambdaContext() { + @Override + public int getRemainingTimeInMillis() { + return expectedTime; + } + }; + + threads[i] = new Thread(() -> { + try { + Product p = new Product(threadIndex, "product" + threadIndex, 10); + function.handleRequest(p, threadContext); + + // Capture the context that was actually stored in ThreadLocal by this thread + Context captured = Idempotency.getInstance().getConfig().getLambdaContext(); + capturedContexts[threadIndex] = captured; + capturedRemainingTimes[threadIndex] = captured != null ? captured.getRemainingTimeInMillis() : -1; + success[threadIndex] = true; + } catch (Exception e) { + success[threadIndex] = false; + } + }); + } + + // Start all threads + for (Thread thread : threads) { + thread.start(); + } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(); + } + + // THEN - All threads should complete successfully + for (boolean result : success) { + assertThat(result).isTrue(); + } + + // THEN - Each thread should have captured its own context (no leakage) + for (int i = 0; i < threadCount; i++) { + int expectedTime = (i + 1) * 2000; + assertThat(capturedRemainingTimes[i]) + .as("Thread %d should have remaining time %d", i, expectedTime) + .isEqualTo(expectedTime); + assertThat(capturedContexts[i]).as("Thread %d should have non-null context", i).isNotNull(); + } + } + + @Test + void testMakeIdempotentWithGenericType() throws Throwable { + InMemoryPersistenceStore inMemoryStore = new InMemoryPersistenceStore(); + + Idempotency.config() + .withPersistenceStore(inMemoryStore) + .configure(); + Idempotency.registerLambdaContext(context); + + int[] callCount = { 0 }; + + // First call - executes function and stores result + Map result1 = Idempotency.makeIdempotent("test-key", () -> { + callCount[0]++; + Map map = new HashMap<>(); + Basket basket = new Basket(); + basket.add(new Product(1, "product1", 10)); + map.put("basket1", basket); + return map; + }, new TypeReference>() { + }); + + assertThat(result1).hasSize(1); + assertThat(result1.get("basket1").getProducts()).hasSize(1); + assertThat(callCount[0]).isEqualTo(1); + + // Second call - should retrieve from cache and deserialize correctly + Map result2 = Idempotency.makeIdempotent("test-key", () -> { + callCount[0]++; + return new HashMap<>(); + }, new TypeReference>() { + }); + + assertThat(result2).hasSize(1); + assertThat(result2.get("basket1").getProducts()).hasSize(1); + assertThat(result2.get("basket1").getProducts().get(0).getName()).isEqualTo("product1"); + assertThat(callCount[0]).isEqualTo(1); // Function should NOT be called again + } +} diff --git a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyFunctionalFunction.java b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyFunctionalFunction.java new file mode 100644 index 000000000..c2a85d178 --- /dev/null +++ b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyFunctionalFunction.java @@ -0,0 +1,48 @@ +/* + * 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.handlers; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +import software.amazon.lambda.powertools.idempotency.Idempotency; +import software.amazon.lambda.powertools.idempotency.model.Basket; +import software.amazon.lambda.powertools.idempotency.model.Product; + +/** + * Lambda function using Idempotency functional API without AspectJ annotations + */ +public class IdempotencyFunctionalFunction implements RequestHandler { + + private boolean processCalled = false; + + public boolean processCalled() { + return processCalled; + } + + @Override + public Basket handleRequest(Product input, Context context) { + Idempotency.registerLambdaContext(context); + + return Idempotency.makeIdempotent(this::process, input, Basket.class); + } + + private Basket process(Product input) { + processCalled = true; + Basket b = new Basket(); + b.add(input); + return b; + } +} diff --git a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyMultiArgFunctionalFunction.java b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyMultiArgFunctionalFunction.java new file mode 100644 index 000000000..42e75fd55 --- /dev/null +++ b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyMultiArgFunctionalFunction.java @@ -0,0 +1,54 @@ +/* + * 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.handlers; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +import software.amazon.lambda.powertools.idempotency.Idempotency; +import software.amazon.lambda.powertools.idempotency.model.Basket; +import software.amazon.lambda.powertools.idempotency.model.Product; + +/** + * Lambda function using Idempotency functional API with explicit idempotency key + */ +public class IdempotencyMultiArgFunctionalFunction implements RequestHandler { + + private boolean processCalled = false; + private String extraData; + + public boolean processCalled() { + return processCalled; + } + + public String getExtraData() { + return extraData; + } + + @Override + public Basket handleRequest(Product input, Context context) { + Idempotency.registerLambdaContext(context); + + return Idempotency.makeIdempotent(input.getId(), () -> process(input, "extra-data"), Basket.class); + } + + private Basket process(Product input, String extraData) { + processCalled = true; + this.extraData = extraData; + Basket b = new Basket(); + b.add(input); + return b; + } +} diff --git a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyAspectTest.java b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyAspectTest.java index 0ccb1e5aa..384a20b2a 100644 --- a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyAspectTest.java +++ b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyAspectTest.java @@ -194,7 +194,7 @@ void secondCall_notExpired_shouldNotGetFromStoreIfPresentOnIdempotencyException( "Test message", new RuntimeException("Test Cause"), dr)) - .when(store).saveInProgress(any(), any(), any()); + .when(store).saveInProgress(any(), any(), any()); // WHEN IdempotencyEnabledFunction function = new IdempotencyEnabledFunction(); @@ -538,4 +538,73 @@ void idempotencyOnSubMethodVoid_shouldThrowException() { IdempotencyConfigurationException.class); } + @Test + void concurrentInvocations_shouldNotLeakContext() throws Exception { + Idempotency.config() + .withPersistenceStore(store) + .configure(); + + // Use IdempotencyInternalFunction which calls registerLambdaContext + IdempotencyInternalFunction function = new IdempotencyInternalFunction(true); + + // GIVEN + int threadCount = 10; + Thread[] threads = new Thread[threadCount]; + Context[] capturedContexts = new Context[threadCount]; + int[] capturedRemainingTimes = new int[threadCount]; + boolean[] success = new boolean[threadCount]; + + // WHEN - Multiple threads call handleRequest with different contexts + for (int i = 0; i < threadCount; i++) { + final int threadIndex = i; + final int expectedTime = (i + 1) * 1000; // 1000, 2000, 3000, ..., 10000 + + final Context threadContext = new TestLambdaContext() { + @Override + public int getRemainingTimeInMillis() { + return expectedTime; + } + }; + + threads[i] = new Thread(() -> { + try { + Product p = new Product(threadIndex, "product" + threadIndex, 10); + function.handleRequest(p, threadContext); + + // Capture the context that was actually stored in ThreadLocal by this thread + Context captured = Idempotency.getInstance().getConfig().getLambdaContext(); + capturedContexts[threadIndex] = captured; + capturedRemainingTimes[threadIndex] = captured != null ? captured.getRemainingTimeInMillis() : -1; + success[threadIndex] = true; + } catch (Exception e) { + success[threadIndex] = false; + } + }); + } + + // Start all threads + for (Thread thread : threads) { + thread.start(); + } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(); + } + + // THEN - All threads should complete successfully + for (boolean result : success) { + assertThat(result).isTrue(); + } + + // THEN - Each thread should have captured its own context (no leakage) + for (int i = 0; i < threadCount; i++) { + int expectedTime = (i + 1) * 1000; + assertThat(capturedRemainingTimes[i]) + .as("Thread %d should have remaining time %d", i, expectedTime) + .isEqualTo(expectedTime); + assertThat(capturedContexts[i]).as("Thread %d should have non-null context", i).isNotNull(); + } + } + } diff --git a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/testutils/InMemoryPersistenceStore.java b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/testutils/InMemoryPersistenceStore.java new file mode 100644 index 000000000..5fec80a3c --- /dev/null +++ b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/testutils/InMemoryPersistenceStore.java @@ -0,0 +1,58 @@ +/* + * 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.testutils; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemAlreadyExistsException; +import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemNotFoundException; +import software.amazon.lambda.powertools.idempotency.persistence.BasePersistenceStore; +import software.amazon.lambda.powertools.idempotency.persistence.DataRecord; + +/** + * In-memory implementation of BasePersistenceStore for testing purposes. + */ +public class InMemoryPersistenceStore extends BasePersistenceStore { + private final Map data = new HashMap<>(); + + @Override + public DataRecord getRecord(String idempotencyKey) throws IdempotencyItemNotFoundException { + DataRecord dr = data.get(idempotencyKey); + if (dr == null) { + throw new IdempotencyItemNotFoundException(idempotencyKey); + } + return dr; + } + + @Override + public void putRecord(DataRecord dr, Instant now) throws IdempotencyItemAlreadyExistsException { + if (data.containsKey(dr.getIdempotencyKey())) { + throw new IdempotencyItemAlreadyExistsException(); + } + data.put(dr.getIdempotencyKey(), dr); + } + + @Override + public void updateRecord(DataRecord dr) { + data.put(dr.getIdempotencyKey(), dr); + } + + @Override + public void deleteRecord(String idempotencyKey) { + data.remove(idempotencyKey); + } +} diff --git a/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/JsonConfig.java b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/JsonConfig.java index fc0f083e5..c6a986564 100644 --- a/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/JsonConfig.java +++ b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/JsonConfig.java @@ -14,19 +14,23 @@ package software.amazon.lambda.powertools.utilities; +import java.lang.reflect.Type; +import java.util.function.Supplier; + import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.json.JsonMapper; + import io.burt.jmespath.JmesPath; import io.burt.jmespath.RuntimeConfiguration; import io.burt.jmespath.function.BaseFunction; import io.burt.jmespath.function.FunctionRegistry; import io.burt.jmespath.jackson.JacksonRuntime; -import java.util.function.Supplier; import software.amazon.lambda.powertools.utilities.jmespath.Base64Function; import software.amazon.lambda.powertools.utilities.jmespath.Base64GZipFunction; import software.amazon.lambda.powertools.utilities.jmespath.JsonFunction; @@ -51,8 +55,7 @@ public final class JsonConfig { private final FunctionRegistry customFunctions = defaultFunctions.extend( new Base64Function(), new Base64GZipFunction(), - new JsonFunction() - ); + new JsonFunction()); private final RuntimeConfiguration configuration = new RuntimeConfiguration.Builder() .withSilentTypeErrors(true) @@ -77,6 +80,23 @@ public ObjectMapper getObjectMapper() { return om.get(); } + /** + * Creates a TypeReference from a Class for use with Jackson deserialization. + * This is useful when you need to convert a Class to a TypeReference for generic type handling. + * + * @param clazz the class to convert to TypeReference + * @param the type parameter + * @return a TypeReference wrapping the provided class + */ + public static TypeReference toTypeReference(Class clazz) { + return new TypeReference() { + @Override + public Type getType() { + return clazz; + } + }; + } + /** * Return the JmesPath used to select sub node of Json * @@ -103,7 +123,7 @@ public void addFunction(T function) { jmesPath = new JacksonRuntime(updatedConfig, getObjectMapper()); } - private static class ConfigHolder { + private static final class ConfigHolder { private static final JsonConfig instance = new JsonConfig(); } }
- * Idempotency.config().withPersistenceStore(...).configure(); - *
This class is thread-safe. All operations delegate to the underlying persistence store + * which handles concurrent access safely.
Configure the persistence layer and idempotency settings before your handler executes (e.g. in constructor):
{@code + * Idempotency.config() + * .withPersistenceStore(persistenceStore) + * .withConfig(idempotencyConfig) + * .configure(); + * }
Make methods idempotent without AspectJ annotations. Generic return types (e.g., {@code Map}, + * {@code List}) are supported via Jackson {@link TypeReference}.
Important: Always call {@link #registerLambdaContext(Context)} + * at the start of your handler to enable proper timeout handling.
Example usage with Function (single parameter):
{@code + * public Basket handleRequest(Product input, Context context) { + * Idempotency.registerLambdaContext(context); + * return Idempotency.makeIdempotent(this::processProduct, input, Basket.class); + * } + * + * private Basket processProduct(Product product) { + * // business logic + * } + * }
Example usage with Supplier (multi-parameter methods):
{@code + * public String handleRequest(SQSEvent event, Context context) { + * Idempotency.registerLambdaContext(context); + * return Idempotency.makeIdempotent( + * event.getRecords().get(0).getBody(), + * () -> processPayment(orderId, amount, currency), + * String.class + * ); + * } + * }
When different methods use the same payload as idempotency key, use explicit function names + * to differentiate between them:
{@code + * // Different methods, same payload + * Idempotency.makeIdempotent("processPayment", orderId, + * () -> processPayment(orderId), String.class); + * + * Idempotency.makeIdempotent("refundPayment", orderId, + * () -> refundPayment(orderId), String.class); + * }
This method is thread-safe and can be used in parallel processing scenarios + * such as batch processors.
This method is suitable for making methods idempotent that have more than one parameter. + * For simple single-parameter methods, {@link #makeIdempotent(Function, Object, Class)} is more intuitive.
Note: If you need to call different functions with the same payload, + * use {@link #makeIdempotent(String, Object, Supplier, Class)} to specify distinct function names. + * This ensures each function has its own idempotency scope.
For functions with more than one parameter, use {@link #makeIdempotent(Object, Supplier, Class)} instead.
Use this method when the return type contains generics (e.g., {@code Map}).
Example usage:
{@code + * Map result = Idempotency.makeIdempotent( + * payload, + * () -> processBaskets(), + * new TypeReference>() {} + * ); + * }
{@code + * Map result = Idempotency.makeIdempotent( + * this::processProduct, + * product, + * new TypeReference>() {} + * ); + * }
+ * This interface is similar to {@link java.util.concurrent.Callable} but throws {@link Throwable} + * instead of {@link Exception}. This is necessary to support AspectJ's {@code ProceedingJoinPoint.proceed()} + * which throws {@code Throwable}, allowing exceptions to bubble up naturally without wrapping. + * + * @param the return type of the function + */ +@FunctionalInterface +public interface IdempotentFunction { + @SuppressWarnings("java:S112") // Throwable is required for AspectJ compatibility + T execute() throws Throwable; +} diff --git a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCache.java b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCache.java index b57ad2977..a3bf1c5ff 100644 --- a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCache.java +++ b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCache.java @@ -35,7 +35,7 @@ public LRUCache(int capacity) { } @Override - protected boolean removeEldestEntry(Map.Entry entry) { - return (size() > this.capacity); + protected boolean removeEldestEntry(Map.Entry entry) { + return size() > this.capacity; } } diff --git a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/IdempotencyTest.java b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/IdempotencyTest.java new file mode 100644 index 000000000..10664a25b --- /dev/null +++ b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/IdempotencyTest.java @@ -0,0 +1,311 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import java.util.HashMap; +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.amazonaws.services.lambda.runtime.Context; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; + +import software.amazon.lambda.powertools.common.stubs.TestLambdaContext; +import software.amazon.lambda.powertools.idempotency.handlers.IdempotencyFunctionalFunction; +import software.amazon.lambda.powertools.idempotency.handlers.IdempotencyMultiArgFunctionalFunction; +import software.amazon.lambda.powertools.idempotency.model.Basket; +import software.amazon.lambda.powertools.idempotency.model.Product; +import software.amazon.lambda.powertools.idempotency.persistence.BasePersistenceStore; +import software.amazon.lambda.powertools.idempotency.testutils.InMemoryPersistenceStore; + +@ExtendWith(MockitoExtension.class) +class IdempotencyTest { + + private Context context = new TestLambdaContext(); + + @Mock + private BasePersistenceStore store; + + @Test + void firstCall_shouldPutInStore() { + Idempotency.config() + .withPersistenceStore(store) + .withConfig(IdempotencyConfig.builder() + .withEventKeyJMESPath("id") + .build()) + .configure(); + + IdempotencyFunctionalFunction function = new IdempotencyFunctionalFunction(); + + Product p = new Product(42, "fake product", 12); + Basket basket = function.handleRequest(p, context); + + assertThat(function.processCalled()).isTrue(); + assertThat(basket.getProducts()).hasSize(1); + + ArgumentCaptor nodeCaptor = ArgumentCaptor.forClass(JsonNode.class); + ArgumentCaptor expiryCaptor = ArgumentCaptor.forClass(OptionalInt.class); + verify(store).saveInProgress(nodeCaptor.capture(), any(), expiryCaptor.capture()); + assertThat(nodeCaptor.getValue().get("id").asLong()).isEqualTo(p.getId()); + assertThat(nodeCaptor.getValue().get("name").asText()).isEqualTo(p.getName()); + assertThat(nodeCaptor.getValue().get("price").asDouble()).isEqualTo(p.getPrice()); + assertThat(expiryCaptor.getValue().orElse(-1)).isEqualTo(30000); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(Basket.class); + verify(store).saveSuccess(any(), resultCaptor.capture(), any()); + assertThat(resultCaptor.getValue()).isEqualTo(basket); + } + + @Test + void testMakeIdempotentWithFunctionName() throws Throwable { + BasePersistenceStore spyStore = spy(BasePersistenceStore.class); + Idempotency.config() + .withPersistenceStore(spyStore) + .configure(); + Idempotency.registerLambdaContext(context); + + String result = Idempotency.makeIdempotent("myFunction", "test-key", () -> "test-result", + String.class); + + assertThat(result).isEqualTo("test-result"); + + ArgumentCaptor functionNameCaptor = ArgumentCaptor.forClass(String.class); + verify(spyStore).configure(any(), functionNameCaptor.capture()); + assertThat(functionNameCaptor.getValue()).isEqualTo("myFunction"); + } + + @Test + void testMakeIdempotentWithMethodReferenceUsesDefaultName() throws Throwable { + BasePersistenceStore spyStore = spy(BasePersistenceStore.class); + Idempotency.config() + .withPersistenceStore(spyStore) + .configure(); + Idempotency.registerLambdaContext(context); + + String result = Idempotency.makeIdempotent("test-key", this::helperMethod, String.class); + + assertThat(result).isEqualTo("helper-result"); + + ArgumentCaptor functionNameCaptor = ArgumentCaptor.forClass(String.class); + verify(spyStore).configure(any(), functionNameCaptor.capture()); + assertThat(functionNameCaptor.getValue()).isEqualTo("function"); + } + + private String helperMethod() { + return "helper-result"; + } + + @Test + void testMakeIdempotentWithFunctionOverload() throws Throwable { + BasePersistenceStore spyStore = spy(BasePersistenceStore.class); + Idempotency.config() + .withPersistenceStore(spyStore) + .configure(); + Idempotency.registerLambdaContext(context); + + Product p = new Product(42, "test product", 10); + Basket result = Idempotency.makeIdempotent(this::processProduct, p, Basket.class); + + assertThat(result.getProducts()).hasSize(1); + assertThat(result.getProducts().get(0)).isEqualTo(p); + + ArgumentCaptor functionNameCaptor = ArgumentCaptor.forClass(String.class); + verify(spyStore).configure(any(), functionNameCaptor.capture()); + assertThat(functionNameCaptor.getValue()).isEqualTo("function"); + + ArgumentCaptor nodeCaptor = ArgumentCaptor.forClass(JsonNode.class); + verify(spyStore).saveInProgress(nodeCaptor.capture(), any(), any()); + assertThat(nodeCaptor.getValue().get("id").asLong()).isEqualTo(p.getId()); + } + + private Basket processProduct(Product product) { + Basket basket = new Basket(); + basket.add(product); + return basket; + } + + @Test + void firstCall_withExplicitIdempotencyKey_shouldPutInStore() { + Idempotency.config() + .withPersistenceStore(store) + .configure(); + + IdempotencyMultiArgFunctionalFunction function = new IdempotencyMultiArgFunctionalFunction(); + + Product p = new Product(42, "fake product", 12); + Basket basket = function.handleRequest(p, context); + + assertThat(function.processCalled()).isTrue(); + assertThat(function.getExtraData()).isEqualTo("extra-data"); + assertThat(basket.getProducts()).hasSize(1); + + ArgumentCaptor nodeCaptor = ArgumentCaptor.forClass(JsonNode.class); + verify(store).saveInProgress(nodeCaptor.capture(), any(), any()); + assertThat(nodeCaptor.getValue().asLong()).isEqualTo(p.getId()); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(Basket.class); + verify(store).saveSuccess(any(), resultCaptor.capture(), any()); + assertThat(resultCaptor.getValue()).isEqualTo(basket); + } + + @Test + void secondCall_shouldRetrieveFromCacheAndDeserialize() throws Throwable { + InMemoryPersistenceStore inMemoryStore = new InMemoryPersistenceStore(); + + Idempotency.config() + .withPersistenceStore(inMemoryStore) + .configure(); + Idempotency.registerLambdaContext(context); + + Product p = new Product(42, "test product", 10); + int[] callCount = { 0 }; + + // First call - executes function and stores result + Basket result1 = Idempotency.makeIdempotent(p, () -> { + callCount[0]++; + return processProduct(p); + }, Basket.class); + assertThat(result1.getProducts()).hasSize(1); + assertThat(callCount[0]).isEqualTo(1); + + // Second call - should retrieve from cache, deserialize, and NOT execute function + Basket result2 = Idempotency.makeIdempotent(p, () -> { + callCount[0]++; + return processProduct(p); + }, Basket.class); + assertThat(result2.getProducts()).hasSize(1); + assertThat(result2.getProducts().get(0).getId()).isEqualTo(42); + assertThat(result2.getProducts().get(0).getName()).isEqualTo("test product"); + assertThat(callCount[0]).isEqualTo(1); // Function should NOT be called again + } + + @Test + void concurrentInvocations_shouldNotLeakContext() throws Exception { + Idempotency.config() + .withPersistenceStore(store) + .configure(); + + IdempotencyMultiArgFunctionalFunction function = new IdempotencyMultiArgFunctionalFunction(); + + // GIVEN + int threadCount = 10; + Thread[] threads = new Thread[threadCount]; + Context[] capturedContexts = new Context[threadCount]; + int[] capturedRemainingTimes = new int[threadCount]; + boolean[] success = new boolean[threadCount]; + + // WHEN - Multiple threads call handleRequest with different contexts + for (int i = 0; i < threadCount; i++) { + final int threadIndex = i; + final int expectedTime = (i + 1) * 2000; // 2000, 4000, 6000, ..., 20000 + + final Context threadContext = new TestLambdaContext() { + @Override + public int getRemainingTimeInMillis() { + return expectedTime; + } + }; + + threads[i] = new Thread(() -> { + try { + Product p = new Product(threadIndex, "product" + threadIndex, 10); + function.handleRequest(p, threadContext); + + // Capture the context that was actually stored in ThreadLocal by this thread + Context captured = Idempotency.getInstance().getConfig().getLambdaContext(); + capturedContexts[threadIndex] = captured; + capturedRemainingTimes[threadIndex] = captured != null ? captured.getRemainingTimeInMillis() : -1; + success[threadIndex] = true; + } catch (Exception e) { + success[threadIndex] = false; + } + }); + } + + // Start all threads + for (Thread thread : threads) { + thread.start(); + } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(); + } + + // THEN - All threads should complete successfully + for (boolean result : success) { + assertThat(result).isTrue(); + } + + // THEN - Each thread should have captured its own context (no leakage) + for (int i = 0; i < threadCount; i++) { + int expectedTime = (i + 1) * 2000; + assertThat(capturedRemainingTimes[i]) + .as("Thread %d should have remaining time %d", i, expectedTime) + .isEqualTo(expectedTime); + assertThat(capturedContexts[i]).as("Thread %d should have non-null context", i).isNotNull(); + } + } + + @Test + void testMakeIdempotentWithGenericType() throws Throwable { + InMemoryPersistenceStore inMemoryStore = new InMemoryPersistenceStore(); + + Idempotency.config() + .withPersistenceStore(inMemoryStore) + .configure(); + Idempotency.registerLambdaContext(context); + + int[] callCount = { 0 }; + + // First call - executes function and stores result + Map result1 = Idempotency.makeIdempotent("test-key", () -> { + callCount[0]++; + Map map = new HashMap<>(); + Basket basket = new Basket(); + basket.add(new Product(1, "product1", 10)); + map.put("basket1", basket); + return map; + }, new TypeReference>() { + }); + + assertThat(result1).hasSize(1); + assertThat(result1.get("basket1").getProducts()).hasSize(1); + assertThat(callCount[0]).isEqualTo(1); + + // Second call - should retrieve from cache and deserialize correctly + Map result2 = Idempotency.makeIdempotent("test-key", () -> { + callCount[0]++; + return new HashMap<>(); + }, new TypeReference>() { + }); + + assertThat(result2).hasSize(1); + assertThat(result2.get("basket1").getProducts()).hasSize(1); + assertThat(result2.get("basket1").getProducts().get(0).getName()).isEqualTo("product1"); + assertThat(callCount[0]).isEqualTo(1); // Function should NOT be called again + } +} diff --git a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyFunctionalFunction.java b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyFunctionalFunction.java new file mode 100644 index 000000000..c2a85d178 --- /dev/null +++ b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyFunctionalFunction.java @@ -0,0 +1,48 @@ +/* + * 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.handlers; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +import software.amazon.lambda.powertools.idempotency.Idempotency; +import software.amazon.lambda.powertools.idempotency.model.Basket; +import software.amazon.lambda.powertools.idempotency.model.Product; + +/** + * Lambda function using Idempotency functional API without AspectJ annotations + */ +public class IdempotencyFunctionalFunction implements RequestHandler { + + private boolean processCalled = false; + + public boolean processCalled() { + return processCalled; + } + + @Override + public Basket handleRequest(Product input, Context context) { + Idempotency.registerLambdaContext(context); + + return Idempotency.makeIdempotent(this::process, input, Basket.class); + } + + private Basket process(Product input) { + processCalled = true; + Basket b = new Basket(); + b.add(input); + return b; + } +} diff --git a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyMultiArgFunctionalFunction.java b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyMultiArgFunctionalFunction.java new file mode 100644 index 000000000..42e75fd55 --- /dev/null +++ b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyMultiArgFunctionalFunction.java @@ -0,0 +1,54 @@ +/* + * 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.handlers; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +import software.amazon.lambda.powertools.idempotency.Idempotency; +import software.amazon.lambda.powertools.idempotency.model.Basket; +import software.amazon.lambda.powertools.idempotency.model.Product; + +/** + * Lambda function using Idempotency functional API with explicit idempotency key + */ +public class IdempotencyMultiArgFunctionalFunction implements RequestHandler { + + private boolean processCalled = false; + private String extraData; + + public boolean processCalled() { + return processCalled; + } + + public String getExtraData() { + return extraData; + } + + @Override + public Basket handleRequest(Product input, Context context) { + Idempotency.registerLambdaContext(context); + + return Idempotency.makeIdempotent(input.getId(), () -> process(input, "extra-data"), Basket.class); + } + + private Basket process(Product input, String extraData) { + processCalled = true; + this.extraData = extraData; + Basket b = new Basket(); + b.add(input); + return b; + } +} diff --git a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyAspectTest.java b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyAspectTest.java index 0ccb1e5aa..384a20b2a 100644 --- a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyAspectTest.java +++ b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyAspectTest.java @@ -194,7 +194,7 @@ void secondCall_notExpired_shouldNotGetFromStoreIfPresentOnIdempotencyException( "Test message", new RuntimeException("Test Cause"), dr)) - .when(store).saveInProgress(any(), any(), any()); + .when(store).saveInProgress(any(), any(), any()); // WHEN IdempotencyEnabledFunction function = new IdempotencyEnabledFunction(); @@ -538,4 +538,73 @@ void idempotencyOnSubMethodVoid_shouldThrowException() { IdempotencyConfigurationException.class); } + @Test + void concurrentInvocations_shouldNotLeakContext() throws Exception { + Idempotency.config() + .withPersistenceStore(store) + .configure(); + + // Use IdempotencyInternalFunction which calls registerLambdaContext + IdempotencyInternalFunction function = new IdempotencyInternalFunction(true); + + // GIVEN + int threadCount = 10; + Thread[] threads = new Thread[threadCount]; + Context[] capturedContexts = new Context[threadCount]; + int[] capturedRemainingTimes = new int[threadCount]; + boolean[] success = new boolean[threadCount]; + + // WHEN - Multiple threads call handleRequest with different contexts + for (int i = 0; i < threadCount; i++) { + final int threadIndex = i; + final int expectedTime = (i + 1) * 1000; // 1000, 2000, 3000, ..., 10000 + + final Context threadContext = new TestLambdaContext() { + @Override + public int getRemainingTimeInMillis() { + return expectedTime; + } + }; + + threads[i] = new Thread(() -> { + try { + Product p = new Product(threadIndex, "product" + threadIndex, 10); + function.handleRequest(p, threadContext); + + // Capture the context that was actually stored in ThreadLocal by this thread + Context captured = Idempotency.getInstance().getConfig().getLambdaContext(); + capturedContexts[threadIndex] = captured; + capturedRemainingTimes[threadIndex] = captured != null ? captured.getRemainingTimeInMillis() : -1; + success[threadIndex] = true; + } catch (Exception e) { + success[threadIndex] = false; + } + }); + } + + // Start all threads + for (Thread thread : threads) { + thread.start(); + } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(); + } + + // THEN - All threads should complete successfully + for (boolean result : success) { + assertThat(result).isTrue(); + } + + // THEN - Each thread should have captured its own context (no leakage) + for (int i = 0; i < threadCount; i++) { + int expectedTime = (i + 1) * 1000; + assertThat(capturedRemainingTimes[i]) + .as("Thread %d should have remaining time %d", i, expectedTime) + .isEqualTo(expectedTime); + assertThat(capturedContexts[i]).as("Thread %d should have non-null context", i).isNotNull(); + } + } + } diff --git a/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/testutils/InMemoryPersistenceStore.java b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/testutils/InMemoryPersistenceStore.java new file mode 100644 index 000000000..5fec80a3c --- /dev/null +++ b/powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/testutils/InMemoryPersistenceStore.java @@ -0,0 +1,58 @@ +/* + * 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.testutils; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemAlreadyExistsException; +import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemNotFoundException; +import software.amazon.lambda.powertools.idempotency.persistence.BasePersistenceStore; +import software.amazon.lambda.powertools.idempotency.persistence.DataRecord; + +/** + * In-memory implementation of BasePersistenceStore for testing purposes. + */ +public class InMemoryPersistenceStore extends BasePersistenceStore { + private final Map data = new HashMap<>(); + + @Override + public DataRecord getRecord(String idempotencyKey) throws IdempotencyItemNotFoundException { + DataRecord dr = data.get(idempotencyKey); + if (dr == null) { + throw new IdempotencyItemNotFoundException(idempotencyKey); + } + return dr; + } + + @Override + public void putRecord(DataRecord dr, Instant now) throws IdempotencyItemAlreadyExistsException { + if (data.containsKey(dr.getIdempotencyKey())) { + throw new IdempotencyItemAlreadyExistsException(); + } + data.put(dr.getIdempotencyKey(), dr); + } + + @Override + public void updateRecord(DataRecord dr) { + data.put(dr.getIdempotencyKey(), dr); + } + + @Override + public void deleteRecord(String idempotencyKey) { + data.remove(idempotencyKey); + } +} diff --git a/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/JsonConfig.java b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/JsonConfig.java index fc0f083e5..c6a986564 100644 --- a/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/JsonConfig.java +++ b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/JsonConfig.java @@ -14,19 +14,23 @@ package software.amazon.lambda.powertools.utilities; +import java.lang.reflect.Type; +import java.util.function.Supplier; + import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.json.JsonMapper; + import io.burt.jmespath.JmesPath; import io.burt.jmespath.RuntimeConfiguration; import io.burt.jmespath.function.BaseFunction; import io.burt.jmespath.function.FunctionRegistry; import io.burt.jmespath.jackson.JacksonRuntime; -import java.util.function.Supplier; import software.amazon.lambda.powertools.utilities.jmespath.Base64Function; import software.amazon.lambda.powertools.utilities.jmespath.Base64GZipFunction; import software.amazon.lambda.powertools.utilities.jmespath.JsonFunction; @@ -51,8 +55,7 @@ public final class JsonConfig { private final FunctionRegistry customFunctions = defaultFunctions.extend( new Base64Function(), new Base64GZipFunction(), - new JsonFunction() - ); + new JsonFunction()); private final RuntimeConfiguration configuration = new RuntimeConfiguration.Builder() .withSilentTypeErrors(true) @@ -77,6 +80,23 @@ public ObjectMapper getObjectMapper() { return om.get(); } + /** + * Creates a TypeReference from a Class for use with Jackson deserialization. + * This is useful when you need to convert a Class to a TypeReference for generic type handling. + * + * @param clazz the class to convert to TypeReference + * @param the type parameter + * @return a TypeReference wrapping the provided class + */ + public static TypeReference toTypeReference(Class clazz) { + return new TypeReference() { + @Override + public Type getType() { + return clazz; + } + }; + } + /** * Return the JmesPath used to select sub node of Json * @@ -103,7 +123,7 @@ public void addFunction(T function) { jmesPath = new JacksonRuntime(updatedConfig, getObjectMapper()); } - private static class ConfigHolder { + private static final class ConfigHolder { private static final JsonConfig instance = new JsonConfig(); } }