From 869fc0c88e38f1cdab1cfd3dffa4600330031944 Mon Sep 17 00:00:00 2001 From: Alex Wang Date: Wed, 12 Aug 2026 20:23:41 +0000 Subject: [PATCH 1/4] feat(plugin): surface execution input and result on invocation hooks Adds InvocationInfo.executionInput (the deserialized handler input) plus InvocationEndInfo.executionInput/executionResult (the value the handler returned, populated only when the invocation completed the execution successfully), so instrumentation plugins can record execution I/O. Both records keep a constructor at their previous arity, so existing callers compile unchanged. The plugin-facing deserialization is skipped when no plugins are registered and yields null on failure, leaving the authoritative input extraction to surface errors at its original point. Mirrors the JS SDK's InvocationBaseInfo.executionInput / InvocationEndInfo.executionResult and the Python SDK's execution_input/execution_result (aws-durable-execution-sdk-python#616). --- .../lambda/durable/PluginIntegrationTest.java | 72 +++++++++++++++++++ .../durable/execution/DurableExecutor.java | 49 +++++++++++-- .../durable/plugin/InvocationEndInfo.java | 31 +++++++- .../lambda/durable/plugin/InvocationInfo.java | 26 ++++++- .../durable/plugin/PluginRunnerTest.java | 35 +++++++++ 5 files changed, 205 insertions(+), 8 deletions(-) diff --git a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java index 5bd794b9c..226882147 100644 --- a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java +++ b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java @@ -122,6 +122,78 @@ void plugin_receivesInvocationEnd_withFailedStatus_onError() { assertNotNull(plugin.invocationEnds.get(0).executionError()); } + @Test + void plugin_invocationHooks_carryExecutionInputAndResult_onSuccess() { + var plugin = new RecordingPlugin(); + var config = DurableConfig.builder().withPlugins(plugin).build(); + + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> context.step("greet", String.class, stepCtx -> "Hello " + input), + config); + + var result = runner.runUntilComplete("World"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + // The deserialized handler input reaches the start hook ... + assertEquals("World", plugin.invocationStarts.get(0).executionInput()); + // ... and the end hook, alongside the value the handler returned. + var end = plugin.invocationEnds.get(0); + assertEquals("World", end.executionInput()); + assertEquals("Hello World", end.executionResult()); + } + + @Test + void plugin_invocationEnd_omitsExecutionResult_onFailure() { + var plugin = new RecordingPlugin(); + var config = DurableConfig.builder().withPlugins(plugin).build(); + + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> context.step( + "failing", + String.class, + stepCtx -> { + throw new RuntimeException("boom"); + }, + StepConfig.builder() + .retryStrategy(RetryStrategies.Presets.NO_RETRY) + .build()), + config); + + var result = runner.run("input"); + + assertEquals(ExecutionStatus.FAILED, result.getStatus()); + + var end = plugin.invocationEnds.get(0); + assertEquals("input", end.executionInput()); + assertNull(end.executionResult(), "a failed execution produced no result"); + } + + @Test + void plugin_invocationEnd_omitsExecutionResult_onSuspension() { + var plugin = new RecordingPlugin(); + var config = DurableConfig.builder().withPlugins(plugin).build(); + + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> { + context.wait("pause", Duration.ofMinutes(5)); + return "complete"; + }, + config); + + var result = runner.run("input"); + + assertEquals(ExecutionStatus.PENDING, result.getStatus()); + + var end = plugin.invocationEnds.get(0); + assertEquals(InvocationStatus.PENDING, end.invocationStatus()); + assertEquals("input", end.executionInput()); + assertNull(end.executionResult(), "a suspended invocation has not produced a result yet"); + } + // ─── Operation-level hooks ─────────────────────────────────────────── @Test diff --git a/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java b/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java index 6ab6bdbc3..080e745dc 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java @@ -7,6 +7,7 @@ import java.nio.charset.StandardCharsets; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -60,6 +61,8 @@ public static DurableExecutionOutput execute( var executionArn = input.durableExecutionArn(); executionManager.registerActiveThread(null); + // Captured for onInvocationEnd, which runs outside the handler thread below. + var pluginExecutionInput = new AtomicReference<>(); var handlerFuture = CompletableFuture.supplyAsync( () -> { executionManager.setCurrentThreadContext(new ThreadContext(null, ThreadType.CONTEXT)); @@ -67,11 +70,14 @@ public static DurableExecutionOutput execute( // onInvocationStart runs on the user thread so plugins can // inject ThreadLocal objects, update MDC, etc. // executionStartTime comes from the initial EXECUTION operation in the first backend event. + pluginExecutionInput.set(extractUserInputForPlugins( + pluginRunner, executionManager.getExecutionOperation(), config.getSerDes(), inputType)); pluginRunner.onInvocationStart(new InvocationInfo( requestId, executionArn, isFirstInvocation, - executionManager.getExecutionOperation().startTimestamp())); + executionManager.getExecutionOperation().startTimestamp(), + pluginExecutionInput.get())); var userInput = extractUserInput( executionManager.getExecutionOperation(), config.getSerDes(), inputType); @@ -103,6 +109,8 @@ public static DurableExecutionOutput execute( executionArn, isFirstInvocation, InvocationStatus.PENDING, + null, + pluginExecutionInput.get(), null); return DurableExecutionOutput.pending(); } @@ -119,7 +127,9 @@ public static DurableExecutionOutput execute( executionArn, isFirstInvocation, InvocationStatus.RETRYING, - cause); + cause, + pluginExecutionInput.get(), + null); throw unrecoverableDurableExecutionException; } @@ -131,7 +141,9 @@ public static DurableExecutionOutput execute( executionArn, isFirstInvocation, InvocationStatus.FAILED, - cause); + cause, + pluginExecutionInput.get(), + null); return DurableExecutionOutput.failure(buildErrorObject(cause, config.getSerDes())); } // user handler complete successfully @@ -145,7 +157,9 @@ public static DurableExecutionOutput execute( executionArn, isFirstInvocation, InvocationStatus.SUCCEEDED, - null); + null, + pluginExecutionInput.get(), + result); return output; }) .join(); @@ -163,8 +177,31 @@ private static void fireOnInvocationEnd( String executionArn, boolean isFirstInvocation, InvocationStatus status, - Throwable error) { - pluginRunner.onInvocationEnd(new InvocationEndInfo(requestId, executionArn, isFirstInvocation, status, error)); + Throwable error, + Object executionInput, + Object executionResult) { + pluginRunner.onInvocationEnd(new InvocationEndInfo( + requestId, executionArn, isFirstInvocation, status, error, executionInput, executionResult)); + } + + /** + * Deserializes the execution input for the plugin hooks, or returns null when it is not needed or not available. + * + *

Skipped entirely when no plugins are registered, so plugin-less executions pay nothing. A deserialization + * failure yields null rather than propagating: the authoritative extraction below still surfaces the error at its + * original point, so the plugin hooks must not change when the invocation fails. + */ + private static Object extractUserInputForPlugins( + PluginRunner pluginRunner, Operation executionOp, SerDes serDes, TypeToken inputType) { + if (pluginRunner.isEmpty()) { + return null; + } + try { + return extractUserInput(executionOp, serDes, inputType); + } catch (RuntimeException e) { + logger.debug("Could not deserialize execution input for plugins: {}", e.getMessage()); + return null; + } } private static String handleLargePayload(ExecutionManager executionManager, String outputPayload) { diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java index dd49f0571..b5e4178bf 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java @@ -12,10 +12,39 @@ * @param isFirstInvocation true if this is the first invocation of the execution * @param invocationStatus the invocation outcome (SUCCEEDED, FAILED, or PENDING) * @param executionError non-null if the execution failed; this component is experimental + * @param executionInput the deserialized execution input passed to the user handler, or null when no plugins are + * registered or the input could not be deserialized. This is a preview API that is experimental and may be changed + * or removed in future releases. + * @param executionResult the value the user handler returned, or null unless the invocation completed the execution + * successfully. This is a preview API that is experimental and may be changed or removed in future releases. */ public record InvocationEndInfo( String requestId, String durableExecutionArn, boolean isFirstInvocation, InvocationStatus invocationStatus, - @Experimental Throwable executionError) {} + @Experimental Throwable executionError, + @Deprecated Object executionInput, + @Deprecated Object executionResult) { + + /** + * Creates invocation-end information without the execution input or result. + * + *

Retained so callers written before {@code executionInput} and {@code executionResult} were added keep + * compiling; both resolve to null. + * + * @param requestId the Lambda request ID for this invocation + * @param durableExecutionArn the durable execution ARN + * @param isFirstInvocation true if this is the first invocation of the execution + * @param invocationStatus the invocation outcome + * @param executionError non-null if the execution failed + */ + public InvocationEndInfo( + String requestId, + String durableExecutionArn, + boolean isFirstInvocation, + InvocationStatus invocationStatus, + Throwable executionError) { + this(requestId, durableExecutionArn, isFirstInvocation, invocationStatus, executionError, null, null); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java index 89af7e189..fe4529ef4 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java @@ -12,6 +12,30 @@ * @param isFirstInvocation true if this is the first invocation of the execution (not a replay invocation) * @param executionStartTime the start timestamp of the durable execution, taken from the initial EXECUTION operation in * the first event delivered by the backend. Stable across all invocations of the same execution. + * @param executionInput the deserialized execution input passed to the user handler, or null when no plugins are + * registered or the input could not be deserialized. This is a preview API that is experimental and may be changed + * or removed in future releases. */ public record InvocationInfo( - String requestId, String durableExecutionArn, boolean isFirstInvocation, Instant executionStartTime) {} + String requestId, + String durableExecutionArn, + boolean isFirstInvocation, + Instant executionStartTime, + @Deprecated Object executionInput) { + + /** + * Creates invocation information without an execution input. + * + *

Retained so callers written before {@code executionInput} was added keep compiling; {@code executionInput} + * resolves to null. + * + * @param requestId the Lambda request ID for this invocation + * @param durableExecutionArn the durable execution ARN + * @param isFirstInvocation true if this is the first invocation of the execution + * @param executionStartTime the start timestamp of the durable execution + */ + public InvocationInfo( + String requestId, String durableExecutionArn, boolean isFirstInvocation, Instant executionStartTime) { + this(requestId, durableExecutionArn, isFirstInvocation, executionStartTime, null); + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java b/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java index d34b5a8ca..60caab1bb 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java @@ -133,6 +133,41 @@ void pluginRunner_isImmutable() { assertEquals(List.of("p1:onInvocationStart"), calls); } + // ─── Execution input / result components ───────────────────────────── + + @Test + void invocationInfo_compatibilityConstructor_leavesExecutionInputNull() { + var info = new InvocationInfo("req-123", "arn:test", false, Instant.now()); + + assertNull(info.executionInput()); + } + + @Test + void invocationInfo_carriesExecutionInput() { + var input = Map.of("name", "World"); + + var info = new InvocationInfo("req-123", "arn:test", true, Instant.now(), input); + + assertEquals(input, info.executionInput()); + } + + @Test + void invocationEndInfo_compatibilityConstructor_leavesExecutionInputAndResultNull() { + var info = new InvocationEndInfo("req-123", "arn:test", false, InvocationStatus.SUCCEEDED, null); + + assertNull(info.executionInput()); + assertNull(info.executionResult()); + } + + @Test + void invocationEndInfo_carriesExecutionInputAndResult() { + var info = new InvocationEndInfo( + "req-123", "arn:test", false, InvocationStatus.SUCCEEDED, null, "World", "Hello World"); + + assertEquals("World", info.executionInput()); + assertEquals("Hello World", info.executionResult()); + } + // ─── Helper methods ────────────────────────────────────────────────── private static InvocationInfo invocationInfo() { From 48a03038554742225c2e3de38b2682262961e1ee Mon Sep 17 00:00:00 2001 From: Alex Wang Date: Wed, 12 Aug 2026 21:02:04 +0000 Subject: [PATCH 2/4] style(plugin): mark new hook components @Experimental Matches the convention established by #620, which replaced the preview-API @Deprecated markers with @Experimental on the individual record components (as on InvocationEndInfo.executionError). --- .../amazon/lambda/durable/plugin/InvocationEndInfo.java | 9 ++++----- .../amazon/lambda/durable/plugin/InvocationInfo.java | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java index b5e4178bf..1ae0bf301 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java @@ -13,10 +13,9 @@ * @param invocationStatus the invocation outcome (SUCCEEDED, FAILED, or PENDING) * @param executionError non-null if the execution failed; this component is experimental * @param executionInput the deserialized execution input passed to the user handler, or null when no plugins are - * registered or the input could not be deserialized. This is a preview API that is experimental and may be changed - * or removed in future releases. + * registered or the input could not be deserialized; this component is experimental * @param executionResult the value the user handler returned, or null unless the invocation completed the execution - * successfully. This is a preview API that is experimental and may be changed or removed in future releases. + * successfully; this component is experimental */ public record InvocationEndInfo( String requestId, @@ -24,8 +23,8 @@ public record InvocationEndInfo( boolean isFirstInvocation, InvocationStatus invocationStatus, @Experimental Throwable executionError, - @Deprecated Object executionInput, - @Deprecated Object executionResult) { + @Experimental Object executionInput, + @Experimental Object executionResult) { /** * Creates invocation-end information without the execution input or result. diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java index fe4529ef4..0cc6d96bd 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java @@ -3,6 +3,7 @@ package software.amazon.lambda.durable.plugin; import java.time.Instant; +import software.amazon.lambda.durable.annotations.Experimental; /** * Invocation-level information available to plugin hooks. @@ -13,15 +14,14 @@ * @param executionStartTime the start timestamp of the durable execution, taken from the initial EXECUTION operation in * the first event delivered by the backend. Stable across all invocations of the same execution. * @param executionInput the deserialized execution input passed to the user handler, or null when no plugins are - * registered or the input could not be deserialized. This is a preview API that is experimental and may be changed - * or removed in future releases. + * registered or the input could not be deserialized; this component is experimental */ public record InvocationInfo( String requestId, String durableExecutionArn, boolean isFirstInvocation, Instant executionStartTime, - @Deprecated Object executionInput) { + @Experimental Object executionInput) { /** * Creates invocation information without an execution input. From 71fd8ba31ab1733f2b8ec92a6412c1d66c613d75 Mon Sep 17 00:00:00 2001 From: Alex Wang Date: Wed, 12 Aug 2026 21:29:09 +0000 Subject: [PATCH 3/4] fix(plugin): share one deserialized input and keep payloads out of toString Review feedback: - Deserialize the handler input once and hand the same instance to the plugin hooks and the handler. The previous plugin-only extraction deserialized a second time, doubling the cost, giving plugins a different object than the handler, and re-running side effects in a stateful custom SerDes. A deserialization failure is now captured and rethrown after onInvocationStart, so the start and end hooks stay paired as before. - Override toString() on both invocation records to omit executionInput and executionResult. Records render every component, so plugins that log the info object whole would have begun emitting customer payloads (possibly secrets or personal data). Output is otherwise unchanged from before this branch; mirrors repr=False on the Python fields. --- .../lambda/durable/PluginIntegrationTest.java | 55 +++++++++++++++++++ .../durable/execution/DurableExecutor.java | 43 ++++++--------- .../durable/plugin/InvocationEndInfo.java | 14 +++++ .../lambda/durable/plugin/InvocationInfo.java | 13 +++++ .../durable/plugin/PluginRunnerTest.java | 24 ++++++++ 5 files changed, 124 insertions(+), 25 deletions(-) diff --git a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java index 226882147..d0b94ef5b 100644 --- a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java +++ b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java @@ -8,7 +8,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import software.amazon.awssdk.services.lambda.model.ErrorObject; import software.amazon.awssdk.services.lambda.model.OperationStatus; @@ -19,6 +22,8 @@ import software.amazon.lambda.durable.model.WaitForConditionResult; import software.amazon.lambda.durable.plugin.*; import software.amazon.lambda.durable.retry.RetryStrategies; +import software.amazon.lambda.durable.serde.JacksonSerDes; +import software.amazon.lambda.durable.serde.SerDes; import software.amazon.lambda.durable.testing.LocalDurableTestRunner; /** Integration tests verifying plugin hooks fire correctly during durable execution lifecycle. */ @@ -194,6 +199,56 @@ void plugin_invocationEnd_omitsExecutionResult_onSuspension() { assertNull(end.executionResult(), "a suspended invocation has not produced a result yet"); } + @Test + void plugin_executionInput_isDeserializedOnce_andSharedWithHandler() { + var serDes = new CountingSerDes(); + var plugin = new RecordingPlugin(); + var config = + DurableConfig.builder().withPlugins(plugin).withSerDes(serDes).build(); + var handlerInput = new AtomicReference(); + + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> { + handlerInput.set(input); + return context.step("greet", String.class, stepCtx -> "Hello " + input); + }, + config); + + var result = runner.runUntilComplete("World"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + // The handler input is deserialized once and that same instance reaches the hooks — a second + // deserialization would double the cost and re-run side effects in a stateful SerDes. + assertEquals(1, serDes.inputDeserializations("World")); + assertSame(handlerInput.get(), plugin.invocationStarts.get(0).executionInput()); + assertSame(handlerInput.get(), plugin.invocationEnds.get(0).executionInput()); + } + + /** SerDes that counts how many times each payload is deserialized. */ + static class CountingSerDes implements SerDes { + private final JacksonSerDes delegate = new JacksonSerDes(); + private final Map deserializations = new ConcurrentHashMap<>(); + + @Override + public String serialize(Object value) { + return delegate.serialize(value); + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + deserializations + .computeIfAbsent(data, ignored -> new AtomicInteger()) + .incrementAndGet(); + return delegate.deserialize(data, typeToken); + } + + int inputDeserializations(String value) { + var counter = deserializations.get(delegate.serialize(value)); + return counter == null ? 0 : counter.get(); + } + } + // ─── Operation-level hooks ─────────────────────────────────────────── @Test diff --git a/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java b/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java index 080e745dc..69c301991 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java @@ -67,20 +67,33 @@ public static DurableExecutionOutput execute( () -> { executionManager.setCurrentThreadContext(new ThreadContext(null, ThreadType.CONTEXT)); + // Deserialize once and share the value with the plugin hooks and the handler below. A second + // deserialization would double the cost, hand plugins a different object than the handler, and + // re-run any side effects in a stateful custom SerDes. A failure is captured rather than thrown + // so onInvocationStart still fires before it surfaces, keeping the start/end hooks paired. + I userInput = null; + RuntimeException inputFailure = null; + try { + userInput = extractUserInput( + executionManager.getExecutionOperation(), config.getSerDes(), inputType); + } catch (RuntimeException e) { + inputFailure = e; + } + pluginExecutionInput.set(userInput); + // onInvocationStart runs on the user thread so plugins can // inject ThreadLocal objects, update MDC, etc. // executionStartTime comes from the initial EXECUTION operation in the first backend event. - pluginExecutionInput.set(extractUserInputForPlugins( - pluginRunner, executionManager.getExecutionOperation(), config.getSerDes(), inputType)); pluginRunner.onInvocationStart(new InvocationInfo( requestId, executionArn, isFirstInvocation, executionManager.getExecutionOperation().startTimestamp(), - pluginExecutionInput.get())); + userInput)); + if (inputFailure != null) { + throw inputFailure; + } - var userInput = extractUserInput( - executionManager.getExecutionOperation(), config.getSerDes(), inputType); var context = DurableContextImpl.createRootContext(executionManager, config, lambdaContext); DurableContextImpl.setCurrentContext(context); // use a try-with-resources to clear logger properties @@ -184,26 +197,6 @@ private static void fireOnInvocationEnd( requestId, executionArn, isFirstInvocation, status, error, executionInput, executionResult)); } - /** - * Deserializes the execution input for the plugin hooks, or returns null when it is not needed or not available. - * - *

Skipped entirely when no plugins are registered, so plugin-less executions pay nothing. A deserialization - * failure yields null rather than propagating: the authoritative extraction below still surfaces the error at its - * original point, so the plugin hooks must not change when the invocation fails. - */ - private static Object extractUserInputForPlugins( - PluginRunner pluginRunner, Operation executionOp, SerDes serDes, TypeToken inputType) { - if (pluginRunner.isEmpty()) { - return null; - } - try { - return extractUserInput(executionOp, serDes, inputType); - } catch (RuntimeException e) { - logger.debug("Could not deserialize execution input for plugins: {}", e.getMessage()); - return null; - } - } - private static String handleLargePayload(ExecutionManager executionManager, String outputPayload) { // Check if the serialized payload exceeds Lambda response size limit var payloadSize = outputPayload != null ? outputPayload.getBytes(StandardCharsets.UTF_8).length : 0; diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java index 1ae0bf301..c187700f6 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java @@ -46,4 +46,18 @@ public InvocationEndInfo( Throwable executionError) { this(requestId, durableExecutionArn, isFirstInvocation, invocationStatus, executionError, null, null); } + + /** + * Returns a representation that omits {@code executionInput} and {@code executionResult}. + * + *

The generated representation would render both payloads, so plugins that log this object whole would start + * emitting customer inputs and results, potentially including secrets or personal data. Read the components + * explicitly to record them. + */ + @Override + public String toString() { + return "InvocationEndInfo[requestId=" + requestId + ", durableExecutionArn=" + durableExecutionArn + + ", isFirstInvocation=" + isFirstInvocation + ", invocationStatus=" + invocationStatus + + ", executionError=" + executionError + "]"; + } } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java index 0cc6d96bd..c384cf887 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java @@ -38,4 +38,17 @@ public InvocationInfo( String requestId, String durableExecutionArn, boolean isFirstInvocation, Instant executionStartTime) { this(requestId, durableExecutionArn, isFirstInvocation, executionStartTime, null); } + + /** + * Returns a representation that omits {@code executionInput}. + * + *

The generated representation would render the execution input, so plugins that log this object whole would + * start emitting customer payloads, potentially including secrets or personal data. Read the component explicitly + * to record it. + */ + @Override + public String toString() { + return "InvocationInfo[requestId=" + requestId + ", durableExecutionArn=" + durableExecutionArn + + ", isFirstInvocation=" + isFirstInvocation + ", executionStartTime=" + executionStartTime + "]"; + } } diff --git a/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java b/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java index 60caab1bb..ed15499c9 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java @@ -168,6 +168,30 @@ void invocationEndInfo_carriesExecutionInputAndResult() { assertEquals("Hello World", info.executionResult()); } + @Test + void invocationInfo_toString_omitsExecutionInput() { + var info = new InvocationInfo("req-123", "arn:test", true, Instant.now(), "s3cret-input"); + + var rendered = info.toString(); + + assertFalse(rendered.contains("s3cret-input"), "execution input must not leak into logs"); + assertTrue(rendered.contains("req-123")); + assertTrue(rendered.contains("arn:test")); + } + + @Test + void invocationEndInfo_toString_omitsExecutionInputAndResult() { + var info = new InvocationEndInfo( + "req-123", "arn:test", false, InvocationStatus.SUCCEEDED, null, "s3cret-input", "s3cret-result"); + + var rendered = info.toString(); + + assertFalse(rendered.contains("s3cret-input"), "execution input must not leak into logs"); + assertFalse(rendered.contains("s3cret-result"), "execution result must not leak into logs"); + assertTrue(rendered.contains("req-123")); + assertTrue(rendered.contains("SUCCEEDED")); + } + // ─── Helper methods ────────────────────────────────────────────────── private static InvocationInfo invocationInfo() { From 38dbb45d6c072402fa3d3fb39e1624a8d3f986f5 Mon Sep 17 00:00:00 2001 From: Alex Wang Date: Wed, 12 Aug 2026 21:55:03 +0000 Subject: [PATCH 4/4] fix(plugin): keep hooks paired for sneaky checked exceptions SerDes.deserialize declares no checked exceptions, so an implementation may sneaky-throw one (as DurableInputOutputSerDes does for IOException). A RuntimeException-only capture let such a failure bypass onInvocationStart while the future's failure path still fired onInvocationEnd(FAILED), leaving a plugin that keys state on the start hook with an unpaired end. Capture Throwable and rethrow it unchanged via ExceptionHelper.sneakyThrow after the start hook. Adds a regression test with a SerDes that sneaky-throws IOException: verified failing (start hook count 0) with the narrower capture. --- .../lambda/durable/PluginIntegrationTest.java | 41 +++++++++++++++++++ .../durable/execution/DurableExecutor.java | 10 +++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java index d0b94ef5b..648488034 100644 --- a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java +++ b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.*; +import java.io.IOException; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; @@ -25,6 +26,7 @@ import software.amazon.lambda.durable.serde.JacksonSerDes; import software.amazon.lambda.durable.serde.SerDes; import software.amazon.lambda.durable.testing.LocalDurableTestRunner; +import software.amazon.lambda.durable.util.ExceptionHelper; /** Integration tests verifying plugin hooks fire correctly during durable execution lifecycle. */ class PluginIntegrationTest { @@ -249,6 +251,45 @@ int inputDeserializations(String value) { } } + @Test + void plugin_hooksStayPaired_whenSerDesSneakyThrowsCheckedException() { + var plugin = new RecordingPlugin(); + var config = DurableConfig.builder() + .withPlugins(plugin) + .withSerDes(new SneakyThrowingSerDes()) + .build(); + + var runner = LocalDurableTestRunner.create(String.class, (input, context) -> "unreachable", config); + + var result = runner.run("input"); + + assertEquals(ExecutionStatus.FAILED, result.getStatus()); + // SerDes.deserialize declares no checked exceptions, so an implementation can sneaky-throw one. The start + // hook must still fire, otherwise a plugin keying state on it sees an end with no start. + assertEquals(1, plugin.invocationStarts.size(), "onInvocationStart must fire before the failure surfaces"); + assertNull(plugin.invocationStarts.get(0).executionInput(), "input could not be deserialized"); + assertEquals(1, plugin.invocationEnds.size()); + assertEquals(InvocationStatus.FAILED, plugin.invocationEnds.get(0).invocationStatus()); + } + + /** + * SerDes that sneaky-throws a checked exception from deserialize, as DurableInputOutputSerDes does on IO errors. + */ + static class SneakyThrowingSerDes implements SerDes { + private final JacksonSerDes delegate = new JacksonSerDes(); + + @Override + public String serialize(Object value) { + return delegate.serialize(value); + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + ExceptionHelper.sneakyThrow(new IOException("sneaky checked failure")); + return null; + } + } + // ─── Operation-level hooks ─────────────────────────────────────────── @Test diff --git a/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java b/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java index 69c301991..649c7a600 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java @@ -71,13 +71,15 @@ public static DurableExecutionOutput execute( // deserialization would double the cost, hand plugins a different object than the handler, and // re-run any side effects in a stateful custom SerDes. A failure is captured rather than thrown // so onInvocationStart still fires before it surfaces, keeping the start/end hooks paired. + // SerDes is a public extension point whose deserialize declares no checked exceptions, so an + // implementation may sneaky-throw one; capture every Throwable and rethrow it unchanged. I userInput = null; - RuntimeException inputFailure = null; + Throwable inputFailure = null; try { userInput = extractUserInput( executionManager.getExecutionOperation(), config.getSerDes(), inputType); - } catch (RuntimeException e) { - inputFailure = e; + } catch (Throwable t) { + inputFailure = t; } pluginExecutionInput.set(userInput); @@ -91,7 +93,7 @@ public static DurableExecutionOutput execute( executionManager.getExecutionOperation().startTimestamp(), userInput)); if (inputFailure != null) { - throw inputFailure; + ExceptionHelper.sneakyThrow(inputFailure); } var context = DurableContextImpl.createRootContext(executionManager, config, lambdaContext);