diff --git a/docs/migration-1.x-to-2.x.md b/docs/migration-1.x-to-2.x.md index 52e1cfaeb..5da0e3aa3 100644 --- a/docs/migration-1.x-to-2.x.md +++ b/docs/migration-1.x-to-2.x.md @@ -185,6 +185,7 @@ assertThrows(IllegalStateException.class, future::get); What changes in practice: - Serialization problems now fail on first execution instead of surfacing later on replay. +- Operation results can be returned after a SerDes round-trip so first execution matches replay. - Custom `SerDes` implementations must be able to deserialize SDK-managed values they serialize. - Child-context results are validated consistently, including virtual child-context paths. @@ -192,20 +193,21 @@ This is usually a correctness improvement, but it can surface previously hidden ### New opt-out configuration -If your workload is very performance-sensitive and you need to skip the extra validation deserialize pass, you can opt out: +If your workload is very performance-sensitive and you need to skip the extra deserialize pass, you can opt out: ```java @Override protected DurableConfig createConfiguration() { return DurableConfig.builder() - .withSerializationRoundTripValidation(false) + .withDeserializeAfterSerialization(false) .build(); } ``` Use that carefully: -- Disabling validation can hide serialization bugs until replay. +- Disabling this can hide serialization bugs until replay. +- First execution may return the raw result shape instead of the replay result shape. - Custom `SerDes` implementations are still expected to be round-trip safe. ## Recommended Validation After Upgrading @@ -226,6 +228,6 @@ Most upgrades are straightforward: - Logger metadata moves to `executionArn`, `operationId`, and `operationName` - Replay-sensitive logging becomes per-context, `isReplaying()` moves to `DurableContext`, and step logs are no longer replay-suppressed - Validation failures now throw `IllegalStateException` -- Serialization round-trip problems surface earlier by default, with an opt-out via `withSerializationRoundTripValidation(false)` +- Serialization round-trip problems surface earlier by default, with an opt-out via `withDeserializeAfterSerialization(false)` If you update those areas first, the `1.x` to `2.x` migration should be low risk. diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/general/CustomConfigExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/general/CustomConfigExample.java index 28d248395..1cfd2c8c7 100644 --- a/examples/src/main/java/software/amazon/lambda/durable/examples/general/CustomConfigExample.java +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/general/CustomConfigExample.java @@ -31,6 +31,7 @@ *
When enabled, the SDK returns deserialized operation results so first execution matches replay, and validates
+ * serialized exceptions before checkpointing them. Defaults to true, and custom SerDes implementations are still
+ * expected to be round-trip safe even if this behavior is disabled.
+ *
+ * @return true when serialized data should be deserialized immediately
+ */
+ public boolean shouldDeserializeAfterSerialization() {
+ return deserializeAfterSerialization;
+ }
+
/**
* Gets the plugin runner that dispatches lifecycle events to registered plugins.
*
@@ -293,6 +308,7 @@ public static final class Builder {
private LoggerConfig loggerConfig;
private PollingStrategy pollingStrategy;
private Duration checkpointDelay;
+ private boolean deserializeAfterSerialization = true;
private List This is enabled by default. When enabled, operation results are returned after a SerDes round-trip so
+ * first execution returns the same value shape as replay, and exceptions are checked before checkpointing.
+ *
+ * @param deserializeAfterSerialization true to deserialize serialized data immediately
+ * @return This builder
+ */
+ public Builder withDeserializeAfterSerialization(boolean deserializeAfterSerialization) {
+ this.deserializeAfterSerialization = deserializeAfterSerialization;
+ return this;
+ }
+
/**
* Registers one or more plugins for lifecycle event instrumentation.
*
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/DurableHandler.java b/sdk/src/main/java/software/amazon/lambda/durable/DurableHandler.java
index a31c94d00..2342c70ac 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/DurableHandler.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/DurableHandler.java
@@ -117,6 +117,7 @@ public DurableConfig getConfiguration() {
* .withDurableExecutionClient(durableClient)
* .withSerDes(customSerDes) // Optional: custom SerDes for user data
* .withExecutorService(customExecutor) // Optional: custom thread pool
+ * .withDeserializeAfterSerialization(false) // Optional: skip immediate deserialize pass
* .build();
* }
* }
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java
index a47aff182..52fd39f08 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java
@@ -149,6 +149,8 @@ private void executeChildContext() {
}
private void handleChildContextSuccess(T result) {
+ var serializedResult = serializeAndDeserializeResult(result);
+
if (replayChildren.get() || isVirtual || parentOperation != null && parentOperation.isOperationCompleted()) {
// Skip checkpointing if
// - parent ConcurrencyOperation has already completed, preventing race conditions where a child finishes
@@ -156,19 +158,17 @@ private void handleChildContextSuccess(T result) {
// - replaying a SUCCEEDED child with replayChildren=true — skip checkpointing.
// - nestingType is FLAT
// Mark the completableFuture completed so get() doesn't block waiting for a checkpoint response.
- cachedOperationResult.set(DeserializedOperationResult.succeeded(result));
+ cachedOperationResult.set(DeserializedOperationResult.succeeded(serializedResult.deserialized()));
if (isVirtual) {
fireOnOperationEnd(null, null);
}
markAlreadyCompleted();
} else {
- checkpointSuccess(result);
+ checkpointSuccess(serializedResult.deserialized(), serializedResult.serialized());
}
}
- private void checkpointSuccess(T result) {
- var serialized = serializeResult(result);
-
+ private void checkpointSuccess(T result, String serialized) {
if (serialized == null || serialized.getBytes(StandardCharsets.UTF_8).length < LARGE_RESULT_THRESHOLD) {
sendOperationUpdate(
OperationUpdate.builder().action(OperationAction.SUCCEED).payload(serialized));
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/MapOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/MapOperation.java
index c2afb1ab7..5776f498b 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/operation/MapOperation.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/MapOperation.java
@@ -178,21 +178,22 @@ protected void replay(Operation existing) {
@Override
protected void handleCompletion(ConcurrencyCompletionStatus concurrencyCompletionStatus) {
this.cachedResult = constructMapResult(concurrencyCompletionStatus);
- var serialized = serializeResult(cachedResult);
- var serializedBytes = serialized.getBytes(StandardCharsets.UTF_8);
+ var serializedResult = serializeAndDeserializeResult(cachedResult);
+ this.cachedResult = serializedResult.deserialized();
+ var serializedBytes = serializedResult.serialized().getBytes(StandardCharsets.UTF_8);
if (serializedBytes.length < LARGE_RESULT_THRESHOLD) {
sendOperationUpdate(OperationUpdate.builder()
.action(OperationAction.SUCCEED)
.subType(getSubType().getValue())
- .payload(serialized));
+ .payload(serializedResult.serialized()));
} else {
// Large result: checkpoint with stripped payload + replayChildren flag
- var strippedResult = serializeResult(stripMapResult(cachedResult));
+ var strippedResult = serializeAndDeserializeResult(stripMapResult(cachedResult));
sendOperationUpdate(OperationUpdate.builder()
.action(OperationAction.SUCCEED)
.subType(getSubType().getValue())
- .payload(strippedResult)
+ .payload(strippedResult.serialized())
.contextOptions(
ContextOptions.builder().replayChildren(true).build()));
}
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/ParallelOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/ParallelOperation.java
index 4ae7b8386..fb093bdf5 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/operation/ParallelOperation.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/ParallelOperation.java
@@ -78,13 +78,15 @@ protected void handleCompletion(ConcurrencyCompletionStatus concurrencyCompletio
int skippedCount = items.size() - succeededCount - failedCount;
cachedResult = new ParallelResult(
items.size(), succeededCount, failedCount, skippedCount, concurrencyCompletionStatus, statuses);
+ var serializedResult = serializeAndDeserializeResult(cachedResult);
+ cachedResult = serializedResult.deserialized();
// Branches added after checkpoint will not exist in the checkpointed result, but they'll be in the returned
// value from get() method.
sendOperationUpdate(OperationUpdate.builder()
.action(OperationAction.SUCCEED)
.subType(getSubType().getValue())
- .payload(serializeResult(cachedResult))
+ .payload(serializedResult.serialized())
.contextOptions(ContextOptions.builder().replayChildren(true).build()));
}
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/SerializableDurableOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/SerializableDurableOperation.java
index 6ccf24e0f..6457c996d 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/operation/SerializableDurableOperation.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/SerializableDurableOperation.java
@@ -34,6 +34,8 @@
public abstract class SerializableDurableOperation Use this for operations that cache a first-execution result instead of reading it back from checkpoint data.
+ * This keeps first execution consistent with replay when a SerDes normalizes or otherwise changes the value.
*
* @param result the result to serialize
- * @return the serialized string
+ * @return the serialized string and the deserialized result
*/
- protected String serializeResult(T result) {
- return resultSerDes.serialize(result);
+ protected SerializedResult