diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index ae25b371a..d16297e40 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -32,10 +32,44 @@ permissions:
id-token: write # This is required for requesting the JWT
contents: read # This is required for actions/checkout
+env:
+ AWS_REGION: us-west-2
+ FILESYSTEM_INFRASTRUCTURE_STACK_NAME: JavaSDKFileSystemSerDesE2EInfrastructureStack
+
jobs:
+ filesystem-infrastructure:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
+ with:
+ role-to-assume: "${{ secrets.TEST_ROLE_ARN }}"
+ role-session-name: java-language-sdk-test-infrastructure
+ aws-region: ${{ env.AWS_REGION }}
+ allowed-account-ids: ${{ secrets.TEST_ACCOUNT_ID }}
+ - name: Test SAM template generator
+ run: python3 -m unittest test_generate_template.py
+ working-directory: ./examples
+ - name: Generate persistent filesystem SerDes E2E infrastructure template
+ run: |
+ python3 generate-template.py \
+ --file-system-infrastructure-only \
+ --output filesystem-infrastructure-template.yaml
+ working-directory: ./examples
+ - name: Ensure persistent filesystem SerDes E2E infrastructure
+ run: |
+ aws cloudformation deploy \
+ --template-file filesystem-infrastructure-template.yaml \
+ --stack-name ${{ env.FILESYSTEM_INFRASTRUCTURE_STACK_NAME }} \
+ --no-fail-on-empty-changeset \
+ --tags Purpose=JavaSDKFileSystemSerDesE2E
+ working-directory: ./examples
+
e2e-tests:
+ needs: filesystem-infrastructure
env:
- AWS_REGION: us-west-2
E2E_TEST_PARALLELISM: 4
runs-on: ubuntu-latest
strategy:
@@ -71,6 +105,9 @@ jobs:
- name: Generate SAM template
run: python3 generate-template.py
working-directory: ./examples
+ - name: Generate filesystem SerDes E2E SAM template
+ run: python3 generate-template.py --file-system-only --output filesystem-template.yaml
+ working-directory: ./examples
- name: sam build
env:
MAVEN_OPTS: -DskipTests=true -Dmaven.test.skip=true
@@ -78,6 +115,14 @@ jobs:
sam build --debug --parameter-overrides \
'ParameterKey=Architecture,ParameterValue=x86_64 ParameterKey=JavaVersion,ParameterValue=java${{ matrix.java }} ParameterKey=FunctionNamePrefix,ParameterValue=Java${{ matrix.java }}- ParameterKey=RoleArn,ParameterValue=${{ secrets.TEST_LAMBDA_EXECUTION_ROLE_ARN }}'
working-directory: ./examples
+ - name: sam build filesystem SerDes E2E stack
+ env:
+ MAVEN_OPTS: -DskipTests=true -Dmaven.test.skip=true
+ run: |
+ sam build --debug --template-file filesystem-template.yaml --build-dir .aws-sam-filesystem \
+ --parameter-overrides \
+ 'ParameterKey=Architecture,ParameterValue=x86_64 ParameterKey=JavaVersion,ParameterValue=java${{ matrix.java }} ParameterKey=FunctionNamePrefix,ParameterValue=Java${{ matrix.java }}- ParameterKey=RoleArn,ParameterValue=${{ secrets.TEST_LAMBDA_EXECUTION_ROLE_ARN }} ParameterKey=FileSystemInfrastructureStackName,ParameterValue=${{ env.FILESYSTEM_INFRASTRUCTURE_STACK_NAME }}'
+ working-directory: ./examples
- name: Clean up unmanaged Lambda log groups
run: |
# TODO: Remove this one-time migration cleanup after existing e2e stacks adopt managed log groups.
@@ -89,12 +134,20 @@ jobs:
--resolve-image-repos --resolve-s3 --parameter-overrides \
'ParameterKey=Architecture,ParameterValue=x86_64 ParameterKey=JavaVersion,ParameterValue=java${{ matrix.java }} ParameterKey=FunctionNamePrefix,ParameterValue=Java${{ matrix.java }}- ParameterKey=RoleArn,ParameterValue=${{ secrets.TEST_LAMBDA_EXECUTION_ROLE_ARN }}'
working-directory: ./examples
+ - name: sam deploy filesystem SerDes E2E stack
+ run: |
+ sam deploy --template-file .aws-sam-filesystem/template.yaml \
+ --stack-name Java${{ matrix.java }}-JavaSDKFileSystemSerDesE2EStack \
+ --resolve-s3 --parameter-overrides \
+ 'ParameterKey=Architecture,ParameterValue=x86_64 ParameterKey=JavaVersion,ParameterValue=java${{ matrix.java }} ParameterKey=FunctionNamePrefix,ParameterValue=Java${{ matrix.java }}- ParameterKey=RoleArn,ParameterValue=${{ secrets.TEST_LAMBDA_EXECUTION_ROLE_ARN }} ParameterKey=FileSystemInfrastructureStackName,ParameterValue=${{ env.FILESYSTEM_INFRASTRUCTURE_STACK_NAME }}'
+ working-directory: ./examples
- name: Record E2E log start time
run: echo "E2E_LOG_START_TIME_MS=$(date +%s%3N)" >> "$GITHUB_ENV"
- name: Cloud Based Integration Tests
run: |
mvn clean test -B \
-Dtest.cloud.enabled=true \
+ -Dtest.filesystem.enabled=true \
-Dtest.aws.account='${{ secrets.TEST_ACCOUNT_ID }}' \
-Dtest=CloudBasedIntegrationTest \
-Dtest.function.name.prefix='Java${{ matrix.java }}-' \
diff --git a/.gitignore b/.gitignore
index 378d24717..1addbb95d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,7 @@ __pycache__/
# SAM
.aws-sam/
examples/template.yaml
+examples/filesystem-template.yaml
samconfig.toml
samconfig.toml.bak
diff --git a/README.md b/README.md
index 766a71b02..296cbc74d 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,7 @@ See [Deploy Lambda durable functions with Infrastructure as Code](https://docs.a
**Advanced Topics**
- [Configuration](docs/advanced/configuration.md) - Customize SDK behaviour
+- [Serialization](docs/advanced/serdes.md) - Custom SerDes, filesystem storage, previews, retries, and caching
- [Error Handling](docs/advanced/error-handling.md) - SDK exceptions for handling failures
- [Logging](docs/advanced/logging.md) - How to use DurableLogger
- [Migrating from 1.x to 2.x](docs/migration-1.x-to-2.x.md) - Upgrade guide for breaking changes since `v1.2.1`
diff --git a/docs/adr/005-filesystem-serdes.md b/docs/adr/005-filesystem-serdes.md
index ef0a0a17e..3e5af42f2 100644
--- a/docs/adr/005-filesystem-serdes.md
+++ b/docs/adr/005-filesystem-serdes.md
@@ -1,35 +1,18 @@
-# ADR-005: Payload Offloading for Filesystem Storage
+# ADR-005: Filesystem SerDes with Explicit Context Methods
-**Status:** Proposed
-**Date:** 2026-07-02
+**Status:** Accepted
+**Date:** 2026-08-31
## Context
-Issue [#463](https://github.com/aws/aws-durable-execution-sdk-java/issues/463) asks for Java parity with the JavaScript SDK's filesystem-backed SerDes. The JavaScript implementation receives a `SerdesContext` containing a stable durable execution ARN and an entity ID, then stores either inline JSON or a file pointer in the checkpoint payload. That context lets the implementation choose a collision-free path for each operation payload.
+Issue [#463](https://github.com/aws/aws-durable-execution-sdk-java/issues/463) requests parity with the JavaScript
+SDK's filesystem-backed SerDes. A filesystem SerDes needs two values that are not present in the existing Java
+`SerDes` methods:
-The Java SDK currently exposes a smaller `SerDes` contract:
+- the durable execution ARN, which isolates files belonging to different executions;
+- a stable entity ID, which identifies the execution or operation payload.
-```java
-String serialize(Object value);
- T deserialize(String data, TypeToken typeToken);
-```
-
-That contract is enough for inline JSON but not enough for external payload storage because the implementation cannot tell which durable execution, operation, payload kind, or exception it is handling. This is also the blocker noted in [#509](https://github.com/aws/aws-durable-execution-sdk-java/issues/509).
-
-There are a few Java-specific constraints:
-
-- `SerDes` is called from different threads today. Serialization usually happens on an operation worker thread, while deserialization can happen on the operation caller's context thread when `DurableFuture.get()` is called.
-- The same operation payload can be deserialized multiple times in one invocation because most operation results are not cached after deserialization.
-- Java uses the configured `SerDes` for both operation results and user-defined exception objects stored in `ErrorObject.errorData`.
-- `DurableInputOutputSerDes` is a hard-coded internal serializer for the Lambda Durable Functions request and response envelope. It is separate from the customer-facing `DurableConfig.getSerDes()`.
-- Filesystem-backed storage is optional and storage-specific. It should not add filesystem-oriented public surface area to the core SDK artifact.
-- Filesystem persistence is not automatically durable. Lambda `/tmp` is not valid for replay across environments. Mounted S3 Files may have delayed synchronization and can lose recent writes if the runtime crashes before the mount flushes. EFS or an explicitly accepted S3 Files durability tradeoff should be required for production use.
-
-## Approach A: Reuse SerDes for Offload
-
-### Summary
-
-Keep the existing `SerDes` contract unchanged and implement `FileSystemSerDes` as an optional extra package. The implementation uses `SerDesContext.getCurrentContext()` to identify the durable execution and entity being serialized.
+The public Java interface is intentionally small and is already implemented by customers:
```java
public interface SerDes {
@@ -39,496 +22,227 @@ public interface SerDes {
}
```
-`FileSystemSerDes` acts as both serializer and payload offloader. It serializes values through a delegate SerDes, writes payloads to the filesystem when configured to do so, and stores a small envelope in the checkpoint.
+Replacing those methods would break existing implementations. Adding default context-aware overloads preserves source
+and binary compatibility while allowing filesystem storage to receive durable identity explicitly. Filesystem I/O must
+also avoid blocking the user-operation executor or the SDK coordination executor, and repeated `DurableFuture.get()`
+calls must not repeatedly read and decode the same file.
-Because the existing `SerDes` methods do not accept context parameters, this approach needs a thread-local `SerDesContext` so `FileSystemSerDes` can discover the current payload identity without changing the `SerDes` interface.
+## Decision
-```java
-public record SerDesContext(
- String durableExecutionArn,
- String entityId,
- SerDesPayloadKind payloadKind,
- String operationId,
- String operationName,
- String parentId,
- OperationType operationType,
- OperationSubType operationSubType,
- Integer attempt) {
- public static SerDesContext getCurrentContext() {
- return SerDesContextHolder.getCurrentContext();
- }
-}
-```
-
-The SDK owns setting and clearing this thread-local value around SDK-managed SerDes calls. The setter should not be part of the public customer API; customers only read the current context. If SerDes is called directly by customer code outside the SDK, `getCurrentContext()` returns `null`.
+### Add context-aware default methods
-### Package
-
-| Concern | Decision |
-|---------|----------|
-| Maven module directory | `extra-filesystem-serdes` |
-| Maven artifact ID | `aws-durable-execution-sdk-java-extra-filesystem-serdes` |
-| Maven group ID | `software.amazon.lambda.durable` |
-| Java package | `software.amazon.lambda.durable.extra.filesystem` |
-| Core dependency direction | Extra module depends on `aws-durable-execution-sdk-java`; core does not depend on extras. |
-
-### Configuration
+The original methods remain abstract and unchanged. New default methods accept `SerDesContext` and delegate to the
+original methods, so existing implementations require no changes:
```java
-import software.amazon.lambda.durable.extra.filesystem.FileSystemSerDes;
-
-var serDes = FileSystemSerDes.builder(Path.of("/mnt/efs/durable-payloads"))
- .storageMode(FileSystemStorageMode.ALWAYS)
- .pathEncoding(FileSystemPathEncoding.URI)
- .delegate(new JacksonSerDes())
- .previewGenerator(optionalPreviewGenerator)
- .build();
-
-return DurableConfig.builder()
- .withSerDes(serDes)
- .build();
-```
-
-Storage modes:
-
-| Mode | Behavior |
-|------|----------|
-| `ALWAYS` | Always write the delegate-serialized value to a file and store a file envelope in the checkpoint. |
-| `OVERFLOW` | Store inline until the checkpoint envelope approaches the service payload limit, then write to a file. |
-
-Path encodings:
+public interface SerDes {
+ String serialize(Object value);
-| Encoding | Behavior |
-|----------|----------|
-| `URI` | Use readable, escaped path segments derived from durable execution ARN and entity ID. |
-| `HASH` | Use SHA-256 names for fixed-length, filesystem-safe paths. |
+ default String serialize(Object value, SerDesContext context) {
+ return serialize(value);
+ }
-Envelope format:
+ T deserialize(String data, TypeToken typeToken);
-```json
-{"data":""}
-{"file":""}
-{"file":"","preview":{ "...": "..." }}
+ default T deserialize(String data, TypeToken typeToken, SerDesContext context) {
+ return deserialize(data, typeToken);
+ }
+}
```
-`FileSystemSerDes` must reject calls when `SerDesContext.getCurrentContext()` is `null` or does not include `durableExecutionArn` and `entityId`.
+Context-aware implementations override the new methods. The SDK passes a non-null `SerDesContext` to every managed
+call; direct customer calls to the original methods remain context-free.
-### Runtime flow
+Entity IDs include a stable payload-kind suffix. Root input, output, and exceptions use `/input`, `/output`, and
+`/exception`; operation invoke payloads, results/state, and exceptions use `/invoke-payload`, `/result`, and
+`/exception`. This prevents different payload kinds on the same operation from sharing an identity, but external
+storage implementations must still publish immutable or versioned references for every serialized value.
-```java
-SerDesContextHolder.set(context);
-try {
- var checkpointPayload = fileSystemSerDes.serialize(value);
- sendCheckpoint(checkpointPayload);
-} finally {
- SerDesContextHolder.clear();
-}
-```
+### Use an invocation-scoped SerDesRunner
-On deserialization, `FileSystemSerDes` parses its envelope. If the envelope contains `data`, it delegates directly to the inner SerDes. If the envelope contains `file`, it reads file contents and delegates to the inner SerDes.
+Each `ExecutionManager` creates one `SerDesRunner` for the Lambda invocation. The runner:
-### Threading
+1. executes inline or dispatches the SerDes call to the configured SerDes executor;
+2. invokes the context-aware default method with `SerDesContext`;
+3. returns the result or rethrows the original failure.
-Add a separate executor to `DurableConfig`:
+The configured executor is available through:
```java
DurableConfig.builder()
- .withSerDesExecutorService(customSerDesExecutor)
+ .withSerDesExecutorService(customExecutor)
.build();
```
-The default should be a cached daemon thread pool named `durable-sdk-serdes-*`.
-
-The core SDK should route user payload SerDes calls through a helper, tentatively `SerDesRunner`, that:
-
-- Builds the correct `SerDesContext`.
-- Sets `SerDesContext` in TLS inside the SerDes executor task.
-- Invokes the existing `SerDes.serialize` and `SerDes.deserialize` methods.
-- Clears TLS after each SerDes call.
-- Wraps failures in `SerDesException` with operation and payload kind metadata.
-
-Because TLS is bound to a single Java thread, `SerDesRunner` must set `SerDesContext` inside the SerDes executor task before calling the user SerDes. It must not rely on inheritable thread-local propagation from the operation thread because cached pool threads can be reused across operations and invocations.
-
-### Caching
-
-Add an invocation-scoped cache for successful deserialization results. The cache key should include:
-
-- Durable execution ARN.
-- `entityId`.
-- Payload kind.
-- Target `TypeToken` type.
-- A hash of the serialized checkpoint string.
-
-The serialized string hash prevents stale results when a `WAIT_FOR_CONDITION` or retried step updates the same operation payload across attempts. Cache entries live only for the current Lambda invocation and are discarded when `ExecutionManager` closes.
-
-With this approach, SDK caching can avoid repeated calls to `FileSystemSerDes.deserialize`. If a cache miss occurs, `FileSystemSerDes` may perform a file read internally.
-
-### Exceptions
-
-Keep the current `ErrorObject` shape:
-
-- `errorType`: the Java exception class name.
-- `errorMessage`: the exception message.
-- `errorData`: the SerDes payload or file pointer for the exception object.
-- `stackTrace`: SDK-serialized stack trace entries.
-
-When serializing `errorData`, set `SerDesPayloadKind.EXCEPTION` and use an entity ID distinct from the operation result. When deserializing, continue to load `Class.forName(errorType)` and call SerDes with `TypeToken.get(exceptionClass.asSubclass(Throwable.class))`.
-
-`FileSystemSerDes` does not own exception type reconstruction. It only stores and loads the exception JSON or file pointer. Reconstruction remains in `SerializableDurableOperation.deserializeException` and `DurableExecutor.buildErrorObject`.
-
-### Input and output
-
-Root user input and output payloads should route through `SerDesRunner` so `FileSystemSerDes` can see `SerDesContext`. The internal `DurableExecutionInput` and `DurableExecutionOutput` envelope stays with `DurableInputOutputSerDes`.
-
-### Implementation plan
-
-1. Add `SerDesContext`, `SerDesPayloadKind`, and package-private TLS setter/clearer support. Leave the `SerDes` interface unchanged.
-2. Add `SerDesRunner` and a `SerDesExecutor` default pool. Add `DurableConfig.withSerDesExecutorService(...)` and validation.
-3. Update root input/output handling in `DurableExecutor` to run user payload SerDes through `SerDesRunner` while leaving `DurableInputOutputSerDes` internal.
-4. Update `SerializableDurableOperation`, `InvokeOperation`, `StepOperation`, `WaitForConditionOperation`, `CallbackOperation`, `ChildContextOperation`, `MapOperation`, and test helpers to use `SerDesRunner`.
-5. Add invocation-scoped deserialization caching keyed by entity, payload kind, type, and serialized data hash.
-6. Update exception serialization and deserialization paths to set `SerDesPayloadKind.EXCEPTION` in TLS.
-7. Add the `extra-filesystem-serdes` Maven module with artifact ID `aws-durable-execution-sdk-java-extra-filesystem-serdes`, depending on the core SDK.
-8. Implement `FileSystemSerDes` in `software.amazon.lambda.durable.extra.filesystem` with `ALWAYS` and `OVERFLOW` modes, `URI` and `HASH` path encodings, envelope parsing, atomic file writes where supported by the filesystem, and clear validation errors for missing context.
-9. Add unit tests for context construction, unchanged `SerDes` compatibility, TLS scoping and clearing, thread-pool isolation, cache hits, cache invalidation when serialized data changes, exception reconstruction, malformed filesystem envelopes, and extra-module packaging.
-10. Add integration tests with `LocalDurableTestRunner` for step results, wait-for-condition state, invoke payload/result, child context results, map results, repeated `get()`, replay from file pointers, and custom exception types.
-11. Update README and advanced configuration docs with FileSystemSerDes dependency coordinates, FileSystemSerDes examples, and warnings about `/tmp`, S3 Files flush behavior, and EFS/S3 Files operational requirements.
-
-### Pros
-
-- Delivers the requested parity feature with the smallest new public API surface.
-- Uses an extension point customers already understand and can configure per operation.
-- Keeps the first implementation in an optional `aws-durable-execution-sdk-java-extra-*` module.
-- Avoids committing the core SDK to a generalized offloading envelope before the storage use cases are proven.
-- Closest to the current JavaScript `createFileSystemSerdes` model and issue #463 wording.
-
-### Cons
-
-- Uses serialization as a storage hook, so the name `SerDes` no longer means only object-to-string conversion.
-- Forces customers who already have a custom SerDes to wrap or compose it with FileSystemSerDes.
-- May lead to one-off storage SerDes implementations if S3, DynamoDB, or other backends are added later.
-- Makes it harder for the SDK to reason separately about serialized text size, offloaded payload references, and storage lifecycle.
-- The SDK treats the checkpoint envelope as opaque serialized data, so lifecycle and preview behavior are owned by the SerDes implementation.
-
-## Approach B: Create a PayloadOffloader Interface
-
-### Summary
-
-Introduce a dedicated offloading abstraction in the core SDK. SerDes remains responsible only for object-to-string conversion. The offloader decides whether to keep serialized data inline or store it in third-party storage.
-
-```java
-public interface PayloadOffloader {
- OffloadedPayload offload(String serializedPayload, PayloadOffloadContext context);
-
- String load(OffloadedPayload payload, PayloadOffloadContext context);
-}
-```
-
-`OffloadedPayload` is an SDK-owned envelope model that can represent inline data, a storage reference, and optional preview data.
+SerDes calls execute inline by default. A dedicated executor is opt-in for implementations that perform blocking
+storage or network I/O. When configured, it must not be the same object as the user-operation executor because operation
+threads synchronously wait for SerDes work and a shared saturated pool could deadlock.
-```java
-public record OffloadedPayload(
- PayloadStorageMode mode,
- String data,
- String reference,
- Map preview) {}
-```
+### Cache successful deserializations per invocation
-`PayloadOffloadContext` carries the stable payload identity directly as an explicit method parameter:
+`SerDesRunner` keeps up to 256 successful deserializations in a weak-reference LRU cache for the lifetime of one
+`ExecutionManager`. The cache key contains:
-```java
-public record PayloadOffloadContext(
- String durableExecutionArn,
- String entityId,
- SerDesPayloadKind payloadKind,
- String operationId,
- String operationName,
- String parentId,
- OperationType operationType,
- OperationSubType operationSubType,
- Integer attempt) {}
-```
+- SerDes instance identity;
+- durable execution ARN;
+- entity ID;
+- target `TypeToken`;
+- SHA-256 of the checkpoint string.
-### Package
+The serialized-data hash prevents stale results when the same entity is updated, such as a retried step or
+`waitForCondition` state. Concurrent callers share one in-flight deserialization. Failed deserializations are removed
+from the cache and can be retried.
-The `PayloadOffloader` interface and SDK-owned envelope model belong in the core SDK because the core runtime must apply them uniformly to root input/output, operation results, invoke payloads, callback results, wait-for-condition state, and exception payloads.
+Each SerDes/context pair also has an invocation-local serialization generation. The runner advances it after every
+serialization attempt, and completed/in-flight cache keys include the current generation. Reusing the same deterministic
+external reference after writing new state therefore cannot return the previous cached value in that invocation. This
+does not make a mutable reference replay-safe: a reference already persisted in a checkpoint must continue to resolve
+the same immutable content after later serialization and checkpoint failures.
-Filesystem-specific implementation remains an extra package:
+Repeated reads return the same object instance while the cached value remains reachable. A new invocation creates a new
+runner and cache.
-| Concern | Decision |
-|---------|----------|
-| Core API package | `software.amazon.lambda.durable.offload` |
-| Extra Maven module directory | `extra-filesystem-offloader` |
-| Extra Maven artifact ID | `aws-durable-execution-sdk-java-extra-filesystem-offloader` |
-| Extra Java package | `software.amazon.lambda.durable.extra.filesystem` |
-| Core dependency direction | Extra module depends on `aws-durable-execution-sdk-java`; core does not depend on extras. |
+### Add FileSystemSerDes to the core SDK
-### Configuration
+`FileSystemSerDes` is a normal `SerDes` in `software.amazon.lambda.durable.serde`. Keeping it in the existing SDK
+artifact avoids a new module and allows it to be selected globally or through existing operation-level SerDes
+configuration.
```java
-import software.amazon.lambda.durable.extra.filesystem.FileSystemPayloadOffloader;
-
-var offloader = FileSystemPayloadOffloader.builder(Path.of("/mnt/efs/durable-payloads"))
- .storageMode(PayloadOffloadMode.ALWAYS)
- .pathEncoding(FileSystemPathEncoding.URI)
- .previewGenerator(optionalPreviewGenerator)
+var serDes = FileSystemSerDes.builder(Path.of("/mnt/efs/durable-payloads"))
+ .storageMode(FileSystemSerDesMode.OVERFLOW)
+ .pathEncoding(FileSystemPathEncoding.HASH)
+ .checkpointEnvelopeLimitBytes(256 * 1024 - 1024)
+ .previewConfig(PreviewConfig.builder(PreviewMode.EXCLUDE_ALL)
+ .include(PreviewField.anywhere("id"))
+ .mask(PreviewField.anywhere("email"))
+ .build())
.build();
return DurableConfig.builder()
- .withSerDes(new JacksonSerDes())
- .withPayloadOffloader(offloader)
- .build();
-```
-
-Configuration needs a precedence model:
-
-| Level | Behavior |
-|-------|----------|
-| Global `DurableConfig.withPayloadOffloader(...)` | Applies to all user payloads unless operation config overrides it. |
-| Operation config offloader | Overrides the global offloader for a step, invoke, callback, child context, map, or wait-for-condition operation. |
-| Disabled offloader | Forces inline payload storage for payloads where external storage is not desired. |
-
-SerDes selection remains independent:
-
-- `SerDes` converts objects to and from serialized text.
-- `PayloadOffloader` converts serialized text to and from checkpoint-safe inline data or storage references.
-
-### Runtime flow
-
-```java
-var serialized = serDes.serialize(value);
-var offloaded = payloadOffloader.offload(serialized, offloadContext);
-var checkpointPayload = offloadEnvelopeSerDes.serialize(offloaded);
-sendCheckpoint(checkpointPayload);
-```
-
-On replay:
-
-```java
-var offloaded = offloadEnvelopeSerDes.deserialize(checkpointPayload, OffloadedPayload.class);
-var serialized = payloadOffloader.load(offloaded, offloadContext);
-var value = serDes.deserialize(serialized, typeToken);
-```
-
-The SDK owns the checkpoint/offload envelope. Storage implementations own only the storage reference and the read/write mechanics.
-
-### Threading
-
-Use a separate executor for blocking payload I/O. This can be the same configured executor as SerDes work or a distinct executor if the team wants independent tuning:
-
-```java
-DurableConfig.builder()
- .withSerDesExecutorService(customSerDesExecutor)
- .withPayloadOffloadExecutorService(customOffloadExecutor)
+ .withSerDes(serDes)
.build();
```
-If a single executor is preferred, name it according to the broader responsibility, for example `durable-sdk-payload-*`.
-
-Because filesystem/S3/DynamoDB offloading can block, offload work should not run on the user operation executor or the SDK internal executor.
-
-### Caching
-
-The SDK can cache at two layers:
-
-| Cache | Key | Value |
-|-------|-----|-------|
-| Offloaded payload cache | Durable execution ARN, entity ID, payload kind, checkpoint payload hash | Resolved serialized text |
-| Deserialized object cache | Durable execution ARN, entity ID, payload kind, target type, serialized text hash | Deserialized object |
-
-This lets the SDK avoid repeated file reads and repeated object reconstruction independently. It is also easier for tests and diagnostics because the SDK can observe whether a checkpoint payload is inline or externally referenced.
-
-### Exceptions
-
-Exception handling becomes uniform. The SDK first serializes the exception object with SerDes, then offloads the resulting `errorData` just like any other payload.
-
-The `ErrorObject` shape can remain the same if `errorData` stores the SDK-owned offload envelope:
-
-- `errorType`: the Java exception class name.
-- `errorMessage`: the exception message.
-- `errorData`: inline serialized exception data or an SDK-owned offload envelope.
-- `stackTrace`: SDK-serialized stack trace entries.
-
-Reconstruction remains in `SerializableDurableOperation.deserializeException` and `DurableExecutor.buildErrorObject`, but those paths must first resolve the offloaded `errorData` before calling SerDes.
-
-### Input and output
-
-Root user input and output payloads should use the same SerDes-plus-offloader pipeline. The internal `DurableExecutionInput` and `DurableExecutionOutput` envelope stays with `DurableInputOutputSerDes`.
-
-This approach gives the SDK one consistent policy for root payloads, operation results, invoke payloads, callbacks, wait-for-condition state, map results, and exception payloads.
-
-### Implementation plan
-
-1. Add `SerDesPayloadKind` and a shared payload identity builder that can create `PayloadOffloadContext` for root input/output, operation results, invoke payloads, callback results, wait-for-condition state, map results, and exception payloads.
-2. Add `PayloadOffloader`, `PayloadOffloadContext`, `OffloadedPayload`, and an SDK-owned offload envelope serializer in the core SDK.
-3. Add `DurableConfig.withPayloadOffloader(...)` and optional operation-level offloader configuration.
-4. Define precedence rules between global offloader, operation offloader, disabled offloader, result SerDes, payload SerDes, and callback deserializers.
-5. Add a payload pipeline helper, tentatively `PayloadCodec`, that composes SerDes, offload, caching, executor routing, and exception wrapping.
-6. Update root input/output handling in `DurableExecutor` to use the payload pipeline while leaving `DurableInputOutputSerDes` internal.
-7. Update all operation result, invoke payload, callback result, wait-for-condition state, child context, map, and exception paths to use the payload pipeline.
-8. Add offloaded payload caching and deserialized object caching.
-9. Add the `extra-filesystem-offloader` Maven module with artifact ID `aws-durable-execution-sdk-java-extra-filesystem-offloader`, depending on the core SDK.
-10. Implement `FileSystemPayloadOffloader` in `software.amazon.lambda.durable.extra.filesystem` with `ALWAYS` and `OVERFLOW` modes, `URI` and `HASH` path encodings, atomic file writes where supported by the filesystem, and clear validation errors for missing context.
-11. Add unit tests for offload envelope compatibility, precedence rules, thread-pool isolation, cache hits, cache invalidation, exception reconstruction, malformed references, and extra-module packaging.
-12. Add integration tests with `LocalDurableTestRunner` for step results, wait-for-condition state, invoke payload/result, child context results, map results, repeated `get()`, replay from external references, and custom exception types.
-13. Update README and advanced configuration docs with offloader dependency coordinates, filesystem offloader examples, and warnings about `/tmp`, S3 Files flush behavior, and EFS/S3 Files operational requirements.
-
-### Pros
-
-- Cleaner separation between object encoding and payload storage.
-- Storage offloading works with any SerDes implementation without replacing it.
-- Gives the SDK one place to enforce checkpoint envelope format, thresholds, previews, caching, and validation.
-- Scales naturally to more backends and policies if third-party payload storage becomes a first-class feature.
-- Lets the SDK cache resolved serialized text separately from deserialized objects.
-- Makes exception, callback, invoke, root input/output, and operation result offloading more uniform.
-
-### Cons
-
-- Requires a new core SDK extension point and configuration model.
-- Needs careful interaction rules with operation-level SerDes, payload SerDes, callback deserializers, test helpers, and error serialization.
-- Requires a migration story for existing custom SerDes implementations that already return external references.
-- Slows direct FileSystemSerDes parity while the broader offloading API is designed and stabilized.
-- Diverges from the JavaScript `createFileSystemSerdes` naming and shape, even if the behavior is similar.
-- Adds more core SDK responsibility because the runtime now owns the offload envelope.
-
-## Approach Comparison
-
-| Dimension | Approach A: Reuse SerDes | Approach B: PayloadOffloader |
-|-----------|--------------------------|------------------------------|
-| Responsibility boundary | Combines value serialization and storage-reference creation in one implementation. | Keeps object encoding in `SerDes` and storage movement in a separate offloader. |
-| User configuration | Users replace or wrap their SerDes with `FileSystemSerDes`. Operation-level SerDes selection already exists. | Users configure both a SerDes and an offloader. The SDK must define global, per-operation, and per-payload precedence. |
-| Parity with JS issue | Closest to the current JavaScript `createFileSystemSerdes` model and issue #463 wording. | Diverges from JavaScript naming and shape, though it may be architecturally cleaner for Java. |
-| Core SDK changes | Requires `SerDesContext` TLS because the existing SerDes contract has no context parameter. | Requires a new core extension point, envelope type, config surface, payload pipeline, and migration story. |
-| Applicability | Only payloads using the filesystem SerDes are offloaded. Other SerDes implementations must implement their own offload behavior or be wrapped. | Any SerDes output can be offloaded uniformly after serialization. Users can combine Jackson/custom SerDes with any offloader. |
-| Envelope ownership | FileSystemSerDes owns the checkpoint envelope (`data`, `file`, preview), so the SDK treats it as opaque serialized data. | SDK owns the checkpoint/offload envelope and must guarantee it composes with replay, errors, callbacks, and test utilities. |
-| Caching | SDK can cache deserialized values, but FileSystemSerDes may still do file reads internally unless cache hits happen before SerDes. | SDK can cache at both layers: resolved offloaded payload text and final deserialized object. |
-| Exception handling | Works if every exception serialization path is routed through SerDes with `SerDesPayloadKind.EXCEPTION`. | Works uniformly because exception `errorData` is another serialized payload that can be offloaded after SerDes. |
-| Third-party storage | Filesystem-specific; S3/DynamoDB would likely become more SerDes wrappers or extra packages. | Natural home for multiple storage backends: filesystem, S3, DynamoDB, EFS, S3 Files, or custom customer storage. |
-| Immediate delivery risk | Lower. Builds on existing customization point. | Higher. Requires new API and more runtime integration. |
-| Long-term design risk | Higher. Blurs SerDes semantics and may accumulate storage behavior in serializers. | Lower if offloading grows into a first-class feature, but higher if this remains a one-off filesystem parity feature. |
-
-## AI Recommendation
-
-**AI recommendation:** Prefer **Approach B: Create a `PayloadOffloader` interface** if the team is willing to treat payload offloading as a first-class Java SDK capability rather than only a JavaScript parity item.
-
-Reasoning:
+Storage modes:
-- The problem being solved is payload storage, not serialization. A dedicated offloader keeps the domain boundary clean.
-- The SDK already needs to touch every payload path for context, caching, threading, exceptions, and root input/output. Once that plumbing exists, composing SerDes plus offloader is a more durable shape than putting storage behavior inside SerDes.
-- Java customers are more likely to have custom Jackson/ObjectMapper SerDes implementations. Approach B lets them keep those and add offloading independently.
-- Both approaches use one optional extra package for filesystem-specific code; that is not a differentiator. The package would be either filesystem SerDes or filesystem offloader depending on the chosen approach. The differentiator is that Approach B gives future storage extras such as S3 or DynamoDB offload the same focused core offloader contract instead of encoding storage behavior as more SerDes implementations.
-- SDK-owned envelopes and two-layer caching make replay behavior easier to test and reason about.
+| Mode | Behavior |
+| --- | --- |
+| `ALWAYS` | Store every SDK-managed non-null payload in a file. |
+| `OVERFLOW` | Keep the versioned envelope inline until it exceeds the configured byte limit, then store it in a file. |
-The main reason to choose Approach A is schedule and parity: it is smaller and maps directly to the JavaScript feature request. If the team needs to satisfy #463 quickly with minimal public API design, Approach A is a reasonable incremental step, but it should be documented as payload offloading implemented through SerDes rather than as the long-term ideal boundary.
+Path encodings:
-## Other Alternatives Considered
+| Encoding | Behavior |
+| --- | --- |
+| `URI` | Percent-encode readable execution and entity path segments. |
+| `HASH` | Use fixed-length SHA-256 path segments. |
-### Add FileSystemSerDes without SerDesContext
+The checkpoint-envelope limit defaults to 255 KiB, can be lowered, and cannot be configured above that safe checkpoint
+ceiling.
-Rejected. A filesystem-backed implementation needs stable operation identity. Without context, it cannot choose a safe file name, distinguish result and exception payloads for the same operation, or avoid collisions across durable executions.
+The default delegate is `JacksonSerDes`; `.delegate(...)` controls how values are encoded inside files. Existing
+operation-level SerDes configuration controls which boundaries use filesystem storage. For example,
+`InvokeConfig.payloadSerDes(new JacksonSerDes()).serDes(fileSystemSerDes)` sends ordinary JSON while decoding the result
+with filesystem storage.
-### Put filesystem-backed offloading in the core SDK artifact
+Structured previews support include-all/exclude-all modes, include/exclude/mask selectors, anywhere or exact-path
+matching, custom mask text, and a default 4 KiB preview budget. Custom preview callbacks remain available.
-Rejected. Filesystem-backed storage is optional, storage-specific functionality. Keeping it in an `aws-durable-execution-sdk-java-extra-*` artifact preserves a small core SDK and creates a repeatable package shape for future optional features.
+### Retry transient failures
-### Add context-aware SerDes overloads
+Filesystem `IOException`s are surfaced as `RetryableSerDesException`. `RetrySerDes` decorates any `SerDes` and applies
+an existing `RetryStrategy` only to retryable failures. Permanent `SerDesException` failures propagate immediately.
+Retry delays run inline or on the configured SerDes executor.
-Rejected for Approach A. Explicit overloads are more discoverable, but they expand the public `SerDes` interface and force context into every custom implementation's method surface. Approach A uses `SerDesContext` TLS only to keep the existing `SerDes` contract unchanged. Approach B does not need SerDes TLS because `PayloadOffloader` receives `PayloadOffloadContext` explicitly.
+Known structural and permission failures, including symbolic links, non-directory path components, filesystem loops,
+and access denial, are permanent rather than retryable.
-### Make SerDes async
+### Envelope and file publication
-Deferred. The TypeScript SDK uses async SerDes because file and service I/O are naturally async in Node.js. Java can isolate blocking work with dedicated executors while preserving synchronous user-facing interfaces. A future major version can revisit `CompletionStage` and `CompletionStage` if there is a stronger need.
+Java writes versioned envelopes:
-### Run payload storage on the user executor
+```json
+{"__durable_execution_filesystem_serdes":1,"data":"","sha256":""}
+{"__durable_execution_filesystem_serdes":1,"file":"/mnt/efs/...json","sha256":""}
+{"__durable_execution_filesystem_serdes":1,"file":"/mnt/efs/...json","sha256":"","preview":{"id":"123"}}
+```
-Rejected. Filesystem-backed storage can block on mounted storage. Running that work on the user executor can starve user operation threads and make unrelated steps appear stuck.
+Only envelopes containing the reserved version marker are interpreted as filesystem payloads. Unmarked JSON, including
+objects with `data` or `file` fields, is passed to the delegate SerDes unchanged.
-### Run payload storage on the internal SDK executor
+File names include an owner digest derived from the execution ARN and entity ID, the serialized-payload digest, and a
+unique suffix. Files are direct children of the pre-provisioned base directory and are created with `CREATE_NEW`,
+and failed writes are removed before the retryable failure is propagated. This prevents a later serialization from
+overwriting data referenced by an earlier checkpoint. Deserialization rejects paths outside the configured base
+directory and verifies the digest.
-Rejected. The internal executor is for checkpointing, polling, and coordination. Blocking storage work should not compete with progress-making SDK tasks.
+The filesystem provider must support `SecureDirectoryStream`. Directory components are opened relative to held parent
+handles with symbolic-link following disabled, and file reads/writes use `NOFOLLOW_LINKS`. Providers without secure
+directory streams fail closed. The SDK does not create missing directory components.
-### Cache inside the filesystem implementation only
+### Initial invocation input
-Rejected. The repeated-deserialization problem exists for every payload implementation. SDK-level caching also lets the cache key include operation metadata, target type, and the serialized checkpoint string.
+The durable execution ARN does not exist when a caller serializes the initial Lambda input. Therefore, the original
+context-free `FileSystemSerDes.serialize()` delegates directly and the initial input remains ordinary delegate JSON.
+After invocation starts, `DurableExecutor` deserializes that input through the context-aware overload with the root
+input identity.
-### Make DurableInputOutputSerDes user-configurable
+After the invocation starts, the SDK routes root input deserialization, operation payloads, exceptions, and root output
+through `SerDesRunner`. A chained-invoke boundary requires compatible filesystem configuration and shared storage only
+when that boundary explicitly selects `FileSystemSerDes`.
-Rejected. The backend request/response envelope is protocol data. User payload customization should happen at the user payload boundary, not at the protocol envelope boundary.
+Local and cloud test utilities propagate `SerDesRunner` and durable payload contexts through result deserialization,
+operation inspection, history processing, and asynchronous snapshots. Local runner invocations and cloud history
+snapshots receive separate caches.
## Consequences
Positive:
-- Both approaches enable filesystem-backed payload storage without changing the existing `SerDes` interface.
-- Filesystem-specific functionality stays out of the core SDK artifact.
-- The repository gets a repeatable `aws-durable-execution-sdk-java-extra-xxx` artifact pattern for optional packages.
-- Custom payload implementations get enough context to use external storage safely.
-- Blocking payload work is isolated from user operation and SDK coordination threads.
-- Repeated file reads and repeated object reconstruction can be reduced within an invocation.
-- User exception type reconstruction remains supported.
+- Existing custom `SerDes` implementations remain source and binary compatible.
+- Filesystem and custom external-storage SerDes implementations receive stable payload identity.
+- Blocking SerDes work is isolated from user and SDK coordination executors.
+- Repeated deserialization and file reads are avoided within an invocation.
+- The implementation uses existing global and per-operation SerDes configuration.
+- Ordinary user JSON cannot be confused with a filesystem envelope.
Negative:
-- Adds executor, context, and caching machinery that must stay deterministic.
-- Adds at least one Maven module and published artifact to release and document.
-- Approach A requires thread-local SerDes context because the existing `SerDes` methods do not accept context.
-- Repeated `get()` calls may return the same object instance in one invocation.
-- Filesystem-backed storage introduces operational durability requirements outside the SDK's control.
-- Approach A risks overloading the meaning of SerDes.
-- Approach B requires a larger core SDK design before delivering filesystem parity.
-
-Deferred:
-
-- Choosing whether payload offloading is a first-class SDK concept or a parity feature implemented through SerDes.
-- A fully async Java SerDes or payload pipeline contract.
-- A separate, explicitly dangerous protocol-envelope customization API.
-- File cleanup, retention policies, and lifecycle management for offloaded payloads.
-
-## Shared Design Constraints
-
-These constraints apply to both approaches above.
-
-### Stable payload identity
-
-Both approaches need a stable payload identity that can be used to address external storage. The identity must include the durable execution ARN, operation identity, payload kind, and enough operation metadata to distinguish result, input, callback, wait-for-condition state, and exception payloads.
-
-`entityId` is the primary stable key for external storage. It must be unique within a durable execution and include the payload kind so one operation can safely store multiple values:
+- The public interface has two additional default overloads that implementations may choose to override.
+- Configuring a SerDes executor adds an executor boundary to every SDK-managed SerDes call.
+- Repeated deserialization returns the same object instance within an invocation.
+- Filesystem retention and cleanup remain the application's responsibility.
+- Chained invoke payloads and results that select filesystem storage require a shared mount and compatible paths.
-| Payload | Example entity ID |
-|---------|-------------------|
-| Root input | `execution//input` |
-| Root output | `execution//output` |
-| Root exception | `execution//exception` |
-| Step result | `operation//result` |
-| Step exception | `operation//exception` |
-| Invoke payload | `operation//invoke-payload` |
-| Invoke result | `operation//result` |
-| Callback result | `operation//result` |
-| Child context result | `operation//result` |
-| Map result | `operation//result` |
-| WaitForCondition state | `operation//state` |
+## Operational Requirements
-Do not include the checkpoint token or raw user payload in the context.
+- Do not use Lambda's ephemeral `/tmp` directory. Replay may run in another execution environment.
+- Use a shared durable mount such as EFS.
+- Pre-provision the configured base directory.
+- S3 Files users must accept its synchronization and crash-durability characteristics.
+- Verify that the mounted Java filesystem provider supports `SecureDirectoryStream`.
+- Configure lifecycle cleanup separately; the SDK does not delete persisted payload files.
-### Extra package pattern
+## Alternatives Rejected
-Payload offloading implementations should live outside the core SDK artifact when they target a specific storage mechanism.
+### Replace the original SerDes method signatures
-Use the `aws-durable-execution-sdk-java-extra-xxx` artifact pattern. The filesystem payload package name depends on which approach is chosen; the repository should not publish both a filesystem SerDes package and a filesystem offloader package for the same feature.
+Rejected because replacing the existing methods would break every implementation. Backward-compatible default
+overloads are the accepted approach.
-| Feature | Artifact ID | Java package |
-|---------|-------------|--------------|
-| Filesystem payload storage, Approach A | `aws-durable-execution-sdk-java-extra-filesystem-serdes` | `software.amazon.lambda.durable.extra.filesystem` |
-| Filesystem payload storage, Approach B | `aws-durable-execution-sdk-java-extra-filesystem-offloader` | `software.amazon.lambda.durable.extra.filesystem` |
-| Event deserialization helpers | `aws-durable-execution-sdk-java-extra-event-deserialization` | `software.amazon.lambda.durable.extra.eventdeserialization` |
-| Virtual thread executor helpers | `aws-durable-execution-sdk-java-extra-virtual-thread-pool` | `software.amazon.lambda.durable.extra.virtualthreads` |
+### Add a PayloadOffloader abstraction
-Extra modules should be independently documented, tested, and versioned with the repository release. They may depend on the core SDK and normal support libraries, but the core SDK should expose stable extension points without knowing about any specific extra package. For filesystem payload storage, create exactly one extra module after choosing Approach A or Approach B.
+Rejected for this feature because it introduces a new extension point, envelope model, precedence rules, and operation
+configuration. A dedicated offloader can be reconsidered if multiple storage backends require one common lifecycle.
-### Protocol SerDes boundary
+### Put filesystem support in a separate Maven module
-Do not make `DurableInputOutputSerDes` customizable as part of this ADR. It serializes the Lambda Durable Functions backend protocol envelope, not user payloads. Routing that envelope through external payload storage would risk storing checkpoint tokens or protocol data externally and would require the backend to understand file pointers.
+Rejected for the initial implementation. The filesystem implementation uses only JDK and existing Jackson APIs, so a
+new artifact would add release and documentation overhead without isolating an additional dependency.
-User input and output payloads should still use the configured user payload mechanism when they are extracted from or written to the execution operation. The internal `DurableExecutionInput` and `DurableExecutionOutput` envelope remains handled by `DurableInputOutputSerDes`.
+### Run blocking filesystem work on the user or internal executor, or omit caching
-If protocol customization is needed later, introduce a separate `ProtocolSerDes` configuration surface with a clear warning that it must produce the exact backend wire format. Do not reuse the user payload `SerDes` for that purpose.
+Rejected. Inline execution remains the default, but applications can isolate blocking filesystem work with the
+optional SerDes executor. The user-operation and internal coordination executors must not be used for that I/O.
+Omitting caching would repeat file reads and object reconstruction within one invocation.
diff --git a/docs/advanced/configuration.md b/docs/advanced/configuration.md
index bc8bf89d0..a81d17cb6 100644
--- a/docs/advanced/configuration.md
+++ b/docs/advanced/configuration.md
@@ -34,12 +34,91 @@ public class OrderProcessor extends DurableHandler {
| `withLambdaClientBuilder()` | Custom AWS Lambda client | Auto-configured Lambda client |
| `withSerDes()` | Serializer for step results | Jackson with default settings |
| `withExecutorService()` | Thread pool for user-defined operations | Cached daemon thread pool |
+| `withSerDesExecutorService()` | Optional thread pool for serialization and payload I/O | Inline on the calling thread |
| `withLoggerConfig()` | Logger behavior configuration | Suppress logs during replay |
| `withPollingStrategy()` | Backend polling strategy | Exponential backoff: 1s base, 2x rate, FULL jitter, 10s max |
| `withCheckpointDelay()` | How often the SDK checkpoints updates | `Duration.ofSeconds(0)` (as soon as possible) |
The `withExecutorService()` option configures the thread pool used for running user-defined operations. Internal SDK coordination (checkpoint batching, polling) runs on an SDK-managed thread pool.
+By default, SerDes calls run inline. Configure `withSerDesExecutorService()` when a SerDes performs blocking storage or
+network I/O. The SerDes executor must be different from the user-operation executor because SerDes calls are
+synchronous from the operation's perspective and a shared saturated pool can deadlock.
+
+### Filesystem-backed SerDes
+
+`FileSystemSerDes` stores operation and execution payloads on a shared durable filesystem while leaving small values
+inline when configured for overflow mode:
+
+```java
+var fileSystemSerDes = FileSystemSerDes.builder(Path.of("/mnt/efs/durable-payloads"))
+ .storageMode(FileSystemSerDesMode.OVERFLOW)
+ .pathEncoding(FileSystemPathEncoding.HASH)
+ .checkpointEnvelopeLimitBytes(256 * 1024 - 1024)
+ .previewConfig(PreviewConfig.builder(PreviewMode.EXCLUDE_ALL)
+ .include(PreviewField.anywhere("id"), PreviewField.path("status"))
+ .mask(PreviewField.anywhere("email"))
+ .build())
+ .build();
+
+return DurableConfig.builder()
+ .withSerDes(fileSystemSerDes)
+ .build();
+```
+
+`ALWAYS` writes every SDK-managed payload to a file. `OVERFLOW` stores the payload inline until the complete checkpoint
+envelope exceeds the configured limit, which defaults to and cannot exceed 255 KiB. `URI` path encoding keeps
+identifiers readable, while `HASH` avoids filesystem name-length and character restrictions.
+
+`PreviewConfig` supports include-all/exclude-all modes, exact-path or anywhere field matching, masking, and a default
+4 KiB preview budget. A custom `previewGenerator(...)` remains available for non-standard preview logic.
+
+The filesystem wrapper and the value codec are configured independently. Use `.delegate(...)` to control how values are
+encoded inside files:
+
+```java
+var fileSystemSerDes = FileSystemSerDes.builder(Path.of("/mnt/efs/durable-payloads"))
+ .delegate(new MyCustomSerDes())
+ .build();
+```
+
+Existing operation configuration controls where filesystem storage is used. For example, an invoke can send an
+ordinary JSON payload while decoding its result through filesystem storage:
+
+```java
+var config = InvokeConfig.builder()
+ .payloadSerDes(new JacksonSerDes())
+ .serDes(fileSystemSerDes)
+ .build();
+```
+
+The same pattern applies to `StepConfig.serDes(...)`, callback, child-context, map, parallel, and wait-for-condition
+configuration.
+
+The SDK supplies a `SerDesContext` explicitly through new default methods during managed calls:
+
+```java
+@Override
+public String serialize(Object value, SerDesContext context) {
+ return store(value, context.durableExecutionArn(), context.entityId());
+}
+```
+
+Custom SerDes implementations can use its durable execution ARN and entity ID for external storage. Calls use the
+configured SerDes executor when present, and successful deserializations are cached for the current Lambda invocation.
+Root input, output, and exception IDs end in `/input`, `/output`, and `/exception`; operation payload IDs end in
+`/invoke-payload`, `/result`, or `/exception`. External-storage implementations must return immutable or versioned
+references rather than overwriting content reachable through an earlier checkpoint.
+
+Do not use Lambda's `/tmp` directory: replay can run in another execution environment. Use a shared durable mount such
+as EFS. S3 Files users must account for synchronization and crash-durability behavior. A chained-invoke boundary only
+requires shared storage and compatible filesystem configuration when that boundary explicitly uses `FileSystemSerDes`.
+The mounted Java filesystem provider must support `SecureDirectoryStream`; unsupported providers fail closed. The
+configured base directory must already exist.
+
+See [Serialization and Filesystem Storage](serdes.md) for envelope details, structured previews, retry configuration,
+testing behavior, and operational guidance.
+
### Dynamic plugin loading
Dynamic plugin loading is an opt-in alternative to registering plugins in application code. Put provider JARs on the application class path, then set `DURABLE_EXECUTION_PLUGINS` to an ordered, comma-separated list of provider names:
diff --git a/docs/advanced/error-handling.md b/docs/advanced/error-handling.md
index e81deeb61..ff2cc252b 100644
--- a/docs/advanced/error-handling.md
+++ b/docs/advanced/error-handling.md
@@ -12,6 +12,7 @@ Error
RuntimeException
└── DurableExecutionException - General durable exception
├── SerDesException - Serialization and deserialization exception.
+ │ └── RetryableSerDesException - Transient SerDes I/O failure; retried only by RetrySerDes.
├── UnrecoverableDurableExecutionException - Execution cannot be recovered. The durable execution will be immediately terminated.
│ ├── NonDeterministicExecutionException - Code changed between original execution and replay. Fix code to maintain determinism; don't change step order/names.
│ └── IllegalDurableOperationException - An illegal operation was detected. The execution will be immediately terminated.
diff --git a/docs/advanced/serdes.md b/docs/advanced/serdes.md
new file mode 100644
index 000000000..24b6700c8
--- /dev/null
+++ b/docs/advanced/serdes.md
@@ -0,0 +1,216 @@
+# Serialization and Filesystem Storage
+
+The SDK uses `SerDes` for handler input/output, durable operation results and state, invoke payloads/results, callback
+results, and serialized exceptions.
+
+## Custom value encoding
+
+Implement `SerDes` to control object-to-string encoding:
+
+```java
+public interface SerDes {
+ String serialize(Object value);
+
+ T deserialize(String data, TypeToken typeToken);
+}
+```
+
+Configure a default:
+
+```java
+return DurableConfig.builder()
+ .withSerDes(new MyCustomSerDes())
+ .build();
+```
+
+Operation configuration can override the default:
+
+```java
+var invokeConfig = InvokeConfig.builder()
+ .payloadSerDes(new JacksonSerDes())
+ .serDes(fileSystemSerDes)
+ .build();
+```
+
+This sends an ordinary JSON invoke payload while decoding the invoke result through filesystem storage. The same
+selection model applies to steps, callbacks, child contexts, map, parallel, and wait-for-condition.
+
+## SerDesContext
+
+SDK-managed calls pass durable identity explicitly through backward-compatible default methods:
+
+```java
+public interface SerDes {
+ String serialize(Object value);
+
+ default String serialize(Object value, SerDesContext context) {
+ return serialize(value);
+ }
+
+ T deserialize(String data, TypeToken typeToken);
+
+ default T deserialize(String data, TypeToken typeToken, SerDesContext context) {
+ return deserialize(data, typeToken);
+ }
+}
+```
+
+Existing implementations continue to implement only the original methods. Context-aware implementations override the
+new overloads. The SDK supplies a non-null context for managed calls; direct calls to the original methods are
+context-free.
+
+Entity IDs distinguish every persisted payload owned by the same execution or operation:
+
+| Payload | Entity ID |
+| --- | --- |
+| Root input | `/input` |
+| Root output | `/output` |
+| Root exception | `/exception` |
+| Invoke request | `/invoke-payload` |
+| Operation result or state | `/result` |
+| Operation exception | `/exception` |
+
+Custom external-storage SerDes implementations can use `entityId` as part of a key namespace. Every serialization must
+still publish immutable content and return a unique or versioned reference. Never overwrite content reachable through
+an older reference: an invocation can stop after publishing new content but before its checkpoint update commits, and
+replay must continue to resolve the older checkpoint.
+
+## Execution and caching
+
+SerDes calls execute inline by default. Configure a dedicated executor when serialization performs blocking filesystem
+or network I/O:
+
+```java
+return DurableConfig.builder()
+ .withSerDes(fileSystemSerDes)
+ .withSerDesExecutorService(Executors.newFixedThreadPool(8))
+ .build();
+```
+
+Do not reuse the user-operation executor. Operations synchronously wait for SerDes calls, so sharing a saturated pool
+can deadlock.
+
+Each Lambda invocation owns a `SerDesRunner`. It shares concurrent reads and keeps up to 256 successful
+deserializations in a weak-reference LRU cache. Cache identity includes the SerDes instance, execution ARN, entity ID,
+target type, serialized payload hash, and the entity's serialization generation. Every serialization advances that
+generation after the SerDes call finishes, so a deterministic external reference that is reused for new state cannot
+return an older cached object during the same invocation. This cache rule does not make mutable references replay-safe;
+external references must remain immutable across invocations and checkpoint failures.
+
+Local and cloud testing utilities propagate the same contexts and caching behavior through `TestResult`,
+`TestOperation`, history processing, and asynchronous execution snapshots.
+
+## FileSystemSerDes
+
+`FileSystemSerDes` stores serialized values on a shared durable filesystem:
+
+```java
+var fileSystemSerDes = FileSystemSerDes.builder(Path.of("/mnt/efs/durable-payloads"))
+ .delegate(new JacksonSerDes())
+ .storageMode(FileSystemSerDesMode.OVERFLOW)
+ .pathEncoding(FileSystemPathEncoding.HASH)
+ .checkpointEnvelopeLimitBytes(256 * 1024 - 1024)
+ .previewConfig(PreviewConfig.builder(PreviewMode.EXCLUDE_ALL)
+ .include(PreviewField.anywhere("id"), PreviewField.path("status"))
+ .mask(PreviewField.anywhere("email"))
+ .build())
+ .build();
+```
+
+### Storage modes
+
+| Mode | Behavior |
+| --- | --- |
+| `ALWAYS` | Writes every SDK-managed non-null payload to a file. |
+| `OVERFLOW` | Keeps the complete envelope inline until it exceeds the configured limit. |
+
+The checkpoint-envelope limit defaults to 255 KiB, can be lowered with `checkpointEnvelopeLimitBytes(...)`, and cannot
+be increased above that safe checkpoint ceiling.
+
+### Path encoding
+
+| Encoding | Behavior |
+| --- | --- |
+| `URI` | Percent-encodes readable execution and entity path segments. |
+| `HASH` | Uses fixed-length SHA-256 segments for arbitrary or long identifiers. |
+
+Files include the serialized-payload digest and a unique suffix. They are published with `CREATE_NEW`; failed writes are
+cleaned up. Each envelope includes a SHA-256 digest that is verified when the file is loaded.
+
+The mounted filesystem provider must support `SecureDirectoryStream`. The SDK traverses every directory relative to an
+already-open parent with symlink following disabled and performs file I/O with `NOFOLLOW_LINKS`. Providers without this
+capability fail closed. The configured base directory must already exist; the SDK never creates path components.
+
+Only objects containing the reserved version marker are treated as filesystem envelopes. Ordinary JSON with `data` or
+`file` fields is passed to the configured delegate.
+
+```json
+{"__durable_execution_filesystem_serdes":1,"data":"","sha256":""}
+{"__durable_execution_filesystem_serdes":1,"file":"/mnt/efs/...json","sha256":""}
+```
+
+See [Filesystem SerDes wire format](../wire-formats/filesystem-serdes.md) for exact schemas, member validation, path
+construction, security requirements, and versioning.
+
+### Structured previews
+
+`PreviewConfig` supports:
+
+- `INCLUDE_ALL` or `EXCLUDE_ALL` defaults;
+- field matching anywhere or by exact dotted path;
+- include, exclude, and mask selectors;
+- configurable mask text;
+- a default 4 KiB preview budget.
+
+Object arrays are flattened at their containing path, while scalar arrays are retained. Field names containing dots are
+skipped because they are ambiguous with path selectors. Use `previewGenerator(...)` for custom behavior.
+
+### Initial input
+
+The durable execution ARN does not exist while the caller serializes the initial Lambda input. A direct call to the
+original `FileSystemSerDes.serialize()` method therefore delegates normally. After invocation begins, root input
+deserialization and all persisted output/operation payloads use the context-aware methods.
+
+### Operational requirements
+
+- Do not use Lambda `/tmp`; replay may run in another execution environment.
+- Use a shared durable mount such as EFS.
+- Pre-provision the configured base directory.
+- If using S3 Files, account for its synchronization and crash-durability behavior.
+- Verify that the Java filesystem provider for the mount supports `SecureDirectoryStream`.
+- Configure retention and cleanup separately; the SDK does not delete completed payload files.
+- Chained-invoke boundaries that use filesystem storage require compatible mount paths on both sides.
+
+## Retrying transient SerDes failures
+
+Filesystem read/write `IOException`s are reported as `RetryableSerDesException`. Wrap a SerDes with `RetrySerDes` to
+apply any existing `RetryStrategy`:
+
+```java
+var retryingSerDes = new RetrySerDes(
+ fileSystemSerDes,
+ RetryStrategies.exponentialBackoff(
+ 4,
+ Duration.ofSeconds(1),
+ Duration.ofSeconds(10),
+ 2.0,
+ JitterStrategy.FULL));
+```
+
+Only `RetryableSerDesException` is retried. Permanent `SerDesException` failures—malformed envelopes, invalid digests,
+incompatible data, symbolic-link violations, non-directory path components, or access-denied failures—fail
+immediately. Retry delays block the calling thread or the configured SerDes executor thread.
+
+## Testing
+
+`LocalDurableTestRunner` preserves the configured SerDes executor and creates a fresh `SerDesRunner` for each simulated
+Lambda invocation. `TestResult.getResult()` and `TestOperation.getStepResult()` deserialize using durable contexts and
+the invocation cache.
+
+`CloudDurableTestRunner` and `AsyncExecution` reconstruct the execution and operation contexts from history events.
+Each asynchronous history snapshot receives its own cache so updated payloads cannot reuse values from an earlier
+snapshot.
+
+See the runnable
+[FileSystemSerDesExample](../../examples/src/main/java/software/amazon/lambda/durable/examples/general/FileSystemSerDesExample.java)
+for filesystem configuration, structured previews, retries, replay, and checksum verification.
diff --git a/docs/advanced/testing.md b/docs/advanced/testing.md
index 47b986475..29dd2dbd4 100644
--- a/docs/advanced/testing.md
+++ b/docs/advanced/testing.md
@@ -56,6 +56,18 @@ List succeeded = result.getSucceededOperations();
List failed = result.getFailedOperations();
```
+When an operation uses a `serDes(...)` override, configure an operation resolver so inspection uses the same SerDes
+instead of the runner-wide default:
+
+```java
+var runner = LocalDurableTestRunner.create(Order.class, handler)
+ .withOperationSerDesResolver((operation, defaultSerDes) ->
+ "process-payment".equals(operation.name()) ? paymentSerDes : defaultSerDes);
+```
+
+`CloudDurableTestRunner` provides the same `withOperationSerDesResolver(...)` method. The resolver is also propagated
+to `AsyncExecution` history snapshots.
+
### Controlling Time in Tests
By default, `runUntilComplete()` skips wait durations. For testing time-dependent logic, disable this:
diff --git a/docs/core/invoke.md b/docs/core/invoke.md
index e79c99cf0..20d15bbdb 100644
--- a/docs/core/invoke.md
+++ b/docs/core/invoke.md
@@ -21,3 +21,16 @@ var result = ctx.invoke("invoke-function",
);
```
+
+Payload and result serialization are selected independently. For example, a standard JSON payload can be sent while a
+filesystem-backed result is decoded:
+
+```java
+var config = InvokeConfig.builder()
+ .payloadSerDes(new JacksonSerDes())
+ .serDes(fileSystemSerDes)
+ .build();
+```
+
+If either boundary uses `FileSystemSerDes`, both functions must be able to access the referenced shared mount path.
+See [Serialization and Filesystem Storage](../advanced/serdes.md).
diff --git a/docs/design.md b/docs/design.md
index eaba54af0..799722823 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -347,9 +347,15 @@ software.amazon.lambda.durable
│ └── WaitForConditionResult # Check function return type (value + isDone)
│
├── serde/
-│ ├── SerDes # Interface
-│ ├── JacksonSerDes # Jackson impl
-│ └── AwsSdkV2Module # SDK type support
+│ ├── SerDes # Interface
+│ ├── JacksonSerDes # Jackson impl
+│ ├── FileSystemSerDes # Shared-filesystem payload storage
+│ ├── PreviewConfig # Structured preview selection and byte budget
+│ ├── RetrySerDes # RetryableSerDesException decorator
+│ ├── SerDesPreview # Structured preview builder
+│ ├── SerDesContext # Explicit durable payload identity
+│ ├── SerDesRunner # Executor dispatch + invocation cache
+│ └── AwsSdkV2Module # SDK type support
│
└── exception/
├── DurableExecutionException
@@ -372,7 +378,8 @@ software.amazon.lambda.durable
├── ChildContextFailedException
├── MapIterationFailedException
├── ParallelBranchFailedException
- └── SerDesException
+ ├── SerDesException
+ └── RetryableSerDesException
```
---
@@ -653,20 +660,34 @@ For testing, use `DurableConfig.builder().withDurableExecutionClient(localMemory
```java
public interface SerDes {
String serialize(Object value);
- T deserialize(String data, Class type);
T deserialize(String data, TypeToken typeToken);
}
```
+SDK-managed calls go through an invocation-scoped `SerDesRunner`. The runner executes inline by default or dispatches
+work to the configured SerDes executor, invokes the context-aware default method with an explicit `SerDesContext`, and
+caches successful deserializations in a bounded weak-reference LRU by SerDes identity, execution ARN, entity ID, target
+type, and serialized-data hash.
+
+The entity ID combines the execution or operation ID with a payload-kind suffix: `/input`, `/output`, or `/exception`
+for root execution payloads, and `/invoke-payload`, `/result`, or `/exception` for operation payloads. Distinct durable
+payload kinds therefore remain distinct. A custom external-storage SerDes must additionally return an immutable or
+versioned reference for each serialization; invocation-local cache invalidation cannot make overwritten references
+safe across replay.
+
+`FileSystemSerDes` uses that context to build collision-free paths on a shared durable filesystem. Calls made before a
+durable execution ARN exists, such as initial invocation input serialization, fall back to the delegate SerDes without
+filesystem storage.
+
**TypeToken and Type Erasure:**
Java's type erasure removes generic type parameters at runtime (`List` becomes `List`). This is problematic for deserialization—Jackson needs the full type to reconstruct objects correctly.
`TypeToken` solves this by capturing generic types at compile time. Creating `new TypeToken>() {}` produces an anonymous subclass whose superclass type parameter is preserved in bytecode and accessible via reflection (`getGenericSuperclass()`).
-The `SerDes` interface provides both `Class` and `TypeToken` overloads:
-- Use `Class` for simple types: `String.class`, `User.class`
-- Use `TypeToken` for parameterized types: `new TypeToken>() {}`
+Durable operation APIs provide both `Class` and `TypeToken` overloads:
+- Use `Class` for simple operation result types: `String.class`, `User.class`
+- Use `TypeToken` for parameterized operation result types: `new TypeToken>() {}`
---
diff --git a/docs/wire-formats/filesystem-serdes.md b/docs/wire-formats/filesystem-serdes.md
new file mode 100644
index 000000000..b593263eb
--- /dev/null
+++ b/docs/wire-formats/filesystem-serdes.md
@@ -0,0 +1,153 @@
+# Filesystem SerDes wire format
+
+## Status and scope
+
+This document defines the persisted strings produced by Java `FileSystemSerDes`. The format is versioned durable state:
+changes must preserve replay of existing checkpoints or introduce an explicit migration boundary.
+
+The internal Lambda durable execution request/response envelope is not covered. It remains encoded by
+`DurableInputOutputSerDes`.
+
+## Recognition
+
+A value is a filesystem envelope only when its top-level JSON object contains:
+
+```json
+"__durable_execution_filesystem_serdes": 1
+```
+
+Unmarked input is passed unchanged to the configured delegate SerDes. Marked malformed input, duplicate fields,
+trailing tokens, unsupported versions, and unknown members are rejected.
+
+Text containing the marker inside a JSON string is not recognized as an envelope.
+
+## Version 1 schemas
+
+### Inline
+
+```json
+{
+ "__durable_execution_filesystem_serdes": 1,
+ "data": "",
+ "sha256": "<64 lowercase hexadecimal characters>"
+}
+```
+
+### File
+
+```json
+{
+ "__durable_execution_filesystem_serdes": 1,
+ "file": "/absolute/path/to/payload.json",
+ "sha256": "<64 lowercase hexadecimal characters>"
+}
+```
+
+### File with preview
+
+```json
+{
+ "__durable_execution_filesystem_serdes": 1,
+ "file": "/absolute/path/to/payload.json",
+ "sha256": "<64 lowercase hexadecimal characters>",
+ "preview": {
+ "id": "order-123",
+ "email": "***"
+ }
+}
+```
+
+Exactly one of `data` or `file` is required. `preview` is valid only with `file`. The complete encoded envelope must fit
+the configured `checkpointEnvelopeLimitBytes`, which cannot exceed the safe 255 KiB checkpoint ceiling.
+
+## Payload and digest
+
+The payload is the UTF-8 byte sequence of the delegate SerDes string. `sha256` is the lowercase hexadecimal SHA-256
+digest of those bytes.
+
+Deserialization verifies both inline and file payloads before invoking the delegate. Missing, malformed, or mismatched
+digests are permanent `SerDesException` failures.
+
+Malformed UTF-8 file content and filesystem read failures are `RetryableSerDesException` failures.
+
+## Path construction
+
+All paths are rooted under the configured absolute base path. The base directory and every ancestor must be
+pre-provisioned; the SDK does not create directory components.
+
+The entity ID used for path construction identifies both the durable owner and payload kind:
+
+- root input, output, and exceptions use `/input`, `/output`, and `/exception`;
+- invoke requests use `/invoke-payload`;
+- operation results/state and exceptions use `/result` and `/exception`.
+
+These suffixes distinguish different payload kinds belonging to one operation. External storage references must also
+remain immutable or versioned so an older checkpoint always resolves the same content.
+
+The owner digest is the lowercase SHA-256 digest of the execution ARN, one zero byte, and the entity ID.
+
+- `HASH` uses the 64-character owner digest as the filename prefix.
+- `URI` uses the first 32 characters of the percent-encoded entity ID, followed by `-` and the owner digest.
+
+URI encoding preserves ASCII letters, digits, `-`, `_`, `.`, and `~`; all other UTF-8 bytes are percent encoded.
+
+Every payload is a direct child of the base directory:
+
+```text
+/--.json
+```
+
+## Publication
+
+Files are immutable and published with `CREATE_NEW`. A failed write attempts to delete the partially created file before
+returning a retryable failure. Repeated serialization writes a distinct UUID-suffixed file, even for identical payloads.
+
+No hard-link or rename operation is required.
+
+## Filesystem security requirements
+
+The provider must support `SecureDirectoryStream`.
+
+Directory traversal starts at the filesystem root. Every path component is opened relative to an already-held parent
+directory with `NOFOLLOW_LINKS`. File reads and writes also use `NOFOLLOW_LINKS`.
+
+The implementation fails closed when:
+
+- the provider lacks `SecureDirectoryStream`;
+- the base path, an ancestor, or payload file is a symbolic link;
+- the base directory is missing or is not a directory;
+- a path is outside the configured base path;
+- the file is missing or unreadable.
+
+Security failures represented by invalid envelope or path metadata are permanent. Filesystem I/O failures are
+retryable unless they identify a structural or permission failure such as a symbolic link, non-directory component, or
+access denial.
+
+## Cross-execution references
+
+The file path is carried in the persisted envelope. A caller and callee may exchange a filesystem-backed invoke payload
+or result only when both can access the same mounted path and use compatible delegate SerDes configuration.
+
+Use `InvokeConfig.payloadSerDes(...)` and `InvokeConfig.serDes(...)` to select filesystem storage independently for the
+payload and result boundaries.
+
+## Preview rules
+
+Preview data is informational and is never used for deserialization.
+
+Built-in structured previews support:
+
+- include-all and exclude-all modes;
+- include, exclude, and mask selectors;
+- field-name matching at any depth;
+- exact dotted-path matching;
+- a configurable mask string;
+- a configurable UTF-8 byte budget, defaulting to 4 KiB.
+
+Object arrays are flattened at the containing path. Scalar arrays are retained. Fields containing dots are omitted
+because they are ambiguous with path selectors.
+
+## Versioning
+
+Readers accept version `1` only. Future versions must use a different marker value and must not reinterpret version 1
+members.
diff --git a/examples/README.md b/examples/README.md
index c17750835..a2910f13d 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -77,6 +77,25 @@ mvn test -Dtest=CloudBasedIntegrationTest \
-Dtest.aws.region=us-east-1
```
+For manually run cloud tests, the filesystem SerDes test is disabled by default because it requires VPC and EFS
+infrastructure. Create the persistent infrastructure stack once:
+
+```bash
+python3 generate-template.py \
+ --file-system-infrastructure-only \
+ --output filesystem-infrastructure-template.yaml
+aws cloudformation deploy \
+ --template-file filesystem-infrastructure-template.yaml \
+ --stack-name JavaSDKFileSystemSerDesE2EInfrastructureStack
+```
+
+Then generate, build, and deploy the filesystem Lambda stack with
+`FileSystemInfrastructureStackName=JavaSDKFileSystemSerDesE2EInfrastructureStack`, and include
+`-Dtest.filesystem.enabled=true` when running `CloudBasedIntegrationTest`.
+
+GitHub Actions maintains one persistent infrastructure stack shared by every Java version and one persistent
+filesystem Lambda stack per Java version. Each E2E matrix job updates its Lambda stack in place and runs the test.
+
## Examples
| Example | Description |
@@ -87,6 +106,7 @@ mvn test -Dtest=CloudBasedIntegrationTest \
| [ErrorHandlingExample](src/main/java/software/amazon/lambda/durable/examples/general/ErrorHandlingExample.java) | Handling `StepFailedException` and `StepInterruptedException` |
| [GenericTypesExample](src/main/java/software/amazon/lambda/durable/examples/general/GenericTypesExample.java) | Working with `List` and `Map` |
| [CustomConfigExample](src/main/java/software/amazon/lambda/durable/examples/general/CustomConfigExample.java) | Custom Lambda client and SerDes |
+| [FileSystemSerDesExample](src/main/java/software/amazon/lambda/durable/examples/general/FileSystemSerDesExample.java) | Durable filesystem payload storage, previews, retries, and replay |
| [WaitAtLeastExample](src/main/java/software/amazon/lambda/durable/examples/wait/WaitAtLeastExample.java) | Concurrent `stepAsync()` with `wait()` |
| [WaitAsyncExample](src/main/java/software/amazon/lambda/durable/examples/wait/WaitAsyncExample.java) | Non-blocking `waitAsync()` with concurrent step |
| [RetryInProcessExample](src/main/java/software/amazon/lambda/durable/examples/step/RetryInProcessExample.java) | In-process retry with concurrent operations |
@@ -96,6 +116,22 @@ mvn test -Dtest=CloudBasedIntegrationTest \
| [CustomShouldCompleteMapExample](src/main/java/software/amazon/lambda/durable/examples/map/CustomShouldCompleteMapExample.java) | Custom map completion with `shouldComplete` decisions |
| [WaitForConditionExample](src/main/java/software/amazon/lambda/durable/examples/wait/WaitForConditionExample.java) | Poll a condition until met with `waitForCondition()` |
+### Filesystem SerDes example
+
+The filesystem example requires a shared durable mount and the `FILESYSTEM_SERDES_PATH` environment variable. Do not
+use Lambda `/tmp`.
+
+For a deployed function, mount EFS or another compatible shared filesystem at the same path in every execution
+environment:
+
+```text
+FILESYSTEM_SERDES_PATH=/mnt/efs
+```
+
+The mounted Java filesystem provider must support `SecureDirectoryStream`. The example uses structured previews,
+filesystem I/O retries, a durable wait that forces replay, and checksum verification after the payload is loaded again.
+The configured path must already exist; the SDK does not create directory components.
+
## Cleanup
```bash
diff --git a/examples/generate-template.py b/examples/generate-template.py
index 2ffcd5d70..26c5a0128 100755
--- a/examples/generate-template.py
+++ b/examples/generate-template.py
@@ -21,6 +21,7 @@ class ExampleFunction:
package_name: str
suffix: str
condition: str | None
+ file_system: bool
@property
def logical_id(self) -> str:
@@ -56,21 +57,23 @@ def is_top_level_durable_handler(source: str, class_name: str) -> bool:
return bool(match and "extends DurableHandler" in match.group("header"))
-def read_template_condition(source: str, class_name: str) -> str | None:
+def read_template_metadata(source: str, class_name: str) -> tuple[str | None, bool]:
class_match = re.search(rf"public\s+(?:final\s+)?class\s+{class_name}\b", source)
if not class_match:
- return None
+ return None, False
prefix = source[: class_match.start()]
matches = list(
re.finditer(rf"@(?:[A-Za-z_][\w.]*\.)?{TEMPLATE_ANNOTATION}\s*(?:\((?P.*?)\))?", prefix, re.DOTALL)
)
if not matches:
- return None
+ return None, False
body = matches[-1].group("body") or ""
condition_match = re.search(r'condition\s*=\s*"([^"]+)"', body)
- return condition_match.group(1) if condition_match else None
+ condition = condition_match.group(1) if condition_match else None
+ file_system = bool(re.search(r"\bfileSystem\s*=\s*true\b", body))
+ return condition, file_system
def discover_examples() -> list[ExampleFunction]:
@@ -81,7 +84,7 @@ def discover_examples() -> list[ExampleFunction]:
if not is_top_level_durable_handler(source, class_name):
continue
- condition = read_template_condition(source, class_name)
+ condition, file_system = read_template_metadata(source, class_name)
package_name = read_package(source, path)
examples.append(
ExampleFunction(
@@ -89,6 +92,7 @@ def discover_examples() -> list[ExampleFunction]:
package_name=package_name,
suffix=kebab_case(class_name),
condition=condition,
+ file_system=file_system,
)
)
return examples
@@ -103,7 +107,15 @@ def emit_function(lines: list[str], example: ExampleFunction) -> None:
)
if example.condition:
lines.append(f" Condition: {example.condition}")
- lines.append(f" DependsOn: {example.log_group_logical_id}")
+ if example.file_system:
+ lines.extend(
+ [
+ " DependsOn:",
+ f" - {example.log_group_logical_id}",
+ ]
+ )
+ else:
+ lines.append(f" DependsOn: {example.log_group_logical_id}")
lines.extend(
[
" Properties:",
@@ -112,6 +124,26 @@ def emit_function(lines: list[str], example: ExampleFunction) -> None:
" Role: !Ref RoleArn",
]
)
+ if example.file_system:
+ lines.extend(
+ [
+ " VpcConfig:",
+ " SecurityGroupIds:",
+ " - Fn::ImportValue:",
+ ' Fn::Sub: "${FileSystemInfrastructureStackName}-LambdaSecurityGroupId"',
+ " SubnetIds:",
+ " - Fn::ImportValue:",
+ ' Fn::Sub: "${FileSystemInfrastructureStackName}-SubnetId"',
+ " FileSystemConfigs:",
+ " - Arn:",
+ " Fn::ImportValue:",
+ ' Fn::Sub: "${FileSystemInfrastructureStackName}-AccessPointArn"',
+ " LocalMountPath: /mnt/efs",
+ " Environment:",
+ " Variables:",
+ " FILESYSTEM_SERDES_PATH: /mnt/efs",
+ ]
+ )
lines.append("")
@@ -134,6 +166,133 @@ def emit_log_group(lines: list[str], example: ExampleFunction) -> None:
)
+def emit_file_system_resources(lines: list[str]) -> None:
+ lines.extend(
+ [
+ " FileSystemVpc:",
+ " Type: AWS::EC2::VPC",
+ " Properties:",
+ " CidrBlock: 10.0.0.0/24",
+ " EnableDnsHostnames: true",
+ " EnableDnsSupport: true",
+ "",
+ " FileSystemSubnet:",
+ " Type: AWS::EC2::Subnet",
+ " Properties:",
+ " CidrBlock: 10.0.0.0/26",
+ " VpcId: !Ref FileSystemVpc",
+ "",
+ " FileSystemLambdaSecurityGroup:",
+ " Type: AWS::EC2::SecurityGroup",
+ " Properties:",
+ " GroupDescription: Lambda access to EFS and the Lambda API endpoint",
+ " VpcId: !Ref FileSystemVpc",
+ "",
+ " FileSystemMountSecurityGroup:",
+ " Type: AWS::EC2::SecurityGroup",
+ " Properties:",
+ " GroupDescription: EFS mount access from Lambda",
+ " VpcId: !Ref FileSystemVpc",
+ " SecurityGroupIngress:",
+ " - IpProtocol: tcp",
+ " FromPort: 2049",
+ " ToPort: 2049",
+ " SourceSecurityGroupId: !Ref FileSystemLambdaSecurityGroup",
+ "",
+ " FileSystemEndpointSecurityGroup:",
+ " Type: AWS::EC2::SecurityGroup",
+ " Properties:",
+ " GroupDescription: Lambda API endpoint access from Lambda",
+ " VpcId: !Ref FileSystemVpc",
+ " SecurityGroupIngress:",
+ " - IpProtocol: tcp",
+ " FromPort: 443",
+ " ToPort: 443",
+ " SourceSecurityGroupId: !Ref FileSystemLambdaSecurityGroup",
+ "",
+ " FileSystemLambdaEndpoint:",
+ " Type: AWS::EC2::VPCEndpoint",
+ " Properties:",
+ " PrivateDnsEnabled: true",
+ " SecurityGroupIds:",
+ " - !Ref FileSystemEndpointSecurityGroup",
+ ' ServiceName: !Sub "com.amazonaws.${AWS::Region}.lambda"',
+ " SubnetIds:",
+ " - !Ref FileSystemSubnet",
+ " VpcEndpointType: Interface",
+ " VpcId: !Ref FileSystemVpc",
+ "",
+ " FileSystem:",
+ " Type: AWS::EFS::FileSystem",
+ " Properties:",
+ " Encrypted: true",
+ " PerformanceMode: generalPurpose",
+ " ThroughputMode: bursting",
+ "",
+ " FileSystemMountTarget:",
+ " Type: AWS::EFS::MountTarget",
+ " Properties:",
+ " FileSystemId: !Ref FileSystem",
+ " SecurityGroups:",
+ " - !Ref FileSystemMountSecurityGroup",
+ " SubnetId: !Ref FileSystemSubnet",
+ "",
+ " FileSystemAccessPoint:",
+ " Type: AWS::EFS::AccessPoint",
+ " Properties:",
+ " FileSystemId: !Ref FileSystem",
+ " PosixUser:",
+ ' Gid: "1000"',
+ ' Uid: "1000"',
+ " RootDirectory:",
+ " CreationInfo:",
+ ' OwnerGid: "1000"',
+ ' OwnerUid: "1000"',
+ ' Permissions: "0777"',
+ " Path: /durable-serdes",
+ "",
+ ]
+ )
+
+
+def emit_file_system_outputs(lines: list[str]) -> None:
+ lines.extend(
+ [
+ "Outputs:",
+ " SubnetId:",
+ " Description: Subnet used by the filesystem SerDes E2E Lambda function",
+ " Value: !Ref FileSystemSubnet",
+ " Export:",
+ ' Name: !Sub "${AWS::StackName}-SubnetId"',
+ "",
+ " LambdaSecurityGroupId:",
+ " Description: Security group used by the filesystem SerDes E2E Lambda function",
+ " Value: !Ref FileSystemLambdaSecurityGroup",
+ " Export:",
+ ' Name: !Sub "${AWS::StackName}-LambdaSecurityGroupId"',
+ "",
+ " AccessPointArn:",
+ " Description: EFS access point mounted by the filesystem SerDes E2E Lambda function",
+ " Value: !GetAtt FileSystemAccessPoint.Arn",
+ " Export:",
+ ' Name: !Sub "${AWS::StackName}-AccessPointArn"',
+ ]
+ )
+
+
+def render_file_system_infrastructure_template() -> str:
+ lines = [
+ "# This file is generated by examples/generate-template.py. Do not edit it by hand.",
+ 'AWSTemplateFormatVersion: "2010-09-09"',
+ "Description: Persistent shared EFS infrastructure for filesystem SerDes E2E tests",
+ "",
+ "Resources:",
+ ]
+ emit_file_system_resources(lines)
+ emit_file_system_outputs(lines)
+ return "\n".join(lines) + "\n"
+
+
def render_template(examples: list[ExampleFunction]) -> str:
lines = [
"# This file is generated by examples/generate-template.py. Do not edit it by hand.",
@@ -160,29 +319,41 @@ def render_template(examples: list[ExampleFunction]) -> str:
" RoleArn:",
" Type: String",
" Description: IAM Role ARN for Lambda function execution",
- "",
- "Conditions:",
- " IsJava21OrLater:",
- " !Or",
- " - !Equals [!Ref JavaVersion, 'java21']",
- " - !Equals [!Ref JavaVersion, 'java25']",
- "",
- "Globals:",
- " Function:",
- " Timeout: 900",
- " MemorySize: 512",
- " Architectures:",
- " - !Ref Architecture",
- " DurableConfig:",
- " ExecutionTimeout: 300",
- " RetentionPeriodInDays: 7",
- " Runtime: !Ref JavaVersion",
- " Environment:",
- " Variables:",
- " FUNCTION_NAME_PREFIX: !Ref FunctionNamePrefix",
- "",
- "Resources:",
]
+ if any(example.file_system for example in examples):
+ lines.extend(
+ [
+ " FileSystemInfrastructureStackName:",
+ " Type: String",
+ " Description: Name of the shared persistent filesystem SerDes E2E infrastructure stack",
+ ]
+ )
+ lines.extend(
+ [
+ "",
+ "Conditions:",
+ " IsJava21OrLater:",
+ " !Or",
+ " - !Equals [!Ref JavaVersion, 'java21']",
+ " - !Equals [!Ref JavaVersion, 'java25']",
+ "",
+ "Globals:",
+ " Function:",
+ " Timeout: 900",
+ " MemorySize: 512",
+ " Architectures:",
+ " - !Ref Architecture",
+ " DurableConfig:",
+ " ExecutionTimeout: 300",
+ " RetentionPeriodInDays: 7",
+ " Runtime: !Ref JavaVersion",
+ " Environment:",
+ " Variables:",
+ " FUNCTION_NAME_PREFIX: !Ref FunctionNamePrefix",
+ "",
+ "Resources:",
+ ]
+ )
for example in examples:
emit_log_group(lines, example)
@@ -208,9 +379,26 @@ def render_template(examples: list[ExampleFunction]) -> str:
def main() -> None:
parser = argparse.ArgumentParser(description="Generate the examples SAM template from Java example handlers.")
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="Path to write the generated template.")
+ template_selection = parser.add_mutually_exclusive_group()
+ template_selection.add_argument(
+ "--file-system-only",
+ action="store_true",
+ help="Generate the filesystem SerDes E2E Lambda stack.",
+ )
+ template_selection.add_argument(
+ "--file-system-infrastructure-only",
+ action="store_true",
+ help="Generate the shared persistent EFS infrastructure stack used by filesystem SerDes E2E tests.",
+ )
args = parser.parse_args()
+ if args.file_system_infrastructure_only:
+ args.output.write_text(render_file_system_infrastructure_template(), encoding="utf-8")
+ print(f"Generated persistent filesystem infrastructure template at {args.output}.")
+ return
+
examples = discover_examples()
+ examples = [example for example in examples if example.file_system == args.file_system_only]
if not examples:
raise RuntimeError("No DurableHandler examples found")
diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java b/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java
index de93e95ca..55eab7934 100644
--- a/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java
+++ b/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java
@@ -12,4 +12,6 @@
@Target(ElementType.TYPE)
public @interface ExampleTemplate {
String condition() default "";
+
+ boolean fileSystem() default false;
}
diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/general/FileSystemSerDesExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/general/FileSystemSerDesExample.java
new file mode 100644
index 000000000..0f6d4f08c
--- /dev/null
+++ b/examples/src/main/java/software/amazon/lambda/durable/examples/general/FileSystemSerDesExample.java
@@ -0,0 +1,88 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package software.amazon.lambda.durable.examples.general;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.util.HexFormat;
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.examples.ExampleTemplate;
+import software.amazon.lambda.durable.retry.JitterStrategy;
+import software.amazon.lambda.durable.retry.RetryStrategies;
+import software.amazon.lambda.durable.serde.FileSystemPathEncoding;
+import software.amazon.lambda.durable.serde.FileSystemSerDes;
+import software.amazon.lambda.durable.serde.FileSystemSerDesMode;
+import software.amazon.lambda.durable.serde.PreviewConfig;
+import software.amazon.lambda.durable.serde.PreviewField;
+import software.amazon.lambda.durable.serde.PreviewMode;
+import software.amazon.lambda.durable.serde.RetrySerDes;
+
+/** Stores durable payloads on a shared filesystem and verifies them after replay. */
+@ExampleTemplate(fileSystem = true)
+public class FileSystemSerDesExample
+ extends DurableHandler {
+ static final String FILE_SYSTEM_PATH_PROPERTY = "filesystem.serdes.path";
+ private static final String FILE_SYSTEM_PATH_ENV = "FILESYSTEM_SERDES_PATH";
+
+ @Override
+ protected DurableConfig createConfiguration() {
+ var path = System.getProperty(FILE_SYSTEM_PATH_PROPERTY);
+ if (path == null || path.isBlank()) {
+ path = System.getenv(FILE_SYSTEM_PATH_ENV);
+ }
+ if (path == null || path.isBlank()) {
+ throw new IllegalStateException(FILE_SYSTEM_PATH_ENV + " must identify the mounted durable filesystem");
+ }
+
+ var fileSystemSerDes = FileSystemSerDes.builder(Path.of(path))
+ .storageMode(FileSystemSerDesMode.ALWAYS)
+ .pathEncoding(FileSystemPathEncoding.HASH)
+ .previewConfig(PreviewConfig.builder(PreviewMode.EXCLUDE_ALL)
+ .include(
+ PreviewField.anywhere("id"),
+ PreviewField.anywhere("length"),
+ PreviewField.anywhere("checksum"))
+ .mask(PreviewField.anywhere("email"))
+ .build())
+ .build();
+ var retryingSerDes = new RetrySerDes(
+ fileSystemSerDes,
+ RetryStrategies.exponentialBackoff(
+ 4, Duration.ofSeconds(1), Duration.ofSeconds(10), 2.0, JitterStrategy.FULL));
+ return DurableConfig.builder().withSerDes(retryingSerDes).build();
+ }
+
+ @Override
+ public Output handleRequest(Input input, DurableContext context) {
+ var stored = context.step(
+ "store-payload",
+ Payload.class,
+ stepContext -> new Payload(
+ input.id(), input.email(), input.value(), input.value().length()));
+ context.wait("force-filesystem-replay", Duration.ofSeconds(1));
+ return context.step(
+ "verify-payload",
+ Output.class,
+ stepContext -> new Output(stored.id(), stored.length(), sha256(stored.value())));
+ }
+
+ private static String sha256(String value) {
+ try {
+ return HexFormat.of()
+ .formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 is unavailable", e);
+ }
+ }
+
+ public record Input(String id, String email, String value) {}
+
+ public record Payload(String id, String email, String value, int length) {}
+
+ public record Output(String id, int length, String checksum) {}
+}
diff --git a/examples/src/test/java/software/amazon/lambda/durable/examples/CloudBasedIntegrationTest.java b/examples/src/test/java/software/amazon/lambda/durable/examples/CloudBasedIntegrationTest.java
index 7490961a5..ae6fb7c68 100644
--- a/examples/src/test/java/software/amazon/lambda/durable/examples/CloudBasedIntegrationTest.java
+++ b/examples/src/test/java/software/amazon/lambda/durable/examples/CloudBasedIntegrationTest.java
@@ -5,8 +5,14 @@
import static org.junit.jupiter.api.Assertions.*;
import static software.amazon.lambda.durable.TypeToken.get;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.HashMap;
+import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
@@ -14,6 +20,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledForJreRange;
import org.junit.jupiter.api.condition.EnabledIf;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.condition.JRE;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@@ -21,9 +28,11 @@
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.lambda.LambdaClient;
import software.amazon.awssdk.services.lambda.model.ErrorObject;
+import software.amazon.awssdk.services.lambda.model.EventType;
import software.amazon.awssdk.services.lambda.model.OperationStatus;
import software.amazon.awssdk.services.sts.StsClient;
import software.amazon.lambda.durable.TypeToken;
+import software.amazon.lambda.durable.examples.general.FileSystemSerDesExample;
import software.amazon.lambda.durable.examples.general.GenericTypesExample;
import software.amazon.lambda.durable.examples.types.ApprovalRequest;
import software.amazon.lambda.durable.examples.types.GreetingRequest;
@@ -38,6 +47,8 @@
@EnabledIf("isEnabled")
class CloudBasedIntegrationTest {
private static final int PERFORMANCE_TEST_REPEAT = 3;
+ private static final String FILE_SYSTEM_ENVELOPE_MARKER = "__durable_execution_filesystem_serdes";
+ private static final ObjectMapper MAPPER = new ObjectMapper();
private static String account;
private static String region;
@@ -301,6 +312,44 @@ void testGenericTypesExample() {
assertNotNull(runner.getOperation("fetch-categories"));
}
+ @Test
+ @EnabledIfSystemProperty(named = "test.filesystem.enabled", matches = "true")
+ void testFileSystemSerDesExample() throws Exception {
+ var value = "filesystem-e2e-".repeat(24 * 1024);
+ var input = new FileSystemSerDesExample.Input("payload-1", "user@example.com", value);
+ var expectedChecksum = sha256(value);
+ var runner = CloudDurableTestRunner.create(
+ arn("file-system-ser-des-example"),
+ FileSystemSerDesExample.Input.class,
+ FileSystemSerDesExample.Output.class,
+ lambdaClient);
+
+ var result = runner.run(input);
+
+ assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
+ assertTrue(result.getHistoryEvents().stream()
+ .filter(event -> event.eventType() == EventType.INVOCATION_COMPLETED)
+ .count()
+ >= 2);
+ assertNotNull(result.getOperation("force-filesystem-replay"));
+
+ var storedEnvelope = assertFileSystemEnvelope(
+ result.getOperation("store-payload").getStepDetails().result());
+ assertPreview(storedEnvelope, input.id(), value.length(), null, "***");
+
+ var verifiedEnvelope = assertFileSystemEnvelope(
+ result.getOperation("verify-payload").getStepDetails().result());
+ assertPreview(verifiedEnvelope, input.id(), value.length(), expectedChecksum, null);
+
+ var outputPayload = result.getHistoryEvents().stream()
+ .filter(event -> event.eventType() == EventType.EXECUTION_SUCCEEDED)
+ .map(event -> event.executionSucceededDetails().result().payload())
+ .findFirst()
+ .orElseThrow();
+ var outputEnvelope = assertFileSystemEnvelope(outputPayload);
+ assertPreview(outputEnvelope, input.id(), value.length(), expectedChecksum, null);
+ }
+
@Test
void testGenericInputOutputExample() {
final TypeToken