diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 8d9f9d4cd..94f206e9f 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,10 +134,18 @@ 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: 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..278691071 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 and SerDes Pipelines](docs/advanced/serdes.md) - Configure value codecs, processing pipelines, and filesystem storage - [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/RELEASE.md b/RELEASE.md index 90c347ff1..a91a8f130 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -43,8 +43,8 @@ The publication workflow: 1. Verifies that the tag is a semantic version, points to a commit on the default branch, and matches the Maven version in the tagged POM. -2. Builds, signs, and uploads the SDK, testing library, and OpenTelemetry plugin - to Sonatype Central Portal. +2. Builds, signs, and uploads the SDK, testing library, and OpenTelemetry + plugin to Sonatype Central Portal. 3. Uploads the three JARs to the existing GitHub release. 4. Opens a pull request for the next development version. A final release increments the patch version, so `2.1.1` produces `2.1.2-SNAPSHOT`. A diff --git a/docs/adr/005-filesystem-serdes.md b/docs/adr/005-filesystem-serdes.md index ef0a0a17e..c93e004ab 100644 --- a/docs/adr/005-filesystem-serdes.md +++ b/docs/adr/005-filesystem-serdes.md @@ -1,7 +1,10 @@ # ADR-005: Payload Offloading for Filesystem Storage -**Status:** Proposed +**Status:** Accepted — Approach A with ComposableSerDes pipeline **Date:** 2026-07-02 +**Updated:** 2026-08-26 — Included FileSystemSerDesStage in core, made every post-codec pipeline component an explicit +string stage, documented the rejected binary-only top-level pipeline, and added envelope payload digests for integrity +verification. ## Context @@ -22,26 +25,69 @@ There are a few Java-specific constraints: - 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. +- The filesystem implementation uses JDK filesystem APIs and existing core dependencies. Including the initial + implementation in the core SDK avoids a second artifact and release path for the accepted parity feature. - 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. +Keep the existing `SerDes` serialization methods source- and binary-compatible, add a string-to-string `SerDesStage` +contract and a core `ComposableSerDes` implementation which together form a processing pipeline, and implement +`FileSystemSerDesStage` in the core SDK's dedicated `software.amazon.lambda.durable.serde.filesystem` Java package. +Every top-level stage consumes and produces a string, making stage composition uniform and preventing intermediate +type mismatches. + +Binary transformations compose inside one `ComposableBinarySerDesStage`. That outer stage converts strings to bytes +with a configurable starting codec, applies any number of reversible `BinarySerDesStage` implementations without +intermediate text conversion, and converts the final bytes back to a string with a configurable ending codec. The +filesystem stage receives `SerDesContext` explicitly to identify the durable execution and entity being serialized. ```java +public interface SerDesStage { + String serialize(String value, SerDesContext context); + + String deserialize(String data, SerDesContext context); +} + +public interface BinarySerDesStage { + byte[] serialize(byte[] value, SerDesContext context); + + byte[] deserialize(byte[] data, SerDesContext context); +} + +public interface StringBinaryCodec { + byte[] toBytes(String value); + + String fromBytes(byte[] data); +} + public interface SerDes { String serialize(Object value); T deserialize(String data, TypeToken typeToken); + + default SerDes then(SerDesStage nextStage) { + return ComposableSerDes.builder(this).then(nextStage).build(); + } } ``` -`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. +`ComposableSerDes` treats the first stage as the value codec and every later stage as a reversible string +transformation. This lets customers compose JSON encoding, framed string transformations, binary processing, and +filesystem storage without unsafe heterogeneous top-level stages. A `ComposableBinarySerDesStage` performs UTF-8, +compression, encryption, and similar byte processing internally and encodes the result once at its string boundary. -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. +`FileSystemSerDesStage` acts as a payload-storage stage. It writes the string produced by the previous stage to the +filesystem when configured to do so and returns a small checkpoint envelope. It is only a `SerDesStage`; a value codec +must precede it in a `ComposableSerDes`, and other string stages may follow it. + +The existing `SerDes` methods remain unchanged and context-free. `SerDesRunner` passes a base `SerDesContext` directly +into `ComposableSerDes`. During serialization, the pipeline derives one stage context whose `originalValue` is the +object supplied to the root value codec and forwards it to every `SerDesStage` and nested `BinarySerDesStage`. During +deserialization, the forwarded stage context has a `null` `originalValue`. No SerDes-specific thread-local storage is +required. ```java public record SerDesContext( @@ -53,48 +99,298 @@ public record SerDesContext( String parentId, OperationType operationType, OperationSubType operationSubType, - Integer attempt) { - public static SerDesContext getCurrentContext() { - return SerDesContextHolder.getCurrentContext(); - } -} + Integer attempt, + Object originalValue) {} ``` -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`. +Only stage implementations receive this context. Root value codecs continue using the backward-compatible `SerDes` +methods without context. If a composable SerDes or stage is invoked directly by customer code outside the SDK, its +stage context is `null`. ### Package | Concern | Decision | |---------|----------| -| Maven module directory | `extra-filesystem-serdes` | -| Maven artifact ID | `aws-durable-execution-sdk-java-extra-filesystem-serdes` | +| Maven module directory | `sdk` | +| Maven artifact ID | `aws-durable-execution-sdk-java` | | 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. | +| Java package | `software.amazon.lambda.durable.serde.filesystem` | +| Dependency impact | No additional artifact or production dependency is required. | ### Configuration ```java -import software.amazon.lambda.durable.extra.filesystem.FileSystemSerDes; +import software.amazon.lambda.durable.serde.filesystem.FileSystemPathEncoding; +import software.amazon.lambda.durable.serde.filesystem.FileSystemSerDesStage; +import software.amazon.lambda.durable.serde.filesystem.FileSystemStorageMode; +import software.amazon.lambda.durable.serde.JacksonSerDes; -var serDes = FileSystemSerDes.builder(Path.of("/mnt/efs/durable-payloads")) +var fileSystemStage = FileSystemSerDesStage.builder(Path.of("/mnt/efs/durable-payloads")) .storageMode(FileSystemStorageMode.ALWAYS) .pathEncoding(FileSystemPathEncoding.URI) - .delegate(new JacksonSerDes()) + .checkpointEnvelopeLimitBytes(configuredCheckpointEnvelopeLimitBytes) .previewGenerator(optionalPreviewGenerator) .build(); +var serDes = new JacksonSerDes().then(fileSystemStage); + return DurableConfig.builder() .withSerDes(serDes) + .withSerDesExecutorService(customSerDesExecutor) .build(); ``` +### Composable SerDes pipeline + +`ComposableSerDes` is a core implementation of `SerDes`. It owns an immutable, ordered list of stages while preserving +the existing `serialize` and `deserialize` methods: + +```java +public final class ComposableSerDes implements SerDes { + public static ComposableSerDes of(SerDes valueCodec, SerDesStage... stages); + + public static Builder builder(SerDes valueCodec); + + public SerDes getValueCodec(); + + public SerDes then(SerDesStage stage); + + public static final class Builder { + public Builder then(SerDesStage stage); + + public ComposableSerDes build(); + } +} +``` + +The first stage is the **value codec**. It converts the user value to a string and converts the final decoded string +back to the requested `TypeToken`. Every later stage consumes and produces a `String`. Stage composition is valid by +construction; binary or other non-string intermediate representations remain encapsulated inside a string stage. + +Serialization runs from first to last: + +```text +Object + -> value codec + -> String + -> string stage 1 + -> String + -> string stage 2 + -> ... + -> checkpoint String +``` + +Deserialization runs in the opposite direction: + +```text +checkpoint String + -> last string stage + -> ... + -> first string stage + -> String + -> value codec, deserialized as the requested TypeToken + -> T +``` + +Equivalent pseudocode: + +```java +String serialize(Object value, SerDesContext context) { + String current = valueCodec.serialize(value); + SerDesContext stageContext = context.withOriginalValue(value); + for (var stage : stages) { + current = stage.serialize(current, stageContext); + } + return current; +} + + T deserialize(String data, TypeToken targetType, SerDesContext context) { + String current = data; + SerDesContext stageContext = context.withOriginalValue(null); + for (int i = stages.size() - 1; i >= 0; i--) { + current = stages.get(i).deserialize(current, stageContext); + } + return valueCodec.deserialize(current, targetType); +} +``` + +Pipeline rules: + +- A pipeline must contain exactly one value codec in the first position and zero or more string stages. +- Every top-level stage consumes and produces a non-null `String`. This makes all stage orderings type-compatible. +- `SerDesRunner` passes a read-only `SerDesContext` explicitly to every top-level and binary stage call. During + serialization the same derived stage context exposes the original object through `originalValue`; during + deserialization `originalValue` is `null`. A context-dependent stage validates the supplied parameter and reports a + normal SerDes failure when it is unavailable or incomplete. +- The test runners identify a configured input pipeline directly as `ComposableSerDes` and reject it because initial + input accepts one value codec, not a pipeline. +- `SerDes.then(...)`, `ComposableSerDes.then(...)`, and the builder accept only `SerDesStage` after the value codec. + This makes the root `SerDes` versus subsequent string-stage roles explicit in the Java type system. +- If the value-codec argument to `ComposableSerDes.of(...)` or the builder is already a `ComposableSerDes`, its root + codec and string stages are flattened while preserving stage order. +- A `null` value at the pipeline boundary short-circuits the entire pipeline: serializing or deserializing `null` + returns `null` without invoking any stage. A stage returning `null` for non-null input is an error. The value + codec may decode a non-null representation such as the JSON literal `null` to a null domain value. +- All stages execute within the same `SerDesRunner` invocation and receive the same read-only stage context, whether + the runner executes inline or dispatches to a configured executor. +- `ComposableSerDes` is immutable. It is safe for concurrent use only when every contained stage is also safe for + concurrent use, matching the existing `SerDes` requirement. +- A failure must identify the stage index and implementation class in `SerDesException`; `SerDesRunner` adds durable + entity and payload-kind metadata around the pipeline failure. +- A stage must be reversible. Compression, encryption, signing envelopes, and external payload storage are + suitable stages; lossy redaction is not. +- A stage must serialize into a self-identifying format, normally using a reserved marker and explicit version. + During deserialization it reverses recognized valid input, rejects recognized malformed or unsupported input, and + returns unrecognized input unchanged. Recognition must inspect the marker rather than attempt decoding and infer the + format from success or failure. +- The pass-through rule lets raw external payloads traverse every stage in reverse order and reach the root value codec + without filesystem-specific pipeline control flow. + +Equivalent stage pseudocode: + +```java +String deserialize(String data, SerDesContext context) { + if (!hasStageMarker(data)) { + return data; + } + validateSupportedEnvelope(data); + return decodeEnvelope(data); +} +``` + +- Stage order is meaningful. For example, `JSON -> binary composite -> filesystem` writes the encoded result of the + binary composite to the filesystem, while `JSON -> filesystem -> signing envelope` signs the filesystem envelope + rather than the offloaded payload. +- The ordered stage list and each stage's configuration are part of the persisted checkpoint format. They must remain + replay-compatible for in-flight executions. Reordering, removing, or incompatibly reconfiguring a stage requires a + versioned envelope or an explicit migration boundary. +- `ComposableSerDes` does not add a generic pipeline envelope or persist stage names. Stages that need format + evolution must version their own output. +- Global and operation-level SerDes selection continues to select one `SerDes` instance. A `ComposableSerDes` is + treated as that single instance; operation-level selection replaces the whole pipeline rather than merging stages. +- Invocation-scoped caching wraps the complete pipeline. Cache keys use the final checkpoint string and target type, so + cache hits skip every reverse-processing stage, including filesystem reads. + +The default `SerDes.then(SerDesStage)` method and immutable `ComposableSerDes.then(SerDesStage)` method provide a +concise form for independently reusable processing chains: + +```java +var binaryStage = ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .then(compressionBinaryStage) + .then(encryptionBinaryStage) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + +var securePayloads = new JacksonSerDes() + .then(binaryStage) + .then(fileSystemStage); +``` + +### Composable binary stage + +`ComposableBinarySerDesStage` is one top-level `String -> String` stage containing zero or more `byte[] -> byte[]` +transformations. It wraps the ending codec's string in its own reserved, versioned frame so it can distinguish its +serialized output from raw external input: + +```java +public final class ComposableBinarySerDesStage implements SerDesStage { + public static StartBuilder builder(); + + public interface StartBuilder { + BinaryStagesBuilder startWith(StringBinaryCodec codec); + } + + public interface BinaryStagesBuilder { + BinaryStagesBuilder then(BinarySerDesStage stage); + + CompletedBuilder endWith(StringBinaryCodec codec); + } + + public interface CompletedBuilder { + ComposableBinarySerDesStage build(); + } +} +``` + +The builder follows forward serialization order and its staged return types prevent another binary transformation from +being appended after `endWith(...)`. + +```text +serialization: +String + -> startingCodec.toBytes + -> binary SerDes 1 + -> binary SerDes 2 + -> endingCodec.fromBytes + -> String + +deserialization: +String + -> endingCodec.toBytes + -> binary SerDes 2 + -> binary SerDes 1 + -> startingCodec.fromBytes + -> String +``` + +Both boundaries use the same `StringBinaryCodec` contract. The core SDK provides UTF-8 and standard Base64 +implementations, while callers may provide reversible alternatives. Each `BinarySerDesStage` must include required +metadata, such as a format version or encryption initialization vector, in its output. The composite performs text +conversion only at its two outer boundaries; binary stages pass bytes directly to each other. + +### Retryable SerDes stages + +Transient failures are explicit. `RetryableSerDesException` extends `SerDesException` and marks a failure that may +succeed when attempted again. `RetrySerDesStage` decorates a `SerDesStage`, and `RetryBinarySerDesStage` decorates a +`BinarySerDesStage`. Both apply an existing `RetryStrategy`: + +```java +var resilientFileSystemStage = new RetrySerDesStage( + fileSystemStage, + RetryStrategies.exponentialBackoff( + 3, + Duration.ofSeconds(1), + Duration.ofSeconds(5), + 2.0, + JitterStrategy.FULL)); + +var serDes = new JacksonSerDes().then(resilientFileSystemStage); + +var resilientEncryptionStage = new RetryBinarySerDesStage( + encryptionStage, + RetryStrategies.fixedDelay(3, Duration.ofMillis(100))); +``` + +Retry rules: + +- Only `RetryableSerDesException` is retried. Ordinary `SerDesException` and other failures propagate immediately. +- `RetryStrategy.makeRetryDecision(error, attempt)` receives the transient failure and a 1-based attempt number. +- When the strategy returns `fail`, the retry wrapper rethrows the last `RetryableSerDesException`. +- The same read-only `SerDesContext` parameter is passed to every attempt because retrying happens inside the original + `SerDesRunner` task. +- `RetryBinarySerDesStage` snapshots its input and passes a fresh byte-array clone to every attempt in both directions. + A delegate may therefore mutate its attempt-local bytes without contaminating a later retry or the caller's input. +- A retry delay blocks the thread executing the SerDes call. This is the caller thread by default or a SerDes executor + thread when one is explicitly configured. It is an in-invocation infrastructure retry, not a durable wait or + checkpoint. Strategies must therefore use short, bounded delays that fit within the Lambda invocation timeout. +- If the invocation is interrupted or times out, replay may execute the SerDes pipeline again. Any stage with side + effects must use stable addressing and idempotent writes. +- `RetrySerDesStage` wraps an individual `SerDesStage`, such as `FileSystemSerDesStage`. + `RetryBinarySerDesStage` wraps an individual binary stage inside a `ComposableBinarySerDesStage`. Neither can wrap + the value codec or complete pipeline. Retrying only the transient component avoids repeating unrelated deterministic + work. +- Pipeline error decoration must preserve retryability: a stage-level `RetryableSerDesException` must remain that type, + with stage metadata added to its message or cause, so an enclosing retry wrapper can recognize it. +- Filesystem read and write `IOException`s are retryable. Malformed envelopes, invalid paths, unsupported types, and + stage transformation errors are permanent. + 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. | +| `ALWAYS` | Always write the incoming stage representation to a file and return a file envelope. | +| `OVERFLOW` | Return an inline envelope until it approaches the configured checkpoint-envelope limit, then write the incoming stage representation to a file. | Path encodings: @@ -106,30 +402,85 @@ Path encodings: Envelope format: ```json -{"data":""} -{"file":""} -{"file":"","preview":{ "...": "..." }} +{"__durable_execution_filesystem_serdes":1,"data":"","payloadType":"STRING","payloadDigest":"","ownerDurableExecutionArn":"","ownerEntityId":""} +{"__durable_execution_filesystem_serdes":1,"file":"","payloadType":"STRING","payloadDigest":"","ownerDurableExecutionArn":"","ownerEntityId":""} +{"__durable_execution_filesystem_serdes":1,"file":"","payloadType":"STRING","payloadDigest":"","ownerDurableExecutionArn":"","ownerEntityId":"","preview":{ "...": "..." }} ``` -`FileSystemSerDes` must reject calls when `SerDesContext.getCurrentContext()` is `null` or does not include `durableExecutionArn` and `entityId`. +`FileSystemSerDesStage` must reject recognized filesystem operations when its `SerDesContext` parameter is `null` or does +not include `durableExecutionArn` and `entityId`. It accepts a `String`, records the payload type in the envelope, and +restores that string during reverse processing. A preceding `ComposableBinarySerDesStage` must encode its final bytes +to a string before filesystem storage. + +The marker and version distinguish filesystem envelopes from strings that were not produced by this stage. +`FileSystemSerDesStage.deserialize(...)` returns input without the filesystem marker unchanged, regardless of payload +source. If the marker is present, the value is recognized as filesystem data and must be a valid supported envelope; +malformed marked envelopes and unsupported versions fail rather than falling back to pass-through behavior. The +context parameter is explicit on every call, but a recognized filesystem envelope requires a non-null SDK-managed +context while unrecognized input passes through even if that parameter is `null`. + +Offloaded filenames include the entity identity, content hash, and a UUID. Each serialization publishes a new immutable +file with one `CREATE_NEW` write. It does not require hard links or renames, making the write path compatible with S3 +Files as well as EFS. Serializing new state never replaces a file referenced by an earlier checkpoint. + +Every envelope includes the SHA-256 digest of the serialized payload. Deserialization verifies inline data and loaded +file bytes against that digest. File references must additionally use a content-addressed filename consistent with the +envelope digest, so changing both a file's contents and its filename cannot bypass integrity validation without also +changing the checkpoint envelope. + +File envelopes identify the execution ARN and entity that produced the content. Normal checkpoint replay requires +that owner to match the current context. Initial input and chained-invoke result boundaries may consume a reference +owned by the other Lambda execution, allowing two functions configured with the same durable filesystem root and path +encoding to exchange offloaded invoke payloads and results. The declared owner must still match the content-hashed +path, and the resolved file must remain beneath the configured root. The file envelope is therefore a capability and +must be protected with the same care as the payload it references. + +The final file envelope, including any preview, must remain below the configured checkpoint-envelope threshold. +`FileSystemSerDesStage` retains the current 255 KiB value as its default while allowing applications to increase it as +the service adds support for larger payloads. Oversized previews are rejected rather than producing a checkpoint that +the service cannot accept. + +Stages may follow `FileSystemSerDesStage` to transform its inline or file-reference envelope. Its overflow and preview-size +checks apply at the filesystem stage boundary, so configurations must account for any size expansion introduced by +later stages. During deserialization, each stage validates its own reserved format and passes unrecognized input +through unchanged. Therefore raw external data can traverse stages on either side of `FileSystemSerDesStage` without being +decoded as a pipeline value. + +For JSON pipelines, `previewConfig(...)` parses the `String` produced by the preceding stage and provides the same +structured preview controls as the Python and TypeScript SDKs: include-all or exclude-all mode, include/exclude/mask +selectors, field-name or exact-path matching, a configurable mask string, and a default 4 KB byte budget. The +standalone `SerDesPreview` utility exposes the same builder for customer-managed values. `previewGenerator(...)` +remains available for non-JSON stage values and fully custom preview logic; it receives both the stage string and the +serialization `SerDesContext`, whose `originalValue` exposes the object supplied to the root value codec. ### Runtime flow ```java -SerDesContextHolder.set(context); -try { - var checkpointPayload = fileSystemSerDes.serialize(value); - sendCheckpoint(checkpointPayload); -} finally { - SerDesContextHolder.clear(); -} +var checkpointPayload = serDesRunner.serialize(composableSerDes, value, context); +sendCheckpoint(checkpointPayload); ``` -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. +Inside `SerDesRunner`, a composable pipeline receives `context` directly. The pipeline invokes the root value codec +without context, derives a serialization stage context containing the original object, and forwards that derived +context to every stage. Deserialization stages receive a context with no original value. + +On deserialization, `FileSystemSerDesStage` first checks for its reserved marker. Unmarked input is returned unchanged. If +the marked envelope contains `data`, it restores the inline text; if it contains `file`, it reads the stored string. +`ComposableSerDes` then passes that value to the preceding string stage. Raw external input, callback results, and +standard invoke results pass unchanged through every stage whose marker is absent until they reach the value codec. + +Filesystem path validation and access are atomic with respect to path replacement. The implementation traverses from +the filesystem root through relative `SecureDirectoryStream` handles with `NOFOLLOW_LINKS`, retains those handles +through the payload read or `CREATE_NEW` write, and performs failed-write cleanup relative to the same held directory. +It fails closed when the mounted provider does not support `SecureDirectoryStream`. ### Threading -Add a separate executor to `DurableConfig`: +Preserve the current SDK behavior by executing SerDes inline on the calling thread by default. Do not create a default +SerDes thread pool. This avoids a queue operation, `CompletableFuture` allocation, and thread hop for ordinary in-memory +serialization such as `JacksonSerDes`. + +Customers can explicitly configure a separate executor when a SerDes performs blocking I/O or retry backoff: ```java DurableConfig.builder() @@ -137,31 +488,61 @@ DurableConfig.builder() .build(); ``` -The default should be a cached daemon thread pool named `durable-sdk-serdes-*`. +If `withSerDesExecutorService(...)` is not called, the configured executor is absent and `SerDesRunner` invokes the +pipeline synchronously on the current thread. The builder method accepts only a non-null executor; not calling it is +how customers select inline execution. If an executor is configured, `SerDesRunner` dispatches the complete pipeline +to that executor and waits for its result. Configuration rejects using the same executor instance for user operations +and SerDes because synchronous dispatch to a saturated shared pool can deadlock. 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. +- Executes inline when no SerDes executor is configured. +- Dispatches to the configured executor only when one is present. +- Passes `SerDesContext` explicitly to every stage in a `ComposableSerDes`. +- Adds the original object to the context passed to serialization stages and clears it for deserialization stages. +- Invokes the existing `SerDes.serialize` and `SerDes.deserialize` methods for non-composable implementations. - 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. +Equivalent execution flow: + +```java +T run(SerDesContext context, Supplier operation) { + if (serDesExecutorService == null) { + return operation.get(); + } + return CompletableFuture.supplyAsync(operation, serDesExecutorService).join(); +} +``` + +Context propagation does not depend on thread-local state. `SerDesRunner` passes context into the composable pipeline +as an ordinary argument, so inline and executor-backed calls have the same stage semantics. + +Inline execution is the compatibility and low-overhead default, not a recommendation to perform blocking storage work +on operation threads. Documentation and filesystem examples should configure a SerDes executor whenever +`FileSystemSerDesStage`, a delayed retry stage, or another blocking stage is used. If it is omitted, I/O and retry delays +block the calling thread. ### Caching Add an invocation-scoped cache for successful deserialization results. The cache key should include: +- The identity of the SerDes instance. - Durable execution ARN. - `entityId`. - Payload kind. +- Attempt, when applicable. - 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. +The SerDes identity prevents two configured pipelines from sharing a call-order-dependent result. The serialized string +hash prevents stale results when a `WAIT_FOR_CONDITION` or retried step updates the same operation payload across +attempts. Concurrent misses share one in-flight deserialization. Completed values use a bounded weak-reference cache +so a large replay does not retain every materialized object. Cache entries live only for the current Lambda invocation +and are discarded when `ExecutionManager` closes. Cloud test polling creates a fresh cache for each history snapshot +and retains that cache only with the corresponding `TestResult`. -With this approach, SDK caching can avoid repeated calls to `FileSystemSerDes.deserialize`. If a cache miss occurs, `FileSystemSerDes` may perform a file read internally. +With this approach, SDK caching can avoid repeated calls to `FileSystemSerDesStage.deserialize`. If a cache miss occurs, `FileSystemSerDesStage` may perform a file read internally. ### Exceptions @@ -174,41 +555,88 @@ Keep the current `ErrorObject` shape: 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`. +`FileSystemSerDesStage` 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`. +Root user input and output payloads should route through `SerDesRunner` so `FileSystemSerDesStage` can see `SerDesContext`. The internal `DurableExecutionInput` and `DurableExecutionOutput` envelope stays with `DurableInputOutputSerDes`. + +The cloud test runner must send initial Lambda input before it receives a durable execution ARN. Initial input +therefore uses a separate context-free value codec configured by `DurableConfig.withInputSerDes(...)`. By default, +`DurableConfig` uses the configured persisted SerDes when it is a plain value codec, or the root value codec when it is +composable. An explicit input codec rejects `ComposableSerDes`: persisted stages are never used at the external +invocation boundary. + +`DurableExecutor` deserializes the execution operation's initial input directly with the configured input codec rather +than the persisted pipeline. The codecs do not need to be wire-compatible, and an external payload that resembles a +persisted stage frame cannot be consumed by that stage accidentally. `LocalDurableTestRunner.withInputSerDes(...)` +updates both its encoder and the copied runtime configuration. `CloudDurableTestRunner.withInputSerDes(...)` updates +the client-side encoder, so the deployed handler must configure the same input codec through `DurableConfig`. Fluent +runner configuration preserves an explicit input-codec override when other runner configuration is replaced. + +Chained invokes preserve their existing wire contract by default. Unless an explicit payload SerDes is configured, the +caller uses its context-free input codec and sends the serialized value unchanged. This keeps standard Lambda targets, +non-Java durable targets, and older Java SDK versions compatible. + +`InvokeConfig.usePersistedSerDesForPayload(true)` is an explicit target-capability opt-in for compatible Java durable +handlers. In this mode the caller uses its persisted SerDes by default and `InvokeOperation` adds a reserved, versioned +source frame outside the pipeline output. The target `DurableExecutor` recognizes and removes that frame before +deserializing with its persisted SerDes. Unframed execution input always uses the context-free input codec. A custom +per-invoke payload SerDes in this mode must be compatible with the target handler's persisted SerDes. ### 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. +1. Add `SerDesContext` and `SerDesPayloadKind`. Leave the existing context-free `SerDes` methods unchanged. +2. Add the binary-compatible `SerDes.then(SerDesStage)` default method and `ComposableSerDes` with one root `SerDes` + followed only by immutable `SerDesStage` entries, forward serialization, reverse deserialization, + self-identifying stage pass-through, null short-circuiting, and stage-aware errors. +3. Add the string-only `SerDesStage` contract plus `BinarySerDesStage`, `StringBinaryCodec`, and + a version-framed `ComposableBinarySerDesStage` for nested binary processing with ordered, configurable boundaries. +4. Add `RetryableSerDesException`, `RetrySerDesStage`, and `RetryBinarySerDesStage`, reusing `RetryStrategy` for + bounded in-invocation retries. +5. Add `SerDesRunner` with inline execution by default and optional dispatch through + `DurableConfig.withSerDesExecutorService(...)`. Do not create a default SerDes pool. +6. Update root input/output handling in `DurableExecutor` to run user payload SerDes through `SerDesRunner` while + leaving `DurableInputOutputSerDes` internal. +7. Update `SerializableDurableOperation`, `InvokeOperation`, `StepOperation`, `WaitForConditionOperation`, + `CallbackOperation`, `ChildContextOperation`, `MapOperation`, and test helpers to use `SerDesRunner`. +8. Add bounded, invocation-scoped deserialization caching keyed by SerDes identity, entity, payload kind, type, and + serialized data hash. +9. Update exception serialization and deserialization paths to set `SerDesPayloadKind.EXCEPTION` in TLS. +10. Implement `FileSystemSerDesStage` in the core `software.amazon.lambda.durable.serde.filesystem` package as a string + stage with + `ALWAYS` and `OVERFLOW` storage, `URI` and `HASH` path encodings, envelope parsing, immutable `CREATE_NEW` writes + compatible with EFS and S3 Files, retryable I/O failures, unrecognized-input pass-through, structured preview + generation, and clear validation errors for recognized malformed input. +11. Add unit tests for string and binary pipeline ordering, custom boundary codecs, reverse processing, nulls, stage + failures, retry selection, exhaustion, delay handling, interruption, context construction, TLS scoping and + restoration, inline execution, configured thread-pool isolation, cache hits, cache invalidation, exception + reconstruction, malformed filesystem envelopes, and core-artifact packaging. +12. Add integration tests with `LocalDurableTestRunner` for multi-stage pipelines, step results, wait-for-condition + state, invoke payload/result, child context results, map results, repeated `get()`, replay from file pointers, and + custom exception types. +13. Update README and advanced configuration docs with pipeline and retry examples, filesystem configuration, 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. +- Preserves inline SerDes execution by default, avoiding new thread-hop overhead for existing applications. +- Makes string stages safely composable while keeping compression and encryption efficiently composable inside one + binary stage. +- Makes filesystem storage available without an additional Maven dependency or release artifact. - 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. +- Requires non-codec stages to obey a string-to-string convention that the current `SerDes` type system cannot enforce. - 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. +- Makes pipeline order and configuration part of checkpoint compatibility for in-flight executions. ## Approach B: Create a PayloadOffloader Interface @@ -314,7 +742,8 @@ The SDK owns the checkpoint/offload envelope. Storage implementations own only t ### 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: +Keep ordinary SerDes work inline by default for compatibility. Blocking payload I/O can use the explicitly configured +SerDes executor or a distinct offload executor if the team wants independent tuning: ```java DurableConfig.builder() @@ -323,9 +752,11 @@ DurableConfig.builder() .build(); ``` -If a single executor is preferred, name it according to the broader responsibility, for example `durable-sdk-payload-*`. +No executor should be created by default. If a single explicitly configured 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. +Because filesystem/S3/DynamoDB offloading can block, production configurations should provide an executor rather than +run that work inline. Offload work must never use the internal SDK executor. ### Caching @@ -369,7 +800,9 @@ This approach gives the SDK one consistent policy for root payloads, operation r 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. +11. Add unit tests for offload envelope compatibility, precedence rules, inline execution, explicitly configured + 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. @@ -387,7 +820,7 @@ This approach gives the SDK one consistent policy for root payloads, operation r - 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. +- Slows direct FileSystemSerDesStage 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. @@ -395,53 +828,111 @@ This approach gives the SDK one consistent policy for root payloads, operation r | 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. | +| Responsibility boundary | Combines value serialization and storage-reference creation in ordered, composable SerDes stages. | Keeps object encoding in `SerDes` and storage movement in a separate offloader. | +| User configuration | Users configure one SerDes or a `ComposableSerDes` pipeline. Operation-level SerDes selection replaces the whole pipeline. | 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. | +| Core SDK changes | Adds the compatible `SerDes.then(SerDesStage)` default method, `ComposableSerDes`, and explicit stage context parameters while root codecs remain context-free. | Requires a new core extension point, envelope type, config surface, payload pipeline, and migration story. | +| Applicability | Any compatible `SerDesStage` implementations can be chained, but storage stages still own their envelope and lifecycle behavior. | Any SerDes output can be offloaded uniformly after serialization. Users can combine Jackson/custom SerDes with any offloader. | +| Envelope ownership | FileSystemSerDesStage 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 FileSystemSerDesStage 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. | +| Third-party storage | S3, DynamoDB, and other backends can be implemented as additional reversible SerDes stages, either in core or separate artifacts based on their dependencies and support model. | 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 +## Decision -**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. +Adopt **Approach A: Reuse SerDes for Offload**, extended with a core `ComposableSerDes` pipeline. It delivers +JavaScript parity, includes filesystem behavior in the existing core artifact, leaves the existing `SerDes` methods unchanged, +and lets customers assemble value encoding, compression, encryption, and storage as independently reusable stages. +Approach B remains a possible future direction if payload offloading grows into a general multi-backend capability +that requires SDK-owned storage envelopes and lifecycle policy. -Reasoning: +## Other Alternatives Considered -- 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. +### Use a binary-only top-level SerDes pipeline -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. +Considered replacing the two-level string/binary composition model with one binary pipeline: -## Other Alternatives Considered +```java +public interface BinarySerDes { + byte[] serialize(Object value); + + T deserialize(byte[] data, TypeToken typeToken); + + BinarySerDes then(SerDesStage stage); + + SerDes then(StringBinaryCodec terminalCodec); +} + +public interface SerDesStage { + byte[] serialize(byte[] value, SerDesContext context); + + byte[] deserialize(byte[] data, SerDesContext context); +} +``` + +In this alternative, `SerDes` keeps only its existing object-to-string methods for backward compatibility. +Composition starts from a new object-to-bytes implementation such as `JacksonBinarySerDes`, every intermediate stage +uses `byte[]`, and the chain ends with one binary-to-string codec. `JacksonSerDes` could appear to be implemented as: + +```java +var jacksonSerDes = new JacksonBinarySerDes() + .then(Utf8StringBinaryCodec.INSTANCE); +``` + +This has an attractive single-level type model and is potentially more efficient when compression, encryption, or +another binary stage always follows Jackson. Jackson can produce bytes directly, and those bytes can pass through all +intermediate stages without first creating a JSON string and encoding that string back to UTF-8. It also eliminates +the need for `ComposableBinarySerDesStage`. + +Rejected for the current design because it makes the common default `JacksonSerDes` path less efficient. Implementing +the existing string-facing API through `JacksonBinarySerDes` would serialize as object -> UTF-8 `byte[]` -> `String` +and deserialize as `String` -> UTF-8 `byte[]` -> object. Compared with Jackson's direct `writeValueAsString(...)` and +`readValue(String, ...)` paths, that requires an additional full-payload conversion and byte-array allocation in both +directions. Special-casing `JacksonBinarySerDes` plus the UTF-8 terminal codec to bypass those conversions would restore +performance, but it would introduce a second execution path and undermine the uniform pipeline model. -### Add FileSystemSerDes without SerDesContext +The alternative also moves all existing string-oriented stages and custom `SerDes` implementations behind adapters or +requires binary replacements, while removing `SerDes.then(...)` from the composable API. The accepted design preserves +direct string SerDes performance and compatibility, and its nested binary composite already avoids conversions between +individual binary transformations: it converts to bytes once before the binary chain and back to a string once after +the chain. + +Revisit a binary-root pipeline in a future major version if profiling shows that JSON string creation before +binary-heavy pipelines is a material bottleneck and the benefit outweighs the default-path allocation cost and +migration burden. + +### Add FileSystemSerDesStage without SerDesContext 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. -### Put filesystem-backed offloading in the core SDK artifact +### Publish filesystem-backed offloading as a separate artifact -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. +Rejected for Approach A. The implementation adds no new production dependency, is part of the accepted JavaScript +parity feature, and already relies on core SerDes context and pipeline behavior. A separate artifact would add module, +publishing, documentation, and dependency-management overhead without isolating a distinct dependency graph. ### Add context-aware SerDes overloads -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. +Rejected for the root `SerDes` interface in Approach A. Context is explicit on the new stage interfaces, where it does +not affect compatibility. Adding it to `SerDes` itself would force every existing custom value codec to change its +serialization method surface. Approach A keeps those signatures unchanged and root value codecs context-free. The +pipeline enriches serialization-stage context with the original object instead. Approach B likewise passes +`PayloadOffloadContext` explicitly to its offloader. ### Make SerDes async -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. +Deferred. The TypeScript SDK uses async SerDes because file and service I/O are naturally async in Node.js. Java can +optionally isolate blocking work with an explicitly configured executor while preserving synchronous user-facing +interfaces and inline defaults. A future major version can revisit `CompletionStage` and `CompletionStage` +if there is a stronger need. -### Run payload storage on the user executor +### Always run payload storage inline -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. +Rejected as the only execution mode. Inline execution remains the default for backward compatibility and low overhead, +but filesystem-backed storage and delayed retries can block the calling thread. Customers should explicitly configure a +SerDes executor for those stages. ### Run payload storage on the internal SDK executor @@ -459,19 +950,26 @@ Rejected. The backend request/response envelope is protocol data. User payload c 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. +- Both approaches enable filesystem-backed payload storage without changing the existing `serialize`/`deserialize` + signatures. +- Approach A makes filesystem-backed storage available from the core SDK without an additional artifact. - Custom payload implementations get enough context to use external storage safely. -- Blocking payload work is isolated from user operation and SDK coordination threads. +- Customers can compose reusable `SerDesStage` implementations without creating a bespoke wrapper for each combination. +- Blocking payload work can be isolated from user operation threads with an explicitly configured SerDes executor and + never runs on the SDK coordination executor. - Repeated file reads and repeated object reconstruction can be reduced within an invocation. - User exception type reconstruction remains supported. 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. +- Adds optional executor, context, and caching machinery that must stay deterministic. +- Adds storage-specific public API and implementation code to the core SDK artifact. +- Root value codecs do not receive durable payload context; only pipeline stages receive it explicitly. +- Top-level stages must encode non-string representations at their boundaries. A composable binary stage avoids + repeated conversion between binary substages, but its final bytes still require one string encoding. +- Pipeline ordering and stage configuration must remain compatible with persisted checkpoints. +- The inline default means filesystem I/O and retry delays block the caller when customers do not configure a SerDes + executor. - 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. @@ -479,8 +977,8 @@ Negative: 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 generalized SDK-owned payload-offloading abstraction beyond the SerDes pipeline. +- A fully async Java SerDes or async pipeline contract. - A separate, explicitly dangerous protocol-envelope customization API. - File cleanup, retention policies, and lifecycle management for offloaded payloads. @@ -499,31 +997,31 @@ Both approaches need a stable payload identity that can be used to address exter | Root input | `execution//input` | | Root output | `execution//output` | | Root exception | `execution//exception` | -| Step result | `operation//result` | -| Step exception | `operation//exception` | +| Step result | `operation//result/attempt-` | +| Step exception | `operation//exception/attempt-` | | 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` | +| WaitForCondition state | `operation//state/attempt-` | Do not include the checkpoint token or raw user payload in the context. -### Extra package pattern - -Payload offloading implementations should live outside the core SDK artifact when they target a specific storage mechanism. +### Packaging boundary -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. +Approach A's filesystem implementation is part of the core SDK because it adds no external production dependency and +is the concrete parity feature accepted by this ADR. A future storage stage may use a separate artifact when it brings +substantial provider-specific dependencies or has an independent support and release model. | 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 A | `aws-durable-execution-sdk-java` | `software.amazon.lambda.durable.serde.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` | -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. +The repository should not publish both a filesystem SerDes and a filesystem offloader for the same feature. ### Protocol SerDes boundary diff --git a/docs/advanced/configuration.md b/docs/advanced/configuration.md index bc8bf89d0..67ec11e79 100644 --- a/docs/advanced/configuration.md +++ b/docs/advanced/configuration.md @@ -16,9 +16,11 @@ public class OrderProcessor extends DurableHandler { return DurableConfig.builder() .withLambdaClientBuilder(lambdaClientBuilder) - .withSerDes(new MyCustomSerDes()) // Custom serialization + .withSerDes(new MyCustomSerDes()) // Custom serialization + .withInputSerDes(new MyInputSerDes()) // Optional initial invocation codec .withExecutorService(Executors.newFixedThreadPool(10)) // Custom thread pool - .withLoggerConfig(LoggerConfig.withReplayLogging()) // Enable replay logs + .withSerDesExecutorService(Executors.newFixedThreadPool(4)) // Optional SerDes/payload I/O pool + .withLoggerConfig(LoggerConfig.withReplayLogging()) // Enable replay logs .build(); } @@ -32,14 +34,26 @@ public class OrderProcessor extends DurableHandler { | Option | Description | Default | |-----------------------------|-----------------------------------------|-------------------------------| | `withLambdaClientBuilder()` | Custom AWS Lambda client | Auto-configured Lambda client | -| `withSerDes()` | Serializer for step results | Jackson with default settings | +| `withSerDes()` | Serializer for persisted execution values | Jackson with default settings | +| `withInputSerDes()` | Context-free codec for initial invocation input | Persisted value codec, or pipeline root | | `withExecutorService()` | Thread pool for user-defined operations | Cached daemon thread pool | +| `withSerDesExecutorService()` | Optional thread pool for SerDes and payload storage 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 runs synchronously on the calling thread to preserve existing behavior and avoid a queue, +`CompletableFuture`, and thread-hop cost for in-memory serialization. Configure +`withSerDesExecutorService()` when a SerDes performs blocking filesystem or network I/O, or uses retry backoff. The +SerDes executor must be different from the user-operation executor to avoid deadlock when the operation pool is +saturated. + +For value codecs, composable string and binary stages, stage context, retry and executor behavior, test-runner input +codecs, and filesystem-backed payload storage, see +[Serialization and SerDes pipelines](serdes.md). + ### 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..f2a28873e 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 failure eligible for a retry stage wrapper. ├── 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..baaf36858 --- /dev/null +++ b/docs/advanced/serdes.md @@ -0,0 +1,330 @@ +# Serialization and SerDes pipelines + +The SDK uses a `SerDes` to convert durable values between Java objects and the strings stored in checkpoints. The +default is `JacksonSerDes`, so most applications do not need to configure serialization. + +The core SDK also supports composable pipelines for transforming the serialized string before it is persisted. A +pipeline has one context-free value codec followed by zero or more reversible `SerDesStage` instances: + +```text +serialization: Object -> SerDes -> String -> stage 1 -> stage 2 -> String +deserialization: Object <- SerDes <- String <- stage 1 <- stage 2 <- String +``` + +Serialization runs stages in declaration order. Deserialization runs them in reverse order. + +## Value codecs + +`SerDes` is the object-to-string boundary: + +```java +public interface SerDes { + String serialize(Object value); + + T deserialize(String data, TypeToken typeToken); +} +``` + +The `TypeToken` preserves runtime type information needed to deserialize generic Java types. `JacksonSerDes` implements +this interface and can be constructed with a custom Jackson `ObjectMapper`: + +```java +var objectMapper = JsonMapper.builder() + .findAndAddModules() + .build(); + +var serDes = new JacksonSerDes(objectMapper); + +return DurableConfig.builder() + .withSerDes(serDes) + .build(); +``` + +Existing custom `SerDes` implementations remain valid. Pipeline behavior is opt-in: calling `then(...)` creates a +composable `SerDes`, while using a value codec by itself retains the existing object-to-string behavior. + +## Selecting a SerDes + +`DurableConfig.Builder.withSerDes(...)` sets the default SerDes for persisted values: + +```java +return DurableConfig.builder() + .withSerDes(new JacksonSerDes()) + .build(); +``` + +Operation configuration builders also support `serDes(...)` for overriding the complete SerDes used by that operation. +This is a replacement, not an additional stage appended to the global pipeline. `InvokeConfig` additionally supports +`payloadSerDes(...)` for the invoked function's payload; `serDes(...)` controls the invoke result. + +## Composable pipelines + +Every top-level pipeline stage implements `SerDesStage` and transforms a string: + +```java +public interface SerDesStage { + String serialize(String value, SerDesContext context); + + String deserialize(String data, SerDesContext context); +} +``` + +Append stages to a value codec with `SerDes.then(...)`: + +```java +SerDes serDes = new JacksonSerDes() + .then(compressionStage) + .then(encryptionStage) + .then(storageStage); +``` + +The returned value is a `SerDes`, so applications can configure or pass a pipeline anywhere a regular `SerDes` is +accepted. Additional `then(...)` calls continue the same immutable pipeline. + +Stages must use a self-identifying, normally versioned representation. During deserialization, a stage must: + +- reverse input that is valid and uses its format; +- throw an exception for input that identifies itself as the stage's format but is malformed or uses an unsupported + version; and +- return input unchanged when it does not use the stage's format. + +The pass-through rule allows raw invocation payloads, callback results, and standard Lambda invoke results to traverse +the pipeline and reach its value codec. It also allows stages to be added without making older checkpoint values +unreadable. Each stage owns the compatibility policy for its format; the pipeline does not add a shared outer +envelope. + +The pipeline short-circuits a `null` value at its boundary. A stage must not return `null` for non-null input. + +## Binary transformations + +Compression, encryption, and similar transformations are usually easier to implement on bytes. A +`BinarySerDesStage` transforms `byte[]` values: + +```java +public interface BinarySerDesStage { + byte[] serialize(byte[] value, SerDesContext context); + + byte[] deserialize(byte[] data, SerDesContext context); +} +``` + +Use `ComposableBinarySerDesStage` to expose several binary stages as one string-to-string `SerDesStage`: + +```java +var binaryStage = ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .then(compressionBinaryStage) + .then(encryptionBinaryStage) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + +SerDes serDes = new JacksonSerDes() + .then(binaryStage); +``` + +The builder follows serialization processing order: + +1. `startWith(...)` converts the incoming string to bytes. +2. Each `then(...)` stage transforms those bytes. +3. `endWith(...)` converts the final bytes back to a string. + +Deserialization reverses that order. Both boundaries implement the same `StringBinaryCodec` interface and are +customizable. The core SDK includes UTF-8 and Base64 codecs. + +`ComposableBinarySerDesStage` adds a reserved, versioned frame to its output. It decodes recognized frames, rejects +malformed or unsupported frames, and passes unrecognized strings through unchanged. + +## Stage context + +The SDK passes a read-only `SerDesContext` explicitly to every `SerDesStage` and nested `BinarySerDesStage`. The +context describes the durable execution, entity, operation, attempt, and payload kind being processed. + +During serialization, `originalValue()` contains the object supplied to the root value codec. This lets a stage derive +metadata such as a preview from the original object even though its direct input is a string. During deserialization, +`originalValue()` is `null`. + +Root `SerDes` value codecs do not receive `SerDesContext`; they remain usable outside a durable execution. A context can +also be `null` when an application invokes a stage directly outside the SDK, so stages should document whether their +recognized format requires durable context. + +The SDK caches successful deserialization results for the current Lambda invocation in a bounded, weak-reference +cache. Concurrent requests for the same persisted value share one in-flight deserialization. Results are not cached +across invocations. + +## Retries and execution + +`RetrySerDesStage` wraps a `SerDesStage`, and `RetryBinarySerDesStage` wraps a `BinarySerDesStage`: + +```java +var resilientStorageStage = new RetrySerDesStage( + storageStage, + RetryStrategies.fixedDelay(3, Duration.ofSeconds(1))); +``` + +These wrappers retry only `RetryableSerDesException`. Permanent errors, such as malformed envelopes or codec failures, +are not retried. `RetryBinarySerDesStage` snapshots its input and gives every attempt a fresh byte array, so a failed +delegate cannot leak in-place mutations into the next attempt. Retry delays consume time in the current Lambda +invocation, so keep strategies short and bounded. + +SerDes runs inline on the calling thread by default, preserving the existing no-thread-pool behavior and avoiding a +thread hop for in-memory serialization. Blocking stages, including filesystem access and retry backoff, can use a +dedicated executor: + +```java +return DurableConfig.builder() + .withSerDes(serDes) + .withSerDesExecutorService(Executors.newFixedThreadPool(4)) + .build(); +``` + +The SerDes executor must be different from the user-operation executor to avoid deadlock when the operation pool is +saturated. + +## Initial invocation payloads + +The initial Lambda invocation uses a separate, context-free value codec because no durable execution context exists +yet. `DurableExecutor` deserializes this payload directly with `DurableConfig.getInputSerDes()` and does not run the +persisted pipeline's stages at this boundary. This also prevents an external payload from being mistaken for a +persisted stage frame. + +By default, `DurableConfig` uses the configured SerDes if it is a plain value codec, or the root value codec if it is a +`ComposableSerDes`. Use `DurableConfig.Builder.withInputSerDes(...)` to select another input codec. The explicit input +codec must not be a `ComposableSerDes`, but it does not need to be wire-compatible with the persisted pipeline: + +```java +var config = DurableConfig.builder() + .withSerDes(persistedPipeline) + .withInputSerDes(externalInputCodec) + .build(); +``` + +`LocalDurableTestRunner.withInputSerDes(...)` updates both sides of the local boundary: the runner serializes with the +selected codec and the local runtime deserializes with it. `CloudDurableTestRunner.withInputSerDes(...)` controls the +invocation sent to AWS; the deployed handler must configure the same codec through `DurableConfig`. + +Chained invokes preserve the existing standard-Lambda wire contract by default: the caller uses its context-free input +codec unless `InvokeConfig.payloadSerDes(...)` is set, and sends that serialized value unchanged. This remains +compatible with standard Lambda functions, non-Java durable functions, and older Java SDK versions. + +To offload or otherwise process an invoke payload through a persisted pipeline, both Java durable handlers must +configure compatible pipelines and the caller must opt in: + +```java +var result = context.invoke( + "invoke-compatible-handler", + targetFunction, + payload, + Result.class, + InvokeConfig.builder() + .usePersistedSerDesForPayload(true) + .build()); +``` + +The opt-in adds a reserved, versioned SDK source frame outside the serialized payload. A compatible target removes the +frame and deserializes the enclosed value with its persisted SerDes. Unframed execution input always uses the +context-free input codec. + +## Filesystem-backed payload storage + +`FileSystemSerDesStage` stores serialized payloads on a durable shared filesystem and leaves small, versioned +file-reference envelopes in checkpoints. It is included in the core `aws-durable-execution-sdk-java` artifact under +the `software.amazon.lambda.durable.serde.filesystem` Java package. + +Configure it after a value codec: + +```java +var fileSystemStage = FileSystemSerDesStage.builder(Path.of("/mnt/efs/durable-payloads")) + .storageMode(FileSystemStorageMode.OVERFLOW) + .pathEncoding(FileSystemPathEncoding.HASH) + .checkpointEnvelopeLimitBytes(configuredCheckpointEnvelopeLimitBytes) + .build(); + +var resilientFileSystemStage = new RetrySerDesStage( + fileSystemStage, + RetryStrategies.fixedDelay(3, Duration.ofSeconds(1))); + +SerDes serDes = new JacksonSerDes() + .then(resilientFileSystemStage); + +return DurableConfig.builder() + .withSerDes(serDes) + .withSerDesExecutorService(Executors.newFixedThreadPool(4)) + .build(); +``` + +The storage modes are: + +- `ALWAYS`: store every non-null payload in a file. +- `OVERFLOW`: keep payloads inline until the checkpoint envelope approaches the configured size limit. + +The path encodings are: + +- `URI`: use readable escaped path segments. +- `HASH`: use fixed-length SHA-256 path segments. + +`checkpointEnvelopeLimitBytes(...)` controls the maximum UTF-8 size accepted for both inline and file envelopes. It +defaults to 255 KiB and can be increased when the durable execution service supports a larger payload limit. + +### Structured previews + +File envelopes can include a structured preview: + +```java +var previewConfig = PreviewConfig.builder(PreviewMode.EXCLUDE_ALL) + .include(PreviewField.anywhere("id"), PreviewField.path("customer.status")) + .exclude(PreviewField.anywhere("internal")) + .mask(PreviewField.anywhere("email")) + .maskString("***") + .maxPreviewBytes(4096) + .build(); + +var fileSystemStage = FileSystemSerDesStage.builder(Path.of("/mnt/efs/durable-payloads")) + .previewConfig(previewConfig) + .build(); +``` + +`INCLUDE_ALL` starts with every leaf visible and then applies exclude and mask rules. `EXCLUDE_ALL` starts with no +fields visible; include and mask rules make selected fields visible. `PreviewField.anywhere(...)` matches a field name +at any depth, while `PreviewField.path(...)` matches an exact dot-separated path. Exclude rules win over mask rules, +and masking implies visibility. + +The built-in `previewConfig(...)` parses the incoming stage string as JSON. Use `previewGenerator(...)` for non-JSON +stage values or custom logic. The generator receives the stage string and `SerDesContext`, including +`originalValue()` during serialization. Custom generators must avoid exposing sensitive fields. + +Previews are included only in file envelopes. The structured builder defaults to a 4 KiB preview budget, and the +complete envelope must remain below the configured checkpoint limit. + +### Replay and envelope behavior + +Filesystem envelopes contain a reserved version marker. The stage passes strings without this marker through +unchanged. A string containing the marker must be a valid, supported filesystem envelope; malformed marked envelopes +and unsupported versions fail rather than falling back to pass-through behavior. + +Payload files are content-hashed and immutable. Each serialization publishes a unique filename with a single +`CREATE_NEW` write. Existing files are never overwritten, and publication does not require hard links or renames. +Every inline and file envelope records the payload's SHA-256 digest. During deserialization, the stage verifies the +restored bytes against that envelope digest; file payloads must also have a content-addressed filename consistent with +the digest. The stage traverses directories with `SecureDirectoryStream`, disables symbolic-link following, and holds +the relative directory handles through each file read or write. This makes path validation and access one safe +operation even if another process changes names on the shared filesystem. Providers without +`SecureDirectoryStream` support are rejected. The stage also validates that ordinary checkpoint replay matches the +execution and entity that produced the reference. Invoke input and result boundaries can consume a file owned by the +other Lambda execution when both functions use the same shared root and path encoding. + +Stages may follow `FileSystemSerDesStage` to transform its inline or file-reference envelope. `OVERFLOW` and preview +size checks occur before those later stages, so account for any expansion when staying within the service checkpoint +limit. + +### Storage requirements + +Do not use Lambda's ephemeral `/tmp` directory. Durable replay may run in another execution environment where the file +does not exist. + +Use a durable shared mount such as EFS or S3 Files. The stage does not rely on hard links or renames, which S3 Files +does not support. The mounted Java filesystem provider must expose `SecureDirectoryStream`; ordinary Linux EFS and S3 +Files mounts use the default provider that supplies it. S3 Files can synchronize writes asynchronously, so a runtime +crash before a flush can lose recent data; use it only when that durability tradeoff is acceptable. + +The SDK does not delete payload files. Configure an appropriate retention or lifecycle policy for the backing +storage. diff --git a/docs/core/invoke.md b/docs/core/invoke.md index e79c99cf0..2d6922975 100644 --- a/docs/core/invoke.md +++ b/docs/core/invoke.md @@ -16,8 +16,14 @@ var result = ctx.invoke("invoke-function", InvokeConfig.builder() .payloadSerDes(...) // payload serializer .serDes(...) // result deserializer + .usePersistedSerDesForPayload(true) // compatible Java durable targets only .tenantId(...) // Lambda tenantId .build() ); ``` + +Invoke payloads use the caller's context-free input codec by default and are sent without SDK framing. This preserves +compatibility with standard Lambda functions, non-Java durable functions, and older Java SDK versions. Enable +`usePersistedSerDesForPayload(true)` only when the target is a compatible Java durable handler with the same persisted +SerDes pipeline—for example, when both handlers use `FileSystemSerDesStage` with a shared filesystem. diff --git a/docs/design.md b/docs/design.md index eaba54af0..ca34cf437 100644 --- a/docs/design.md +++ b/docs/design.md @@ -10,7 +10,7 @@ This document explains the internal architecture, threading model, and extension ``` aws-durable-execution-sdk-java/ -├── sdk/ # Core SDK - DurableHandler, DurableContext, operations +├── sdk/ # Core SDK - DurableHandler, DurableContext, operations, SerDes ├── sdk-testing/ # Test utilities for local and cloud testing ├── sdk-integration-tests/ # Integration tests using LocalDurableTestRunner └── examples/ # Real-world usage patterns as customers would implement them @@ -18,7 +18,7 @@ aws-durable-execution-sdk-java/ | Module | Purpose | Key Classes | |--------|---------|-------------| -| `sdk` | Core runtime - extend `DurableHandler`, use `DurableContext` for durable operations | `DurableHandler`, `DurableContext`, `DurableExecutor`, `ExecutionManager` | +| `sdk` | Core runtime - extend `DurableHandler`, use `DurableContext` for durable operations, and configure composable or filesystem-backed SerDes | `DurableHandler`, `DurableContext`, `DurableExecutor`, `ExecutionManager`, `FileSystemSerDesStage` | | `sdk-testing` | Test utilities: `LocalDurableTestRunner` (in-memory, simulates re-invocations and time-skipping) and `CloudDurableTestRunner` (executes against deployed Lambda) | `LocalDurableTestRunner`, `CloudDurableTestRunner`, `LocalMemoryExecutionClient`, `TestResult` | | `sdk-integration-tests` | Dogfooding tests - validates the SDK using its own test utilities. Separate module keeps dependencies acyclic: `sdk` → `sdk-testing` → `sdk-integration-tests`. | Test classes only | | `examples` | Real-world usage patterns as customers would implement them, with local and cloud tests | Example handlers, `CloudBasedIntegrationTest` | @@ -145,13 +145,14 @@ public class MyHandler extends DurableHandler { | `lambdaClientBuilder` | Auto-created `LambdaClient` for current region, primed for performance (see [`DurableConfig.java`](../sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java)) | | `serDes` | `JacksonSerDes` | | `executorService` | `Executors.newCachedThreadPool()` (for user-defined operations only) | +| `serDesExecutorService` | `null`; SerDes executes inline unless a dedicated executor is configured | | `loggerConfig` | `LoggerConfig.defaults()` (suppress replay logs) | | `pollingStrategy` | Exponential backoff: 1s base, 2x rate, FULL jitter, 10s max | | `checkpointDelay` | `Duration.ofSeconds(0)` (checkpoint as soon as possible) | ### Thread Pool Architecture -The SDK uses two separate thread pools with distinct responsibilities: +The SDK uses two always-present executors and supports one optional executor with distinct responsibilities: **User Executor (`DurableConfig.executorService`):** - Runs user-defined operations (the code passed to `ctx.step()` and `ctx.stepAsync()`) @@ -163,11 +164,17 @@ The SDK uses two separate thread pools with distinct responsibilities: - Dedicated cached thread pool with daemon threads named `durable-sdk-internal-*` - Not configurable by users +**Optional SerDes Executor (`DurableConfig.serDesExecutorService`):** +- Runs the complete configured SerDes pipeline, including blocking payload storage and retry backoff +- Configurable via `DurableConfig.builder().withSerDesExecutorService()` +- Default: absent; SerDes executes inline on the calling thread +- Must not be the same instance as the user executor + **Benefits of this separation:** | Benefit | Description | |---------|-------------| -| **Isolation** | User operations can't starve SDK internals, and vice versa | +| **Isolation** | User operations can't starve SDK internals, and blocking SerDes work can be isolated explicitly | | **No shutdown management** | Internal pool uses daemon threads; SDK coordination continues even if the user's executor is shut down | | **Efficient resource usage** | Cached thread pool creates threads on demand and reuses idle threads (60s timeout) | | **Daemon threads** | Internal threads won't prevent JVM shutdown | @@ -347,9 +354,30 @@ software.amazon.lambda.durable │ └── WaitForConditionResult # Check function return type (value + isDone) │ ├── serde/ -│ ├── SerDes # Interface +│ ├── SerDes # Interface and pipeline composition entry point +│ ├── SerDesStage # Reversible string-to-string pipeline stage +│ ├── ComposableSerDes # Immutable ordered value-codec/string-stage pipeline +│ ├── BinarySerDesStage # Reversible byte-array transformation +│ ├── StringBinaryCodec # Customizable string/byte boundary conversion +│ ├── Utf8StringBinaryCodec # UTF-8 string/byte conversion +│ ├── Base64StringBinaryCodec # Standard Base64 string/byte conversion +│ ├── ComposableBinarySerDesStage # Ordered binary chain exposed as one string stage │ ├── JacksonSerDes # Jackson impl -│ └── AwsSdkV2Module # SDK type support +│ ├── RetrySerDesStage # Retrying string-stage decorator +│ ├── RetryBinarySerDesStage # Retrying binary-stage decorator +│ ├── SerDesRunner # Stage context, optional executor dispatch, and invocation cache +│ ├── SerDesContext # Read-only durable payload identity and serialization source value +│ ├── SerDesPayloadKind # Input/result/state/exception/invoke payload kind +│ ├── AwsSdkV2Module # SDK type support +│ └── filesystem/ +│ ├── FileSystemSerDesStage # Filesystem-backed string stage +│ ├── FileSystemStorageMode # Always or overflow-only storage +│ ├── FileSystemPathEncoding # URI or hashed path encoding +│ ├── SerDesPreview # Structured preview builder +│ ├── PreviewConfig # Preview selection, masking, and size configuration +│ ├── PreviewField # Field-name or exact-path preview selector +│ ├── PreviewMode # Include-all or exclude-all preview default +│ └── FieldMatchMode # Anywhere or exact-path field matching │ └── exception/ ├── DurableExecutionException @@ -372,7 +400,8 @@ software.amazon.lambda.durable ├── ChildContextFailedException ├── MapIterationFailedException ├── ParallelBranchFailedException - └── SerDesException + ├── SerDesException + └── RetryableSerDesException ``` --- @@ -463,6 +492,7 @@ sequenceDiagram ``` DurableExecutionException (base) ├── SerDesException # Serialization error +│ └── RetryableSerDesException # Transient SerDes error ├── UnrecoverableDurableExecutionException # Execution cannot be recovered │ ├── NonDeterministicExecutionException # Replay mismatch │ └── IllegalDurableOperationException # Illegal operation detected @@ -503,6 +533,7 @@ SuspendExecutionException # Internal: triggers suspension (not | `NonDeterministicExecutionException` | Replay finds different operation than expected | Bug in handler (non-deterministic code) | | `IllegalDurableOperationException` | Illegal operation detected | Bug in handler | | `SerDesException` | Jackson fails to serialize/deserialize | Fix data model or custom SerDes | +| `RetryableSerDesException` | Transient stage or payload storage failure | Wrap the failing string or binary stage with `RetrySerDesStage` or `RetryBinarySerDesStage` and a bounded retry strategy | --- diff --git a/examples/README.md b/examples/README.md index c17750835..ab4770876 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 | diff --git a/examples/generate-template.py b/examples/generate-template.py index 2ffcd5d70..9cb380568 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/durable-payloads", + ] + ) 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..23c11a466 --- /dev/null +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/general/FileSystemSerDesExample.java @@ -0,0 +1,81 @@ +// 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 java.util.LinkedHashMap; +import java.util.Map; +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.serde.JacksonSerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.filesystem.FileSystemSerDesStage; + +/** E2E fixture that offloads durable payloads to an EFS mount and reads them after reinvocation. */ +@ExampleTemplate(fileSystem = true) +public class FileSystemSerDesExample + extends DurableHandler { + private static final String FILE_SYSTEM_PATH_ENV = "FILESYSTEM_SERDES_PATH"; + + @Override + protected DurableConfig createConfiguration() { + var 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 fileSystemStage = FileSystemSerDesStage.builder(Path.of(path)) + .previewGenerator(FileSystemSerDesExample::preview) + .build(); + return DurableConfig.builder() + .withSerDes(new JacksonSerDes().then(fileSystemStage)) + .build(); + } + + @Override + public Output handleRequest(Input input, DurableContext context) { + var stored = + context.step("store-payload", Payload.class, stepContext -> new Payload(input.id(), input.value())); + context.wait("force-filesystem-replay", Duration.ofSeconds(1)); + return context.step( + "verify-payload", + Output.class, + stepContext -> new Output(stored.id(), stored.value().length(), sha256(stored.value()))); + } + + private static Map preview(String value, SerDesContext context) { + var preview = new LinkedHashMap(); + preview.put("payloadKind", context.payloadKind().name()); + preview.put("operationName", context.operationName()); + if (context.originalValue() instanceof Payload payload) { + preview.put("id", payload.id()); + preview.put("length", payload.value().length()); + } else if (context.originalValue() instanceof Output output) { + preview.put("id", output.id()); + preview.put("length", output.length()); + preview.put("checksum", output.checksum()); + } + return preview; + } + + 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 value) {} + + public record Payload(String id, String value) {} + + 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 31d5af673..05a96d1db 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; @@ -363,6 +374,44 @@ void testCustomConfigExample() { assertTrue(stepResult.contains("email_address")); } + @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", 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, "RESULT", "store-payload", input.id(), value.length(), null); + + var verifiedEnvelope = assertFileSystemEnvelope( + result.getOperation("verify-payload").getStepDetails().result()); + assertPreview(verifiedEnvelope, "RESULT", "verify-payload", input.id(), value.length(), expectedChecksum); + + 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, "OUTPUT", null, input.id(), value.length(), expectedChecksum); + } + @Test void testErrorHandlingExample() { var runner = @@ -853,4 +902,36 @@ void testPluginExample() { assertNotNull(runner.getOperation("create-greeting")); assertNotNull(runner.getOperation("transform")); } + + private static JsonNode assertFileSystemEnvelope(String value) throws Exception { + var envelope = MAPPER.readTree(value); + assertEquals(1, envelope.get(FILE_SYSTEM_ENVELOPE_MARKER).intValue()); + assertEquals("STRING", envelope.get("payloadType").textValue()); + assertTrue(envelope.get("payloadDigest").textValue().matches("[0-9a-f]{64}")); + assertTrue(envelope.get("file").textValue().startsWith("/mnt/efs/durable-payloads/")); + return envelope; + } + + private static void assertPreview( + JsonNode envelope, String payloadKind, String operationName, String id, int length, String checksum) { + var preview = envelope.get("preview"); + assertEquals(payloadKind, preview.get("payloadKind").textValue()); + if (operationName != null) { + assertEquals(operationName, preview.get("operationName").textValue()); + } + assertEquals(id, preview.get("id").textValue()); + assertEquals(length, preview.get("length").intValue()); + if (checksum != null) { + assertEquals(checksum, preview.get("checksum").textValue()); + } + } + + 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); + } + } } diff --git a/examples/test_generate_template.py b/examples/test_generate_template.py new file mode 100644 index 000000000..9223fec57 --- /dev/null +++ b/examples/test_generate_template.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path + + +GENERATOR_PATH = Path(__file__).with_name("generate-template.py") +SPEC = importlib.util.spec_from_file_location("generate_template", GENERATOR_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Unable to load {GENERATOR_PATH}") +generate_template = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = generate_template +SPEC.loader.exec_module(generate_template) + + +class GenerateTemplateTest(unittest.TestCase): + def test_default_template_does_not_include_file_system_infrastructure(self) -> None: + examples = [example for example in generate_template.discover_examples() if not example.file_system] + + template = generate_template.render_template(examples) + + self.assertNotIn("FileSystemInfrastructureStackName", template) + self.assertNotIn("AWS::EFS::FileSystem", template) + + def test_file_system_lambda_template_imports_persistent_infrastructure(self) -> None: + examples = [example for example in generate_template.discover_examples() if example.file_system] + + template = generate_template.render_template(examples) + + self.assertIn("FileSystemInfrastructureStackName:", template) + self.assertIn("${FileSystemInfrastructureStackName}-SubnetId", template) + self.assertIn("${FileSystemInfrastructureStackName}-LambdaSecurityGroupId", template) + self.assertIn("${FileSystemInfrastructureStackName}-AccessPointArn", template) + self.assertNotIn("AWS::EFS::FileSystem", template) + self.assertNotIn("FileSystemMountTarget", template) + + def test_file_system_infrastructure_template_exports_shared_resources(self) -> None: + template = generate_template.render_file_system_infrastructure_template() + + self.assertIn("AWS::EFS::FileSystem", template) + self.assertIn("AWS::EFS::MountTarget", template) + self.assertIn("${AWS::StackName}-SubnetId", template) + self.assertIn("${AWS::StackName}-LambdaSecurityGroupId", template) + self.assertIn("${AWS::StackName}-AccessPointArn", template) + self.assertNotIn("AWS::Serverless::Function", template) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/FileSystemSerDesStageIntegrationTest.java b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/FileSystemSerDesStageIntegrationTest.java new file mode 100644 index 000000000..1c4049581 --- /dev/null +++ b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/FileSystemSerDesStageIntegrationTest.java @@ -0,0 +1,594 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.awssdk.services.lambda.model.CheckpointUpdatedExecutionState; +import software.amazon.awssdk.services.lambda.model.ErrorObject; +import software.amazon.awssdk.services.lambda.model.ExecutionDetails; +import software.amazon.awssdk.services.lambda.model.Operation; +import software.amazon.awssdk.services.lambda.model.OperationAction; +import software.amazon.awssdk.services.lambda.model.OperationStatus; +import software.amazon.awssdk.services.lambda.model.OperationType; +import software.amazon.lambda.durable.config.InvokeConfig; +import software.amazon.lambda.durable.config.StepConfig; +import software.amazon.lambda.durable.config.WaitForConditionConfig; +import software.amazon.lambda.durable.execution.DurableExecutor; +import software.amazon.lambda.durable.model.DurableExecutionInput; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.model.WaitForConditionResult; +import software.amazon.lambda.durable.retry.JitterStrategy; +import software.amazon.lambda.durable.retry.RetryStrategies; +import software.amazon.lambda.durable.retry.WaitStrategies; +import software.amazon.lambda.durable.serde.Base64StringBinaryCodec; +import software.amazon.lambda.durable.serde.ComposableBinarySerDesStage; +import software.amazon.lambda.durable.serde.JacksonSerDes; +import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.SerDesRunner; +import software.amazon.lambda.durable.serde.SerDesStage; +import software.amazon.lambda.durable.serde.Utf8StringBinaryCodec; +import software.amazon.lambda.durable.serde.filesystem.FileSystemSerDesStage; +import software.amazon.lambda.durable.serde.internal.ChainedInvokePayloadFrame; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; +import software.amazon.lambda.durable.testing.TestResult; +import software.amazon.lambda.durable.testing.local.LocalMemoryExecutionClient; +import software.amazon.lambda.durable.testing.local.OperationResult; + +class FileSystemSerDesStageIntegrationTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TempDir + Path basePath; + + @Test + void pipelineReplaysStepWaitChildAndMapPayloadsFromFilesystem() throws Exception { + var stepExecutions = new AtomicInteger(); + var pollExecutions = new AtomicInteger(); + var serDes = filesystemPipeline(); + var config = DurableConfig.builder().withSerDes(serDes).build(); + + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> { + var stepResult = context.step("load-order", String.class, stepContext -> { + stepExecutions.incrementAndGet(); + return input + "-loaded"; + }); + var waitConfig = WaitForConditionConfig.builder() + .waitStrategy(WaitStrategies.exponentialBackoff( + 5, Duration.ofSeconds(1), Duration.ofSeconds(10), 1, JitterStrategy.NONE)) + .build(); + var pollResult = context.waitForCondition( + "poll-order", + Integer.class, + (state, stepContext) -> { + pollExecutions.incrementAndGet(); + var next = state == null ? 1 : state + 1; + return next == 2 + ? WaitForConditionResult.stopPolling(next) + : WaitForConditionResult.continuePolling(next); + }, + waitConfig); + var childResult = context.runInChildContext( + "format-order", String.class, child -> stepResult + "-child"); + var mapResult = context.map( + "map-order", List.of(1, 2), Integer.class, (item, index, child) -> item * 2); + return childResult + "-" + pollResult + "-" + mapResult.results(); + }, + config) + .withOutputType(String.class); + + var result = runner.runUntilComplete("order"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("order-loaded-child-2-[2, 4]", result.getResult()); + assertEquals(1, stepExecutions.get()); + assertEquals(2, pollExecutions.get()); + assertEquals("order-loaded", result.getOperation("load-order").getStepResult(String.class)); + assertEnvelopePointsToFile( + result.getOperation("load-order").getStepDetails().result()); + assertEnvelopePointsToFile( + result.getOperation("map-order").getContextDetails().result()); + } + + @Test + void durableExecutorAcceptsRawServiceInputBeforeFilesystemEnvelopeExists() { + var executionArn = + "arn:aws:lambda:us-east-1:123456789012:function:test:$LATEST/durable-execution/execution/raw-input"; + var invocationId = "raw-input"; + var executionName = "execution"; + var executionOperation = Operation.builder() + .id(invocationId) + .name(executionName) + .type(OperationType.EXECUTION) + .status(OperationStatus.STARTED) + .startTimestamp(Instant.now()) + .executionDetails(ExecutionDetails.builder() + .inputPayload("\"service-input\"") + .build()) + .build(); + var input = new DurableExecutionInput( + executionArn, + "checkpoint-token", + CheckpointUpdatedExecutionState.builder() + .operations(executionOperation) + .build(), + List.of()); + var client = new LocalMemoryExecutionClient(); + var serDes = filesystemPipeline(); + var config = DurableConfig.builder() + .withDurableExecutionClient(client) + .withSerDes(serDes) + .build(); + + var output = DurableExecutor.execute( + input, null, TypeToken.get(String.class), (value, context) -> value + "-output", config); + + assertEquals(ExecutionStatus.SUCCEEDED, output.status()); + var result = new SerDesRunner(null) + .deserialize( + serDes, + output.result(), + TypeToken.get(String.class), + SerDesContext.forExecution( + executionArn, invocationId, executionName, SerDesPayloadKind.OUTPUT)); + assertEquals("service-input-output", result); + } + + @Test + void acceptsRawCallbackAndInvokeResultsAndOffloadsInvokePayload() throws Exception { + var invokePayload = new AtomicReference(); + var recordingStage = identityStage((action, value, context) -> { + if ("serialize".equals(action) && context.payloadKind() == SerDesPayloadKind.INVOKE_PAYLOAD) { + invokePayload.set(value); + } + }); + var serDes = new JacksonSerDes() + .then(recordingStage) + .then(FileSystemSerDesStage.builder(basePath).build()); + var config = DurableConfig.builder().withSerDes(serDes).build(); + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> { + var callback = context.createCallback("approval", String.class); + var approval = callback.get(); + return context.invoke( + "notify", + "target-function", + Map.of("approval", approval), + String.class, + InvokeConfig.builder() + .usePersistedSerDesForPayload(true) + .build()); + }, + config) + .withOutputType(String.class); + + var waitingForCallback = runner.run("input"); + assertEquals(ExecutionStatus.PENDING, waitingForCallback.getStatus()); + var callbackId = runner.getCallbackId("approval"); + assertNotNull(callbackId); + + runner.completeCallback(callbackId, "\"approved\""); + var waitingForInvoke = runner.run("input"); + assertEquals(ExecutionStatus.PENDING, waitingForInvoke.getStatus()); + assertEquals( + "approved", MAPPER.readTree(invokePayload.get()).get("approval").textValue()); + + runner.completeChainedInvoke("notify", "\"notified\""); + var completed = runner.run("input"); + + assertEquals(ExecutionStatus.SUCCEEDED, completed.getStatus()); + assertEquals("notified", completed.getResult()); + } + + @Test + void callerAndCalleeExchangeOffloadedInvokePayloadAndResult() throws Exception { + var callerArn = + "arn:aws:lambda:us-east-1:123456789012:function:caller:1/durable-execution/caller-execution/caller-invocation"; + var calleeArn = + "arn:aws:lambda:us-east-1:123456789012:function:callee:1/durable-execution/callee-execution/callee-invocation"; + var serDes = filesystemPipeline(); + var callerClient = new LocalMemoryExecutionClient(); + var callerConfig = DurableConfig.builder() + .withDurableExecutionClient(callerClient) + .withSerDes(serDes) + .build(); + BiFunction callerHandler = (input, context) -> context.invoke( + "call-callee", + "callee", + new CrossInvokeRequest(input), + CrossInvokeResponse.class, + InvokeConfig.builder().usePersistedSerDesForPayload(true).build()); + var callerExecution = + executionOperation("caller-invocation", "caller-execution", "\"request\"", OperationStatus.STARTED); + + var pending = DurableExecutor.execute( + durableInput(callerArn, callerExecution, List.of(), List.of()), + null, + TypeToken.get(String.class), + callerHandler, + callerConfig); + + assertEquals(ExecutionStatus.PENDING, pending.status()); + var invokePayload = callerClient.getOperationUpdates().stream() + .filter(update -> + update.type() == OperationType.CHAINED_INVOKE && update.action() == OperationAction.START) + .findFirst() + .orElseThrow() + .payload(); + assertEnvelopePointsToFile(ChainedInvokePayloadFrame.decode(invokePayload)); + + var calleeClient = new LocalMemoryExecutionClient(); + var calleeConfig = DurableConfig.builder() + .withDurableExecutionClient(calleeClient) + .withSerDes(serDes) + .build(); + var calleeExecution = + executionOperation("callee-invocation", "callee-execution", invokePayload, OperationStatus.STARTED); + var calleeOutput = DurableExecutor.execute( + durableInput(calleeArn, calleeExecution, List.of(), List.of()), + null, + TypeToken.get(CrossInvokeRequest.class), + (request, context) -> new CrossInvokeResponse("reply:" + request.value()), + calleeConfig); + + assertEquals(ExecutionStatus.SUCCEEDED, calleeOutput.status()); + assertEnvelopePointsToFile(calleeOutput.result()); + + callerClient.completeChainedInvoke("call-callee", OperationResult.succeeded(calleeOutput.result())); + var resumed = DurableExecutor.execute( + durableInput( + callerArn, + callerExecution, + callerClient.getAllOperations(), + callerClient.getUpdatedOperationIdsSinceLastInvocation()), + null, + TypeToken.get(String.class), + callerHandler, + callerConfig); + + assertEquals(ExecutionStatus.SUCCEEDED, resumed.status()); + var result = new SerDesRunner(null) + .deserialize( + serDes, + resumed.result(), + TypeToken.get(CrossInvokeResponse.class), + SerDesContext.forExecution( + callerArn, "caller-invocation", "caller-execution", SerDesPayloadKind.OUTPUT)); + assertEquals(new CrossInvokeResponse("reply:request"), result); + } + + @Test + void defaultInvokePayloadPreservesStandardAndLegacyJavaWireContracts() { + var callerArn = + "arn:aws:lambda:us-east-1:123456789012:function:caller:1/durable-execution/caller-execution/caller-invocation"; + var callerClient = new LocalMemoryExecutionClient(); + var callerConfig = DurableConfig.builder() + .withDurableExecutionClient(callerClient) + .withSerDes(filesystemPipeline()) + .build(); + BiFunction callerHandler = (input, context) -> + context.invoke("call-standard", "standard", new CrossInvokeRequest(input), String.class); + var callerExecution = + executionOperation("caller-invocation", "caller-execution", "\"request\"", OperationStatus.STARTED); + + var pending = DurableExecutor.execute( + durableInput(callerArn, callerExecution, List.of(), List.of()), + null, + TypeToken.get(String.class), + callerHandler, + callerConfig); + + assertEquals(ExecutionStatus.PENDING, pending.status()); + var invokePayload = callerClient.getOperationUpdates().stream() + .filter(update -> + update.type() == OperationType.CHAINED_INVOKE && update.action() == OperationAction.START) + .findFirst() + .orElseThrow() + .payload(); + assertEquals("{\"value\":\"request\"}", invokePayload); + assertEquals( + new CrossInvokeRequest("request"), + new JacksonSerDes().deserialize(invokePayload, TypeToken.get(CrossInvokeRequest.class))); + } + + @Test + void repeatedGetUsesInvocationCacheForTheCompletePipeline() { + var resultDeserializations = new AtomicInteger(); + var countingStage = identityStage((action, value, context) -> { + if ("deserialize".equals(action) + && context.payloadKind() == SerDesPayloadKind.RESULT + && "cached-step".equals(context.operationName())) { + resultDeserializations.incrementAndGet(); + } + }); + var serDes = new JacksonSerDes() + .then(countingStage) + .then(FileSystemSerDesStage.builder(basePath).build()); + var config = DurableConfig.builder().withSerDes(serDes).build(); + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> { + var future = + context.stepAsync("cached-step", Payload.class, stepContext -> new Payload(input)); + var first = future.get(); + var second = future.get(); + assertSame(first, second); + return first.value(); + }, + config) + .withOutputType(String.class); + + var result = runner.runUntilComplete("cached"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("cached", result.getResult()); + assertEquals(1, resultDeserializations.get()); + } + + @Test + void successfulRetryUsesTheProducingAttemptForResultSerialization() { + var executions = new AtomicInteger(); + var resultAttempts = new ArrayList(); + var attemptStage = identityStage((action, value, context) -> { + if ("serialize".equals(action) + && context.payloadKind() == SerDesPayloadKind.RESULT + && "retry-step".equals(context.operationName())) { + resultAttempts.add(context.attempt()); + } + }); + var serDes = new JacksonSerDes() + .then(attemptStage) + .then(FileSystemSerDesStage.builder(basePath).build()); + var config = DurableConfig.builder().withSerDes(serDes).build(); + var stepConfig = StepConfig.builder() + .retryStrategy(RetryStrategies.fixedDelay(2, Duration.ofSeconds(1))) + .build(); + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> context.step( + "retry-step", + String.class, + stepContext -> { + if (executions.incrementAndGet() == 1) { + throw new IllegalStateException("retry"); + } + return input + "-attempt-" + stepContext.getAttempt(); + }, + stepConfig), + config) + .withOutputType(String.class); + + var result = runner.runUntilComplete("value"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("value-attempt-2", result.getResult()); + assertEquals(List.of(2), resultAttempts); + } + + @Test + void customExceptionPayloadsRoundTripThroughFilesystem() throws Exception { + var serDes = filesystemPipeline(); + var config = DurableConfig.builder().withSerDes(serDes).build(); + var stepConfig = StepConfig.builder() + .retryStrategy(RetryStrategies.Presets.NO_RETRY) + .build(); + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> context.step( + "fail-step", + String.class, + stepContext -> { + throw new CustomFailure("boom"); + }, + stepConfig), + config) + .withOutputType(String.class); + + var result = runner.runUntilComplete("input"); + + assertEquals(ExecutionStatus.FAILED, result.getStatus()); + assertEquals( + CustomFailure.class.getName(), result.getError().orElseThrow().errorType()); + var operationError = result.getOperation("fail-step").getError(); + assertEquals(CustomFailure.class.getName(), operationError.errorType()); + assertEnvelopePointsToFile(operationError.errorData()); + } + + @Test + void nestedInvokeFailurePreservesProducerContextAcrossReplay() throws Exception { + var childExecutions = new AtomicInteger(); + var serDes = filesystemPipeline(); + var config = DurableConfig.builder().withSerDes(serDes).build(); + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> { + try { + return context.runInChildContext("invoke-child", String.class, child -> { + childExecutions.incrementAndGet(); + return child.invoke("nested-invoke", "callee", input, String.class); + }); + } catch (CustomFailure failure) { + return "caught:" + failure.getMessage(); + } + }, + config) + .withOutputType(String.class); + + assertEquals(ExecutionStatus.PENDING, runner.run("input").getStatus()); + var calleeArn = + "arn:aws:lambda:us-east-1:123456789012:function:callee:1/durable-execution/callee-execution/callee-invocation"; + var errorData = new SerDesRunner(null) + .serialize( + serDes, + new CustomFailure("invoke-boom"), + SerDesContext.forExecution( + calleeArn, "callee-invocation", "callee-execution", SerDesPayloadKind.EXCEPTION)); + runner.failChainedInvoke( + "nested-invoke", + ErrorObject.builder() + .errorType(CustomFailure.class.getName()) + .errorMessage("invoke-boom") + .errorData(errorData) + .build()); + + var completed = runner.runUntilComplete("input"); + assertEquals(ExecutionStatus.SUCCEEDED, completed.getStatus()); + assertEquals("caught:invoke-boom", completed.getResult()); + assertForwardedErrorOwnedByChild(completed, "invoke-child"); + var executionsAfterCompletion = childExecutions.get(); + + var replay = runner.run("input"); + assertEquals(ExecutionStatus.SUCCEEDED, replay.getStatus()); + assertEquals("caught:invoke-boom", replay.getResult()); + assertEquals(executionsAfterCompletion, childExecutions.get()); + } + + @Test + void nestedCallbackFailurePreservesProducerContextAcrossReplay() throws Exception { + var childExecutions = new AtomicInteger(); + var serDes = filesystemPipeline(); + var config = DurableConfig.builder().withSerDes(serDes).build(); + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> { + try { + return context.runInChildContext("callback-child", String.class, child -> { + childExecutions.incrementAndGet(); + return child.createCallback("nested-callback", String.class) + .get(); + }); + } catch (CustomFailure failure) { + return "caught:" + failure.getMessage(); + } + }, + config) + .withOutputType(String.class); + + assertEquals(ExecutionStatus.PENDING, runner.run("input").getStatus()); + runner.failCallback( + runner.getCallbackId("nested-callback"), + ErrorObject.builder() + .errorType(CustomFailure.class.getName()) + .errorMessage("callback-boom") + .errorData(new JacksonSerDes().serialize(new CustomFailure("callback-boom"))) + .build()); + + var completed = runner.runUntilComplete("input"); + assertEquals(ExecutionStatus.SUCCEEDED, completed.getStatus()); + assertEquals("caught:callback-boom", completed.getResult()); + assertForwardedErrorOwnedByChild(completed, "callback-child"); + var executionsAfterCompletion = childExecutions.get(); + + var replay = runner.run("input"); + assertEquals(ExecutionStatus.SUCCEEDED, replay.getStatus()); + assertEquals("caught:callback-boom", replay.getResult()); + assertEquals(executionsAfterCompletion, childExecutions.get()); + } + + private SerDes filesystemPipeline() { + var binaryStage = ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + return new JacksonSerDes() + .then(binaryStage) + .then(FileSystemSerDesStage.builder(basePath).build()); + } + + private static DurableExecutionInput durableInput( + String executionArn, Operation executionOperation, List operations, List updatedIds) { + var allOperations = new ArrayList(); + allOperations.add(executionOperation); + allOperations.addAll(operations); + return new DurableExecutionInput( + executionArn, + "checkpoint-token", + CheckpointUpdatedExecutionState.builder() + .operations(allOperations) + .build(), + updatedIds); + } + + private static Operation executionOperation(String id, String name, String inputPayload, OperationStatus status) { + return Operation.builder() + .id(id) + .name(name) + .type(OperationType.EXECUTION) + .status(status) + .startTimestamp(Instant.now()) + .executionDetails( + ExecutionDetails.builder().inputPayload(inputPayload).build()) + .build(); + } + + private static SerDesStage identityStage(RecordingFunction recorder) { + return new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + recorder.record("serialize", value, context); + return value; + } + + @Override + public String deserialize(String data, SerDesContext context) { + recorder.record("deserialize", data, context); + return data; + } + }; + } + + private void assertEnvelopePointsToFile(String envelope) throws Exception { + var file = Path.of(MAPPER.readTree(envelope).get("file").textValue()); + assertTrue(Files.exists(file)); + assertTrue(file.startsWith(basePath)); + } + + private void assertForwardedErrorOwnedByChild(TestResult result, String childName) throws Exception { + var child = result.getOperation(childName); + var errorData = child.getContextDetails().error().errorData(); + assertEnvelopePointsToFile(errorData); + assertEquals( + "operation/" + child.getId() + "/exception", + MAPPER.readTree(errorData).get("ownerEntityId").textValue()); + } + + @FunctionalInterface + private interface RecordingFunction { + void record(String action, String value, SerDesContext context); + } + + record Payload(String value) {} + + record CrossInvokeRequest(String value) {} + + record CrossInvokeResponse(String value) {} + + public static class CustomFailure extends RuntimeException { + public CustomFailure() {} + + public CustomFailure(String message) { + super(message); + } + } +} diff --git a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/AsyncExecution.java b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/AsyncExecution.java index 57b6c6921..50ac079e4 100644 --- a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/AsyncExecution.java +++ b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/AsyncExecution.java @@ -16,6 +16,7 @@ import software.amazon.lambda.durable.TypeToken; import software.amazon.lambda.durable.model.ExecutionStatus; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesRunner; import software.amazon.lambda.durable.testing.cloud.HistoryEventProcessor; /** @@ -195,7 +196,9 @@ private void refreshHistory() { .build(); var response = lambdaClient.getDurableExecutionHistory(request); this.currentHistory = response.events(); - this.currentResult = processor.processEvents(currentHistory, outputType, serDes); + var snapshotSerDesRunner = new SerDesRunner(null); + this.currentResult = + processor.processEvents(currentHistory, outputType, serDes, snapshotSerDesRunner, executionArn); } catch (ResourceNotFoundException e) { // Execution doesn't exist yet - this can happen immediately after async invoke // Leave currentHistory as null, pollUntil will retry diff --git a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/CloudDurableTestRunner.java b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/CloudDurableTestRunner.java index b06b0dfc4..ad280a800 100644 --- a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/CloudDurableTestRunner.java +++ b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/CloudDurableTestRunner.java @@ -10,8 +10,10 @@ import software.amazon.awssdk.services.lambda.model.InvocationType; import software.amazon.awssdk.services.lambda.model.InvokeRequest; import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.serde.ComposableSerDes; import software.amazon.lambda.durable.serde.JacksonSerDes; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesRunner; import software.amazon.lambda.durable.testing.cloud.HistoryEventProcessor; import software.amazon.lambda.durable.testing.cloud.HistoryPoller; @@ -30,6 +32,8 @@ public class CloudDurableTestRunner { private final Duration pollInterval; private final Duration timeout; private final InvocationType invocationType; + private final SerDes inputSerDes; + private final SerDes inputSerDesOverride; private final SerDes serDes; // Store last execution result for operation inspection private TestResult lastResult; @@ -42,6 +46,7 @@ private CloudDurableTestRunner( Duration pollInterval, Duration timeout, InvocationType invocationType, + SerDes inputSerDes, SerDes serDes) { this.functionArn = functionArn; this.inputType = inputType; @@ -52,6 +57,8 @@ private CloudDurableTestRunner( this.timeout = timeout; this.invocationType = invocationType; this.serDes = Objects.requireNonNullElseGet(serDes, JacksonSerDes::new); + this.inputSerDesOverride = inputSerDes; + this.inputSerDes = inputValueCodec(inputSerDes, this.serDes); } private static LambdaClient createDefaultLambdaClient() { @@ -77,6 +84,7 @@ public static CloudDurableTestRunner create( Duration.ofSeconds(2), Duration.ofSeconds(300), InvocationType.REQUEST_RESPONSE, + null, null); } @@ -97,36 +105,99 @@ public static CloudDurableTestRunner create( Duration.ofSeconds(2), Duration.ofSeconds(300), InvocationType.REQUEST_RESPONSE, + null, null); } /** Returns a new runner with the specified lambda client. */ public CloudDurableTestRunner withLambdaClient(LambdaClient lambdaClient) { return new CloudDurableTestRunner<>( - functionArn, inputType, outputType, lambdaClient, pollInterval, timeout, invocationType, serDes); + functionArn, + inputType, + outputType, + lambdaClient, + pollInterval, + timeout, + invocationType, + inputSerDesOverride, + serDes); } /** Returns a new runner with the specified poll interval between history checks. */ public CloudDurableTestRunner withPollInterval(Duration interval) { return new CloudDurableTestRunner<>( - functionArn, inputType, outputType, lambdaClient, interval, timeout, invocationType, serDes); + functionArn, + inputType, + outputType, + lambdaClient, + interval, + timeout, + invocationType, + inputSerDesOverride, + serDes); } /** Returns a new runner with the specified maximum wait time for execution completion. */ public CloudDurableTestRunner withTimeout(Duration timeout) { return new CloudDurableTestRunner<>( - functionArn, inputType, outputType, lambdaClient, pollInterval, timeout, invocationType, serDes); + functionArn, + inputType, + outputType, + lambdaClient, + pollInterval, + timeout, + invocationType, + inputSerDesOverride, + serDes); } /** Returns a new runner with the specified Lambda invocation type. */ public CloudDurableTestRunner withInvocationType(InvocationType type) { return new CloudDurableTestRunner<>( - functionArn, inputType, outputType, lambdaClient, pollInterval, timeout, type, serDes); + functionArn, + inputType, + outputType, + lambdaClient, + pollInterval, + timeout, + type, + inputSerDesOverride, + serDes); } + /** Returns a new runner with the specified SerDes for persisted execution payloads. */ public CloudDurableTestRunner withSerDes(SerDes serDes) { return new CloudDurableTestRunner<>( - functionArn, inputType, outputType, lambdaClient, pollInterval, timeout, invocationType, serDes); + functionArn, + inputType, + outputType, + lambdaClient, + pollInterval, + timeout, + invocationType, + inputSerDesOverride, + serDes); + } + + /** + * Returns a new runner with a separate value codec for the initial Lambda invocation payload. + * + *

The input codec is independent of the SerDes used for persisted execution payloads and must not be a + * {@link ComposableSerDes}. By default, the configured persisted SerDes is used when it is a value codec; for a + * composable pipeline, its root value codec is used. The deployed handler must configure the same codec with + * {@link software.amazon.lambda.durable.DurableConfig.Builder#withInputSerDes(SerDes)}. + */ + public CloudDurableTestRunner withInputSerDes(SerDes inputSerDes) { + return new CloudDurableTestRunner<>( + functionArn, + inputType, + outputType, + lambdaClient, + pollInterval, + timeout, + invocationType, + Objects.requireNonNull(inputSerDes, "inputSerDes cannot be null"), + serDes); } /** Invokes the Lambda function, polls execution history until completion, and returns the result. */ @@ -138,7 +209,7 @@ public TestResult runUntilComplete(I input) { public TestResult run(I input) { try { // Serialize input - var inputJson = serDes.serialize(input); + var inputJson = serializeInput(input); // Invoke function var invokeRequest = InvokeRequest.builder() @@ -161,7 +232,7 @@ public TestResult run(I input) { // Process events into TestResult var processor = new HistoryEventProcessor(); - var result = processor.processEvents(events, outputType, serDes); + var result = processor.processEvents(events, outputType, serDes, new SerDesRunner(null), executionArn); this.lastResult = result; return result; } catch (Exception e) { @@ -179,7 +250,7 @@ public TestResult run(I input) { public AsyncExecution startAsync(I input) { try { // Serialize input - var inputJson = serDes.serialize(input); + var inputJson = serializeInput(input); // Invoke function with EVENT type (async) var invokeRequest = InvokeRequest.builder() @@ -216,4 +287,21 @@ public TestOperation getOperation(String name) { } return lastResult.getOperation(name); } + + private String serializeInput(I input) { + return inputSerDes.serialize(input); + } + + private static SerDes inputValueCodec(SerDes inputSerDes, SerDes persistedSerDes) { + var codec = inputSerDes; + if (codec == null) { + codec = persistedSerDes instanceof ComposableSerDes composable + ? composable.getValueCodec() + : persistedSerDes; + } + if (codec instanceof ComposableSerDes) { + throw new IllegalArgumentException("inputSerDes must be a value codec, not a composable pipeline"); + } + return codec; + } } diff --git a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/LocalDurableTestRunner.java b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/LocalDurableTestRunner.java index f04e1c27b..a7d3196eb 100644 --- a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/LocalDurableTestRunner.java +++ b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/LocalDurableTestRunner.java @@ -6,6 +6,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.UUID; import java.util.function.BiFunction; import software.amazon.awssdk.services.lambda.model.CheckpointUpdatedExecutionState; @@ -23,6 +24,7 @@ import software.amazon.lambda.durable.model.ExecutionStatus; import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesRunner; import software.amazon.lambda.durable.testing.local.LocalMemoryExecutionClient; import software.amazon.lambda.durable.testing.local.OperationResult; @@ -40,24 +42,33 @@ public class LocalDurableTestRunner { private final TypeToken outputType; private final BiFunction handler; private final LocalMemoryExecutionClient storage; + private final SerDes inputSerDes; + private final SerDes inputSerDesOverride; private final SerDes serDes; private final DurableConfig customerConfig; private final Instant executionStartTime = Instant.now(); + private final String executionName = UUID.randomUUID().toString(); + private final String invocationId = UUID.randomUUID().toString(); + private final String executionArn = String.format( + "arn:aws:lambda:us-east-1:123456789012:function:test:$LATEST/durable-execution/%s/%s", + executionName, invocationId); private LocalDurableTestRunner( TypeToken inputType, TypeToken outputType, BiFunction handlerFn, - DurableConfig customerConfig) { + DurableConfig customerConfig, + SerDes inputSerDes) { this.inputType = inputType; this.outputType = outputType; this.handler = handlerFn; + this.inputSerDesOverride = inputSerDes; this.storage = new LocalMemoryExecutionClient(); // Create config that uses customer's configuration but overrides the client with in-memory storage if (customerConfig != null) { // Use customer's config but override the client with our in-memory implementation - this.customerConfig = DurableConfig.builder() + var configBuilder = DurableConfig.builder() .withDurableExecutionClient(storage) .withSerDes(customerConfig.getSerDes()) .withExecutorService(customerConfig.getExecutorService()) @@ -66,14 +77,23 @@ private LocalDurableTestRunner( .withLoggerConfig(customerConfig.getLoggerConfig()) // Temporary: remove along with the checkpointEmptyMap flag in a future major version. .withCheckpointEmptyMap(customerConfig.shouldCheckpointEmptyMap()) - .withPlugins(customerConfig.getPluginRunner().getPlugins().toArray(new DurableExecutionPlugin[0])) - .build(); + .withDeserializeAfterSerialization(customerConfig.shouldDeserializeAfterSerialization()) + .withPlugins(customerConfig.getPluginRunner().getPlugins().toArray(new DurableExecutionPlugin[0])); + configBuilder.withInputSerDes(inputSerDes != null ? inputSerDes : customerConfig.getInputSerDes()); + if (customerConfig.getSerDesExecutorService() != null) { + configBuilder.withSerDesExecutorService(customerConfig.getSerDesExecutorService()); + } + this.customerConfig = configBuilder.build(); } else { // Fallback to default config with in-memory client - this.customerConfig = - DurableConfig.builder().withDurableExecutionClient(storage).build(); + var configBuilder = DurableConfig.builder().withDurableExecutionClient(storage); + if (inputSerDes != null) { + configBuilder.withInputSerDes(inputSerDes); + } + this.customerConfig = configBuilder.build(); } this.serDes = this.customerConfig.getSerDes(); + this.inputSerDes = this.customerConfig.getInputSerDes(); } /** @@ -88,7 +108,7 @@ private LocalDurableTestRunner( */ public static LocalDurableTestRunner create( Class inputType, BiFunction handlerFn) { - return new LocalDurableTestRunner<>(TypeToken.get(inputType), null, handlerFn, null); + return new LocalDurableTestRunner<>(TypeToken.get(inputType), null, handlerFn, null, null); } /** @@ -109,7 +129,7 @@ public static LocalDurableTestRunner create( */ public static LocalDurableTestRunner create( TypeToken inputType, BiFunction handlerFn) { - return new LocalDurableTestRunner<>(inputType, null, handlerFn, null); + return new LocalDurableTestRunner<>(inputType, null, handlerFn, null, null); } /** @@ -125,7 +145,7 @@ public static LocalDurableTestRunner create( */ public static LocalDurableTestRunner create( Class inputType, BiFunction handlerFn, DurableConfig config) { - return new LocalDurableTestRunner<>(TypeToken.get(inputType), null, handlerFn, config); + return new LocalDurableTestRunner<>(TypeToken.get(inputType), null, handlerFn, config, null); } /** * Creates a LocalDurableTestRunner that uses a custom configuration. This allows the test runner to use custom @@ -163,7 +183,7 @@ public static LocalDurableTestRunner create( */ public static LocalDurableTestRunner create( TypeToken inputType, BiFunction handlerFn, DurableConfig config) { - return new LocalDurableTestRunner<>(inputType, null, handlerFn, config); + return new LocalDurableTestRunner<>(inputType, null, handlerFn, config, null); } /** @@ -179,7 +199,7 @@ public static LocalDurableTestRunner create( */ public static LocalDurableTestRunner create(Class inputType, DurableHandler handler) { return new LocalDurableTestRunner<>( - TypeToken.get(inputType), null, handler::handleRequest, handler.getConfiguration()); + TypeToken.get(inputType), null, handler::handleRequest, handler.getConfiguration(), null); } /** @@ -187,17 +207,33 @@ public static LocalDurableTestRunner create(Class inputType, Dur * a new runner instance. */ public LocalDurableTestRunner withDurableConfig(DurableConfig config) { - return new LocalDurableTestRunner<>(inputType, outputType, handler, config); + return new LocalDurableTestRunner<>(inputType, outputType, handler, config, inputSerDesOverride); } /** Overrides the output type for this test runner. */ public LocalDurableTestRunner withOutputType(TypeToken outputType) { - return new LocalDurableTestRunner<>(inputType, outputType, handler, customerConfig); + return new LocalDurableTestRunner<>(inputType, outputType, handler, customerConfig, inputSerDesOverride); } /** Overrides the output type for this test runner. */ public LocalDurableTestRunner withOutputType(Class outputType) { - return new LocalDurableTestRunner<>(inputType, TypeToken.get(outputType), handler, customerConfig); + return new LocalDurableTestRunner<>( + inputType, TypeToken.get(outputType), handler, customerConfig, inputSerDesOverride); + } + + /** + * Returns a new runner with a separate value codec for the initial Lambda invocation payload. + * + *

The returned runner uses this codec both to serialize the external payload and to configure + * {@link DurableExecutor} to deserialize it. Persisted pipeline stages are not invoked at this boundary. + */ + public LocalDurableTestRunner withInputSerDes(SerDes inputSerDes) { + return new LocalDurableTestRunner<>( + inputType, + outputType, + handler, + customerConfig, + Objects.requireNonNull(inputSerDes, "inputSerDes cannot be null")); } /** @@ -233,16 +269,18 @@ public LocalDurableTestRunner withOutputType(Class outputType) { * @return LocalDurableTestRunner configured with the handler's settings */ public static LocalDurableTestRunner create(TypeToken inputType, DurableHandler handler) { - return new LocalDurableTestRunner<>(inputType, null, handler::handleRequest, handler.getConfiguration()); + return new LocalDurableTestRunner<>(inputType, null, handler::handleRequest, handler.getConfiguration(), null); } /** Run a single invocation (may return PENDING if waiting/retrying). */ public TestResult run(I input) { + var serDesRunner = new SerDesRunner(customerConfig.getSerDesExecutorService()); var durableInput = createDurableInput(input); var output = DurableExecutor.execute(durableInput, mockLambdaContext(), inputType, handler, customerConfig); - return storage.toTestResult(output, outputType, serDes); + return storage.toTestResult( + output, outputType, serDes, serDesRunner, executionArn, invocationId, executionName); } /** @@ -281,7 +319,14 @@ public void simulateFireAndForgetCheckpointLoss(String stepName) { /** Returns the {@link TestOperation} for the given operation name, or null if not found. */ public TestOperation getOperation(String name) { var op = storage.getOperationByName(name); - return op != null ? new TestOperation(op, serDes) : null; + return op != null + ? new TestOperation( + op, + List.of(), + serDes, + new SerDesRunner(customerConfig.getSerDesExecutorService()), + executionArn) + : null; } /** Get callback ID for a named callback operation. */ @@ -330,12 +375,7 @@ public void stopChainedInvoke(String name, ErrorObject error) { } private DurableExecutionInput createDurableInput(I input) { - var executionName = UUID.randomUUID().toString(); - var invocationId = UUID.randomUUID().toString(); - var executionArn = String.format( - "arn:aws:lambda:us-east-1:123456789012:function:test:$LATEST/durable-execution/%s/%s", - executionName, invocationId); - var inputJson = serDes.serialize(input); + var inputJson = serializeInput(input); var executionOp = Operation.builder() .id(invocationId) .name(executionName) @@ -347,7 +387,9 @@ private DurableExecutionInput createDurableInput(I input) { .build(); // Load previous operations and include them in InitialExecutionState - var existingOps = storage.getAllOperations(); + var existingOps = storage.getAllOperations().stream() + .filter(op -> op.type() != OperationType.EXECUTION) + .toList(); var allOps = new ArrayList<>(List.of(executionOp)); allOps.addAll(existingOps); @@ -362,6 +404,10 @@ private DurableExecutionInput createDurableInput(I input) { updatedOperationIds); } + private String serializeInput(I input) { + return inputSerDes.serialize(input); + } + private Context mockLambdaContext() { return null; // Minimal - tests don't need real Lambda context } diff --git a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/TestOperation.java b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/TestOperation.java index 31a28b988..308b7e397 100644 --- a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/TestOperation.java +++ b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/TestOperation.java @@ -18,22 +18,39 @@ import software.amazon.awssdk.services.lambda.model.WaitDetails; import software.amazon.lambda.durable.TypeToken; import software.amazon.lambda.durable.execution.ExecutionManager; +import software.amazon.lambda.durable.model.OperationSubType; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.SerDesRunner; /** Wrapper for AWS SDK Operation providing convenient access methods. */ public class TestOperation { private final Operation operation; private final List events; private final SerDes serDes; + private final SerDesRunner serDesRunner; + private final String durableExecutionArn; public TestOperation(Operation operation, SerDes serDes) { this(operation, List.of(), serDes); } public TestOperation(Operation operation, List events, SerDes serDes) { + this(operation, events, serDes, null, null); + } + + public TestOperation( + Operation operation, + List events, + SerDes serDes, + SerDesRunner serDesRunner, + String durableExecutionArn) { this.operation = operation; this.events = events; this.serDes = serDes; + this.serDesRunner = serDesRunner; + this.durableExecutionArn = durableExecutionArn; } /** Returns the raw history events associated with this operation. */ @@ -119,7 +136,40 @@ public T getStepResult(TypeToken type) { if (details == null || details.result() == null) { return null; } - return serDes.deserialize(details.result(), type); + if (serDesRunner == null) { + return serDes.deserialize(details.result(), type); + } + var subType = java.util.Arrays.stream(OperationSubType.values()) + .filter(value -> value.getValue().equals(operation.subType())) + .findFirst() + .orElse(OperationSubType.STEP); + var payloadKind = + subType == OperationSubType.WAIT_FOR_CONDITION ? SerDesPayloadKind.STATE : SerDesPayloadKind.RESULT; + var resultAttempt = resultAttempt(details, subType); + return serDesRunner.deserialize( + serDes, + details.result(), + type, + SerDesContext.forOperation( + durableExecutionArn, + operation.id(), + operation.name(), + operation.parentId(), + operation.type(), + subType, + payloadKind, + resultAttempt)); + } + + private Integer resultAttempt(StepDetails details, OperationSubType subType) { + var attempt = details.attempt(); + if (subType == OperationSubType.WAIT_FOR_CONDITION + && operation.status() == OperationStatus.FAILED + && attempt != null + && attempt > 1) { + return attempt - 1; + } + return attempt; } /** Returns the step error, or null if the step succeeded or this is not a step operation. */ diff --git a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/TestResult.java b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/TestResult.java index 7de85beef..f78fb80b7 100644 --- a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/TestResult.java +++ b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/TestResult.java @@ -15,6 +15,9 @@ import software.amazon.lambda.durable.TypeToken; import software.amazon.lambda.durable.model.ExecutionStatus; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.SerDesRunner; /** * Represents the result of a durable execution, providing access to the execution status, output, operations, and @@ -33,6 +36,8 @@ public class TestResult { private final List allEvents; private final SerDes serDes; private final TypeToken resultType; + private final SerDesRunner serDesRunner; + private final SerDesContext outputContext; public TestResult( ExecutionStatus status, @@ -42,6 +47,21 @@ public TestResult( List allEvents, TypeToken resultType, SerDes serDes) { + this(status, resultPayload, error, operations, allEvents, resultType, serDes, null, null, null, null); + } + + public TestResult( + ExecutionStatus status, + String resultPayload, + ErrorObject error, + List operations, + List allEvents, + TypeToken resultType, + SerDes serDes, + SerDesRunner serDesRunner, + String durableExecutionArn, + String executionOperationId, + String executionOperationName) { this.status = status; this.resultPayload = resultPayload; this.error = error; @@ -51,6 +71,11 @@ public TestResult( this.allEvents = List.copyOf(allEvents); this.serDes = serDes; this.resultType = resultType; + this.serDesRunner = serDesRunner; + this.outputContext = serDesRunner == null + ? null + : SerDesContext.forExecution( + durableExecutionArn, executionOperationId, executionOperationName, SerDesPayloadKind.OUTPUT); } /** Returns the execution status (SUCCEEDED, FAILED, or PENDING). */ @@ -75,12 +100,18 @@ public T getResult(TypeToken resultType) { if (resultPayload == null || resultPayload.isEmpty()) { var lastEvent = allEvents.get(allEvents.size() - 1); if (lastEvent.eventType() == EventType.EXECUTION_SUCCEEDED) { - return serDes.deserialize( + return deserialize( lastEvent.executionSucceededDetails().result().payload(), resultType); } return null; } - return serDes.deserialize(resultPayload, resultType); + return deserialize(resultPayload, resultType); + } + + private T deserialize(String payload, TypeToken type) { + return serDesRunner == null + ? serDes.deserialize(payload, type) + : serDesRunner.deserialize(serDes, payload, type, outputContext); } /** Deserializes and returns the execution output if the result type is known. */ diff --git a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/cloud/HistoryEventProcessor.java b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/cloud/HistoryEventProcessor.java index 4a3b8f1b2..445ee335c 100644 --- a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/cloud/HistoryEventProcessor.java +++ b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/cloud/HistoryEventProcessor.java @@ -5,19 +5,23 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Objects; import software.amazon.awssdk.services.lambda.model.CallbackDetails; import software.amazon.awssdk.services.lambda.model.ChainedInvokeDetails; import software.amazon.awssdk.services.lambda.model.ContextDetails; import software.amazon.awssdk.services.lambda.model.ErrorObject; import software.amazon.awssdk.services.lambda.model.Event; +import software.amazon.awssdk.services.lambda.model.EventType; import software.amazon.awssdk.services.lambda.model.Operation; import software.amazon.awssdk.services.lambda.model.OperationStatus; import software.amazon.awssdk.services.lambda.model.OperationType; import software.amazon.awssdk.services.lambda.model.StepDetails; import software.amazon.awssdk.services.lambda.model.WaitDetails; import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.execution.ExecutionManager; import software.amazon.lambda.durable.model.ExecutionStatus; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesRunner; import software.amazon.lambda.durable.testing.AsyncExecution; import software.amazon.lambda.durable.testing.CloudDurableTestRunner; import software.amazon.lambda.durable.testing.TestOperation; @@ -37,11 +41,33 @@ public class HistoryEventProcessor { * @return a TestResult containing the execution status, output, and operation details */ public TestResult processEvents(List events, TypeToken outputType, SerDes serDes) { + return processEvents(events, outputType, serDes, null, null); + } + + /** + * Processes execution history using SDK-managed SerDes context. + * + * @param events the raw history events from the GetDurableExecutionHistory API + * @param outputType the expected output type for deserialization + * @param serDes the SerDes used by the durable function + * @param serDesRunner invocation-scoped SerDes runner, or {@code null} for legacy direct calls + * @param durableExecutionArn durable execution ARN, required when {@code serDesRunner} is supplied + * @param the handler output type + * @return a TestResult containing the execution status, output, and operation details + */ + public TestResult processEvents( + List events, + TypeToken outputType, + SerDes serDes, + SerDesRunner serDesRunner, + String durableExecutionArn) { var operations = new HashMap(); var operationEvents = new HashMap>(); var status = ExecutionStatus.PENDING; String result = null; ErrorObject error = null; + String executionOperationId = null; + String executionOperationName = null; for (var event : events) { var eventType = event.eventType(); @@ -56,7 +82,8 @@ public TestResult processEvents(List events, TypeToken outputTy switch (eventType) { case EXECUTION_STARTED -> { - // Execution started - no action needed, just track the event + executionOperationId = operationId; + executionOperationName = event.name(); } case INVOCATION_COMPLETED -> { var details = event.invocationCompletedDetails(); @@ -111,7 +138,14 @@ public TestResult processEvents(List events, TypeToken outputTy if (operationId != null) { operations.putIfAbsent( operationId, - createStepOperation(operationId, event.name(), null, OperationStatus.STARTED, 1)); + createStepOperation( + operationId, + event.name(), + event.parentId(), + event.subType(), + null, + OperationStatus.STARTED, + 1)); } } case STEP_SUCCEEDED -> { @@ -126,7 +160,13 @@ public TestResult processEvents(List events, TypeToken outputTy operations.put( operationId, createStepOperation( - operationId, event.name(), stepResult, OperationStatus.SUCCEEDED, attempt)); + operationId, + event.name(), + event.parentId(), + event.subType(), + stepResult, + OperationStatus.SUCCEEDED, + attempt)); } } case STEP_FAILED -> { @@ -137,7 +177,14 @@ public TestResult processEvents(List events, TypeToken outputTy : 1; operations.put( operationId, - createStepOperation(operationId, event.name(), null, OperationStatus.FAILED, attempt)); + createStepOperation( + operationId, + event.name(), + event.parentId(), + event.subType(), + null, + OperationStatus.FAILED, + attempt)); } } @@ -224,7 +271,11 @@ public TestResult processEvents(List events, TypeToken outputTy CHAINED_INVOKE_TIMED_OUT, CHAINED_INVOKE_STOPPED -> { if (operationId != null) { - operations.putIfAbsent(operationId, createInvokeOperation(operationId, event)); + if (eventType == EventType.CHAINED_INVOKE_STARTED) { + operations.putIfAbsent(operationId, createInvokeOperation(operationId, event)); + } else { + operations.put(operationId, createInvokeOperation(operationId, event)); + } } } @@ -236,14 +287,60 @@ public TestResult processEvents(List events, TypeToken outputTy var testOperations = new ArrayList(); for (var entry : operations.entrySet()) { var opEvents = operationEvents.getOrDefault(entry.getKey(), List.of()); - testOperations.add(new TestOperation(entry.getValue(), opEvents, serDes)); + var operation = withEventTimestamps(entry.getValue(), opEvents); + testOperations.add( + serDesRunner == null + ? new TestOperation(operation, opEvents, serDes) + : new TestOperation(operation, opEvents, serDes, serDesRunner, durableExecutionArn)); } - return new TestResult<>(status, result, error, testOperations, events, outputType, serDes); + if (executionOperationId == null && durableExecutionArn != null) { + var parts = durableExecutionArn.split("/", -1); + executionOperationId = parts[parts.length - 1]; + } + return serDesRunner == null + ? new TestResult<>(status, result, error, testOperations, events, outputType, serDes) + : new TestResult<>( + status, + result, + error, + testOperations, + events, + outputType, + serDes, + serDesRunner, + durableExecutionArn, + executionOperationId, + executionOperationName); + } + + private Operation withEventTimestamps(Operation operation, List events) { + var startTimestamp = events.stream() + .map(Event::eventTimestamp) + .filter(Objects::nonNull) + .min(java.time.Instant::compareTo) + .orElse(operation.startTimestamp()); + var endTimestamp = ExecutionManager.isTerminalStatus(operation.status()) + ? events.stream() + .map(Event::eventTimestamp) + .filter(Objects::nonNull) + .max(java.time.Instant::compareTo) + .orElse(operation.endTimestamp()) + : operation.endTimestamp(); + return operation.toBuilder() + .startTimestamp(startTimestamp) + .endTimestamp(endTimestamp) + .build(); } private Operation createStepOperation( - String id, String name, String stepResult, OperationStatus status, Integer attempt) { + String id, + String name, + String parentId, + String subType, + String stepResult, + OperationStatus status, + Integer attempt) { var stepDetails = StepDetails.builder() .result(stepResult) .attempt(attempt != null ? attempt : 1) @@ -252,8 +349,10 @@ private Operation createStepOperation( return Operation.builder() .id(id) .name(name) + .parentId(parentId) .status(status) .type(OperationType.STEP) + .subType(subType) .stepDetails(stepDetails) .build(); } @@ -267,8 +366,10 @@ private Operation createWaitOperation(String id, String name, OperationStatus st return Operation.builder() .id(id) .name(name) + .parentId(event.parentId()) .status(status) .type(OperationType.WAIT) + .subType(event.subType()) .waitDetails(builder.build()) .build(); } @@ -302,8 +403,10 @@ private Operation createCallbackOperation(String id, String name, OperationStatu return Operation.builder() .id(id) .name(name) + .parentId(event.parentId()) .status(status) .type(OperationType.CALLBACK) + .subType(event.subType()) .callbackDetails(builder.build()) .build(); } @@ -315,7 +418,7 @@ private Operation createInvokeOperation(String id, Event event) { switch (event.eventType()) { case CHAINED_INVOKE_STARTED -> OperationStatus.STARTED; case CHAINED_INVOKE_SUCCEEDED -> { - var details = event.callbackSucceededDetails(); + var details = event.chainedInvokeSucceededDetails(); if (details != null && details.result() != null && details.result().payload() != null) { @@ -324,7 +427,7 @@ private Operation createInvokeOperation(String id, Event event) { yield OperationStatus.SUCCEEDED; } case CHAINED_INVOKE_FAILED -> { - var details = event.callbackFailedDetails(); + var details = event.chainedInvokeFailedDetails(); if (details != null && details.error() != null && details.error().payload() != null) { @@ -359,8 +462,10 @@ private Operation createInvokeOperation(String id, Event event) { return Operation.builder() .id(id) .name(event.name()) + .parentId(event.parentId()) .status(status) .type(OperationType.CHAINED_INVOKE) + .subType(event.subType()) .chainedInvokeDetails(builder.build()) .build(); } @@ -383,6 +488,7 @@ private Operation createContextOperation(String id, String name, OperationStatus return Operation.builder() .id(id) .name(name) + .parentId(event.parentId()) .status(status) .type(OperationType.CONTEXT) .subType(event.subType()) diff --git a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/local/LocalMemoryExecutionClient.java b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/local/LocalMemoryExecutionClient.java index 25f016cd9..ca9cd0861 100644 --- a/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/local/LocalMemoryExecutionClient.java +++ b/sdk-testing/src/main/java/software/amazon/lambda/durable/testing/local/LocalMemoryExecutionClient.java @@ -24,6 +24,7 @@ import software.amazon.lambda.durable.client.DurableExecutionClient; import software.amazon.lambda.durable.model.DurableExecutionOutput; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesRunner; import software.amazon.lambda.durable.testing.TestOperation; import software.amazon.lambda.durable.testing.TestResult; @@ -131,9 +132,22 @@ public List getUpdatedOperationIdsSinceLastInvocation() { /** Build TestResult from current state. */ public TestResult toTestResult(DurableExecutionOutput output, TypeToken resultType, SerDes serDes) { + return toTestResult(output, resultType, serDes, null, null, null, null); + } + + /** Build a context-aware TestResult from current state. */ + public TestResult toTestResult( + DurableExecutionOutput output, + TypeToken resultType, + SerDes serDes, + SerDesRunner serDesRunner, + String durableExecutionArn, + String executionOperationId, + String executionOperationName) { var testOperations = existingOperations.values().stream() .filter(op -> op.type() != OperationType.EXECUTION) - .map(op -> new TestOperation(op, eventProcessor.getEventsForOperation(op.id()), serDes)) + .map(op -> new TestOperation( + op, eventProcessor.getEventsForOperation(op.id()), serDes, serDesRunner, durableExecutionArn)) .toList(); return new TestResult<>( output.status(), @@ -142,7 +156,11 @@ public TestResult toTestResult(DurableExecutionOutput output, TypeToken T deserialize(String data, TypeToken typeToken) { + deserializations.incrementAndGet(); + return (T) data; + } + }; + var execution = new AsyncExecution<>( + EXECUTION_ARN, lambdaClient, TypeToken.get(String.class), serDes, Duration.ZERO, Duration.ofSeconds(1)); + var snapshots = new AtomicInteger(); + + execution.pollUntil(current -> { + assertEquals("step-result", current.getOperation("step").getStepResult(String.class)); + assertEquals("step-result", current.getOperation("step").getStepResult(String.class)); + return snapshots.incrementAndGet() == 2; + }); + + assertEquals(2, deserializations.get()); + } + + private static List stepEvents() { + var startedAt = Instant.parse("2026-08-25T00:00:00Z"); + return List.of( + Event.builder() + .id("step-id") + .name("step") + .subType("Step") + .eventType(EventType.STEP_STARTED) + .eventTimestamp(startedAt) + .stepStartedDetails(StepStartedDetails.builder().build()) + .build(), + Event.builder() + .id("step-id") + .name("step") + .subType("Step") + .eventType(EventType.STEP_SUCCEEDED) + .eventTimestamp(startedAt.plusSeconds(1)) + .stepSucceededDetails(StepSucceededDetails.builder() + .result(EventResult.builder() + .payload("step-result") + .build()) + .retryDetails( + RetryDetails.builder().currentAttempt(1).build()) + .build()) + .build()); + } +} diff --git a/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/CloudDurableTestRunnerTest.java b/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/CloudDurableTestRunnerTest.java index b1fde42e9..a3d418a9d 100644 --- a/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/CloudDurableTestRunnerTest.java +++ b/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/CloudDurableTestRunnerTest.java @@ -5,10 +5,24 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import java.nio.file.Path; import java.time.Duration; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; import software.amazon.awssdk.services.lambda.LambdaClient; import software.amazon.awssdk.services.lambda.model.InvocationType; +import software.amazon.awssdk.services.lambda.model.InvokeRequest; +import software.amazon.awssdk.services.lambda.model.InvokeResponse; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.exception.SerDesException; +import software.amazon.lambda.durable.serde.JacksonSerDes; +import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.SerDesRunner; +import software.amazon.lambda.durable.serde.SerDesStage; +import software.amazon.lambda.durable.serde.filesystem.FileSystemSerDesStage; class CloudDurableTestRunnerTest { @@ -31,4 +45,153 @@ void testPlaceholderMethods() { assertThrows(IllegalStateException.class, () -> runner.getOperation("test")); } + + @Test + void rejectsComposableInputSerDes() { + var mockClient = mock(LambdaClient.class); + var runner = CloudDurableTestRunner.create( + "arn:aws:lambda:us-east-2:123:function:test", String.class, String.class, mockClient); + + var failure = assertThrows( + IllegalArgumentException.class, + () -> runner.withInputSerDes(new JacksonSerDes().then(wrappingStage()))); + + assertTrue(failure.getMessage().contains("value codec")); + } + + @Test + void persistedComposableSerDesUsesRootValueCodec() { + var mockClient = mock(LambdaClient.class); + when(mockClient.invoke(any(InvokeRequest.class))) + .thenReturn(InvokeResponse.builder() + .durableExecutionArn("arn:aws:lambda:us-east-2:123:function:test:1/durable-execution/e/i") + .build()); + var runner = CloudDurableTestRunner.create( + "arn:aws:lambda:us-east-2:123:function:test", String.class, String.class, mockClient) + .withSerDes(wrappingValueCodec().then(wrappingStage())); + + runner.startAsync("value"); + + var request = ArgumentCaptor.forClass(InvokeRequest.class); + verify(mockClient).invoke(request.capture()); + assertEquals("<\"value\">", request.getValue().payload().asUtf8String()); + } + + @Test + void plainPersistedSerDesIsUsedAsDefaultInputCodec() { + var mockClient = mock(LambdaClient.class); + when(mockClient.invoke(any(InvokeRequest.class))) + .thenReturn(InvokeResponse.builder() + .durableExecutionArn("arn:aws:lambda:us-east-2:123:function:test:1/durable-execution/e/i") + .build()); + var runner = CloudDurableTestRunner.create( + "arn:aws:lambda:us-east-2:123:function:test", String.class, String.class, mockClient) + .withSerDes(wrappingValueCodec()); + + runner.startAsync("value"); + + var request = ArgumentCaptor.forClass(InvokeRequest.class); + verify(mockClient).invoke(request.capture()); + assertEquals("<\"value\">", request.getValue().payload().asUtf8String()); + } + + @Test + void explicitInputValueCodecIsUsed() { + var mockClient = mock(LambdaClient.class); + when(mockClient.invoke(any(InvokeRequest.class))) + .thenReturn(InvokeResponse.builder() + .durableExecutionArn("arn:aws:lambda:us-east-2:123:function:test:1/durable-execution/e/i") + .build()); + var runner = CloudDurableTestRunner.create( + "arn:aws:lambda:us-east-2:123:function:test", String.class, String.class, mockClient) + .withInputSerDes(wrappingValueCodec()); + + runner.startAsync("value"); + + var request = ArgumentCaptor.forClass(InvokeRequest.class); + verify(mockClient).invoke(request.capture()); + assertEquals("<\"value\">", request.getValue().payload().asUtf8String()); + } + + @Test + void valueCodecInputRoundTripsThroughFileSystemPipeline(@TempDir Path basePath) { + var mockClient = mock(LambdaClient.class); + var executionArn = "arn:aws:lambda:us-east-2:123:function:test:1/durable-execution/e/i"; + when(mockClient.invoke(any(InvokeRequest.class))) + .thenReturn(InvokeResponse.builder() + .durableExecutionArn(executionArn) + .build()); + var persistedSerDes = + new JacksonSerDes().then(FileSystemSerDesStage.builder(basePath).build()); + var runner = CloudDurableTestRunner.create( + "arn:aws:lambda:us-east-2:123:function:test", String.class, String.class, mockClient) + .withSerDes(persistedSerDes); + + runner.startAsync("value"); + + var request = ArgumentCaptor.forClass(InvokeRequest.class); + verify(mockClient).invoke(request.capture()); + assertEquals( + "value", + new SerDesRunner(null) + .deserialize( + persistedSerDes, + request.getValue().payload().asUtf8String(), + TypeToken.get(String.class), + SerDesContext.forExecution(executionArn, "i", "execution", SerDesPayloadKind.INPUT))); + } + + @Test + void replacingPersistedSerDesPreservesExplicitInputSerDes() { + var mockClient = mock(LambdaClient.class); + when(mockClient.invoke(any(InvokeRequest.class))) + .thenReturn(InvokeResponse.builder() + .durableExecutionArn("arn:aws:lambda:us-east-2:123:function:test:1/durable-execution/e/i") + .build()); + var runner = CloudDurableTestRunner.create( + "arn:aws:lambda:us-east-2:123:function:test", String.class, String.class, mockClient) + .withInputSerDes(wrappingValueCodec()) + .withSerDes(new JacksonSerDes()); + + runner.startAsync("value"); + + var request = ArgumentCaptor.forClass(InvokeRequest.class); + verify(mockClient).invoke(request.capture()); + assertEquals("<\"value\">", request.getValue().payload().asUtf8String()); + } + + private static SerDesStage wrappingStage() { + return new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return "<" + value + ">"; + } + + @Override + public String deserialize(String data, SerDesContext context) { + if (!data.startsWith("<")) { + return data; + } + if (!data.endsWith(">")) { + throw new SerDesException("Malformed wrapping stage value"); + } + return data.substring(1, data.length() - 1); + } + }; + } + + private static SerDes wrappingValueCodec() { + var delegate = new JacksonSerDes(); + return new SerDes() { + @Override + public String serialize(Object value) { + return "<" + delegate.serialize(value) + ">"; + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + return delegate.deserialize(data.substring(1, data.length() - 1), typeToken); + } + }; + } } diff --git a/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/LocalDurableTestRunnerTest.java b/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/LocalDurableTestRunnerTest.java index 36f1bbced..e6b6d58ac 100644 --- a/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/LocalDurableTestRunnerTest.java +++ b/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/LocalDurableTestRunnerTest.java @@ -5,17 +5,30 @@ import static org.junit.jupiter.api.Assertions.*; import static software.amazon.lambda.durable.TypeToken.get; +import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import software.amazon.lambda.durable.DurableConfig; import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.exception.SerDesException; import software.amazon.lambda.durable.model.ExecutionStatus; import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; import software.amazon.lambda.durable.plugin.InvocationInfo; +import software.amazon.lambda.durable.serde.Base64StringBinaryCodec; +import software.amazon.lambda.durable.serde.BinarySerDesStage; +import software.amazon.lambda.durable.serde.ComposableBinarySerDesStage; +import software.amazon.lambda.durable.serde.JacksonSerDes; +import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesStage; +import software.amazon.lambda.durable.serde.Utf8StringBinaryCodec; +import software.amazon.lambda.durable.serde.filesystem.FileSystemSerDesStage; class LocalDurableTestRunnerTest { @@ -114,4 +127,189 @@ public void onInvocationStart(InvocationInfo info) { assertNotNull(executionStartTimes.get(0)); assertEquals(executionStartTimes.get(0), executionStartTimes.get(1)); } + + @Test + void checkpointedLargeOutputReplaysWithoutDuplicateExecutionOperation() { + var stepExecutions = new AtomicInteger(); + var largeResult = "x".repeat(7 * 1024 * 1024); + var runner = LocalDurableTestRunner.create(String.class, (input, context) -> { + context.step("once", Void.class, step -> { + stepExecutions.incrementAndGet(); + return null; + }); + return largeResult; + }) + .withOutputType(String.class); + + var firstResult = runner.run("test"); + var replayResult = runner.run("test"); + + assertEquals(ExecutionStatus.SUCCEEDED, firstResult.getStatus()); + assertEquals(largeResult, firstResult.getResult()); + assertEquals(ExecutionStatus.SUCCEEDED, replayResult.getStatus()); + assertEquals(largeResult, replayResult.getResult()); + assertEquals(1, stepExecutions.get()); + } + + @Test + void filesystemPersistedSerDesUsesDefaultJacksonInputCodec(@TempDir Path basePath) { + var config = DurableConfig.builder() + .withSerDes(new JacksonSerDes() + .then(FileSystemSerDesStage.builder(basePath).build())) + .build(); + var runner = LocalDurableTestRunner.create(String.class, (input, context) -> input, config) + .withOutputType(String.class); + + var result = runner.run("value"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("value", result.getResult()); + } + + @Test + void plainPersistedSerDesIsUsedAsDefaultInputCodec() { + var config = DurableConfig.builder().withSerDes(prefixedStringSerDes()).build(); + var runner = LocalDurableTestRunner.create(String.class, (input, context) -> input, config) + .withOutputType(String.class); + + var result = runner.run("value"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("value", result.getResult()); + } + + @Test + void explicitInputSerDesIsUsedByRunnerAndRuntime() { + var config = DurableConfig.builder().withSerDes(new JacksonSerDes()).build(); + var runner = LocalDurableTestRunner.create(String.class, (input, context) -> input, config) + .withInputSerDes(prefixedStringSerDes()) + .withOutputType(String.class); + + var result = runner.run("value"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("value", result.getResult()); + } + + @Test + void initialInputBypassesPersistedPipelineStages() { + var deserializeCalls = new AtomicInteger(); + var persistedSerDes = new JacksonSerDes().then(new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return "persisted:" + value; + } + + @Override + public String deserialize(String data, SerDesContext context) { + deserializeCalls.incrementAndGet(); + if (data.startsWith("custom:")) { + throw new SerDesException("Initial input collided with a persisted stage frame"); + } + return data.startsWith("persisted:") ? data.substring("persisted:".length()) : data; + } + }); + var config = DurableConfig.builder() + .withSerDes(persistedSerDes) + .withInputSerDes(prefixedStringSerDes()) + .build(); + var runner = LocalDurableTestRunner.create( + String.class, (input, context) -> input + ":" + deserializeCalls.get(), config) + .withOutputType(String.class); + + var result = runner.run("value"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("value:0", result.getResult()); + } + + @Test + void rejectsComposableInputSerDes(@TempDir Path basePath) { + var config = DurableConfig.builder() + .withSerDes(new JacksonSerDes() + .then(FileSystemSerDesStage.builder(basePath).build())) + .build(); + var runner = LocalDurableTestRunner.create(String.class, (input, context) -> input, config); + + var failure = assertThrows( + IllegalArgumentException.class, + () -> runner.withInputSerDes(new JacksonSerDes().then(wrappingStage(new AtomicInteger())))); + + assertTrue(failure.getMessage().contains("value codec")); + } + + @Test + void rawInputPassesThroughPersistedStagesBeforeFileSystemSerDesStage(@TempDir Path basePath) { + var deserializeCalls = new AtomicInteger(); + var persistedSerDes = new JacksonSerDes() + .then(bytesStage(deserializeCalls)) + .then(FileSystemSerDesStage.builder(basePath).build()); + var config = DurableConfig.builder().withSerDes(persistedSerDes).build(); + var runner = LocalDurableTestRunner.create( + String.class, (input, context) -> input + ":" + deserializeCalls.get(), config) + .withOutputType(String.class); + + var result = runner.run("value"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("value:0", result.getResult()); + } + + private static SerDesStage wrappingStage(AtomicInteger deserializeCalls) { + return new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return "<" + value + ">"; + } + + @Override + public String deserialize(String data, SerDesContext context) { + deserializeCalls.incrementAndGet(); + if (!data.startsWith("<")) { + return data; + } + if (!data.endsWith(">")) { + throw new SerDesException("Malformed wrapping stage value"); + } + return data.substring(1, data.length() - 1); + } + }; + } + + private static SerDesStage bytesStage(AtomicInteger deserializeCalls) { + return ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .then(new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + return value; + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + deserializeCalls.incrementAndGet(); + return data; + } + }) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + } + + private static SerDes prefixedStringSerDes() { + return new SerDes() { + @Override + public String serialize(Object value) { + return "custom:" + value; + } + + @Override + @SuppressWarnings("unchecked") + public T deserialize(String data, TypeToken typeToken) { + if (!TypeToken.get(String.class).equals(typeToken) || !data.startsWith("custom:")) { + throw new SerDesException("Invalid custom string payload"); + } + return (T) data.substring("custom:".length()); + } + }; + } } diff --git a/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/TestOperationTest.java b/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/TestOperationTest.java new file mode 100644 index 000000000..ba0d29a3e --- /dev/null +++ b/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/TestOperationTest.java @@ -0,0 +1,70 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.testing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.lambda.model.Operation; +import software.amazon.awssdk.services.lambda.model.OperationStatus; +import software.amazon.awssdk.services.lambda.model.StepDetails; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.model.OperationSubType; +import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesRunner; +import software.amazon.lambda.durable.serde.SerDesStage; + +class TestOperationTest { + private static final String EXECUTION_ARN = "arn:aws:lambda:us-east-1:123456789012:function:test:$LATEST" + + "/durable-execution/execution-id/invocation-id"; + + @Test + void failedWaitForConditionReadsStateFromPreviousAttempt() { + var observedContext = new AtomicReference(); + var valueCodec = new SerDes() { + @Override + public String serialize(Object value) { + return value.toString(); + } + + @Override + @SuppressWarnings("unchecked") + public T deserialize(String data, TypeToken typeToken) { + return (T) data; + } + }; + var serDes = valueCodec.then(new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return value; + } + + @Override + public String deserialize(String data, SerDesContext context) { + observedContext.set(context); + return data; + } + }); + var operation = Operation.builder() + .id("wait-id") + .name("wait-condition") + .type(OperationSubType.WAIT_FOR_CONDITION.getOperationType()) + .subType(OperationSubType.WAIT_FOR_CONDITION.getValue()) + .status(OperationStatus.FAILED) + .stepDetails(StepDetails.builder() + .attempt(3) + .result("retained-state") + .build()) + .build(); + var testOperation = new TestOperation(operation, List.of(), serDes, new SerDesRunner(null), EXECUTION_ARN); + + assertEquals("retained-state", testOperation.getStepResult(String.class)); + assertEquals(2, observedContext.get().attempt()); + assertEquals("operation/wait-id/state/attempt-2", observedContext.get().entityId()); + assertNull(observedContext.get().originalValue()); + } +} diff --git a/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/cloud/HistoryEventProcessorTest.java b/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/cloud/HistoryEventProcessorTest.java new file mode 100644 index 000000000..326be662c --- /dev/null +++ b/sdk-testing/src/test/java/software/amazon/lambda/durable/testing/cloud/HistoryEventProcessorTest.java @@ -0,0 +1,155 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.testing.cloud; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.lambda.model.ChainedInvokeStartedDetails; +import software.amazon.awssdk.services.lambda.model.ChainedInvokeSucceededDetails; +import software.amazon.awssdk.services.lambda.model.Event; +import software.amazon.awssdk.services.lambda.model.EventResult; +import software.amazon.awssdk.services.lambda.model.EventType; +import software.amazon.awssdk.services.lambda.model.ExecutionStartedDetails; +import software.amazon.awssdk.services.lambda.model.ExecutionSucceededDetails; +import software.amazon.awssdk.services.lambda.model.OperationStatus; +import software.amazon.awssdk.services.lambda.model.OperationType; +import software.amazon.awssdk.services.lambda.model.RetryDetails; +import software.amazon.awssdk.services.lambda.model.StepStartedDetails; +import software.amazon.awssdk.services.lambda.model.StepSucceededDetails; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.SerDesRunner; +import software.amazon.lambda.durable.serde.SerDesStage; + +class HistoryEventProcessorTest { + private static final String EXECUTION_ARN = "arn:aws:lambda:us-east-1:123456789012:function:test:$LATEST" + + "/durable-execution/execution-id/invocation-id"; + + @Test + void deserializesCloudResultsWithDurablePayloadContext() { + var observedContexts = new ArrayList(); + var serDes = recordingStringSerDes(observedContexts); + var startedAt = Instant.parse("2026-08-24T00:00:00Z"); + var events = List.of( + Event.builder() + .id("invocation-id") + .name("execution") + .eventType(EventType.EXECUTION_STARTED) + .eventTimestamp(startedAt) + .executionStartedDetails( + ExecutionStartedDetails.builder().build()) + .build(), + Event.builder() + .id("step-id") + .name("step") + .subType("Step") + .eventType(EventType.STEP_STARTED) + .eventTimestamp(startedAt.plusSeconds(1)) + .stepStartedDetails(StepStartedDetails.builder().build()) + .build(), + Event.builder() + .id("step-id") + .name("step") + .subType("Step") + .eventType(EventType.STEP_SUCCEEDED) + .eventTimestamp(startedAt.plusSeconds(3)) + .stepSucceededDetails(StepSucceededDetails.builder() + .result(EventResult.builder() + .payload("step-result") + .build()) + .retryDetails( + RetryDetails.builder().currentAttempt(2).build()) + .build()) + .build(), + Event.builder() + .id("invoke-id") + .name("invoke") + .eventType(EventType.CHAINED_INVOKE_STARTED) + .eventTimestamp(startedAt.plusSeconds(4)) + .chainedInvokeStartedDetails(ChainedInvokeStartedDetails.builder() + .functionName("target") + .build()) + .build(), + Event.builder() + .id("invoke-id") + .name("invoke") + .eventType(EventType.CHAINED_INVOKE_SUCCEEDED) + .eventTimestamp(startedAt.plusSeconds(5)) + .chainedInvokeSucceededDetails(ChainedInvokeSucceededDetails.builder() + .result(EventResult.builder() + .payload("invoke-result") + .build()) + .build()) + .build(), + Event.builder() + .id("invocation-id") + .name("execution") + .eventType(EventType.EXECUTION_SUCCEEDED) + .eventTimestamp(startedAt.plusSeconds(6)) + .executionSucceededDetails(ExecutionSucceededDetails.builder() + .result(EventResult.builder() + .payload("execution-result") + .build()) + .build()) + .build()); + + var result = new HistoryEventProcessor() + .processEvents(events, TypeToken.get(String.class), serDes, new SerDesRunner(null), EXECUTION_ARN); + + assertEquals("execution-result", result.getResult()); + assertEquals("step-result", result.getOperation("step").getStepResult(String.class)); + assertEquals(Duration.ofSeconds(2), result.getOperation("step").getDuration()); + assertEquals(OperationStatus.SUCCEEDED, result.getOperation("invoke").getStatus()); + assertEquals( + "invoke-result", + result.getOperation("invoke").getChainedInvokeDetails().result()); + assertEquals(2, observedContexts.size()); + + var outputContext = observedContexts.get(0); + assertEquals(OperationType.EXECUTION, outputContext.operationType()); + assertEquals(SerDesPayloadKind.OUTPUT, outputContext.payloadKind()); + assertEquals("execution/invocation-id/output", outputContext.entityId()); + assertNull(outputContext.originalValue()); + + var stepContext = observedContexts.get(1); + assertEquals(OperationType.STEP, stepContext.operationType()); + assertEquals(SerDesPayloadKind.RESULT, stepContext.payloadKind()); + assertEquals("operation/step-id/result/attempt-2", stepContext.entityId()); + assertEquals(2, stepContext.attempt()); + } + + private static SerDes recordingStringSerDes(List observedContexts) { + SerDes valueCodec = new SerDes() { + @Override + public String serialize(Object value) { + return value.toString(); + } + + @Override + @SuppressWarnings("unchecked") + public T deserialize(String data, TypeToken typeToken) { + return (T) data; + } + }; + return valueCodec.then(new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return value; + } + + @Override + public String deserialize(String data, SerDesContext context) { + observedContexts.add(context); + return data; + } + }); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java b/sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java index 5101b9fda..e5aa1711d 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java @@ -27,6 +27,7 @@ import software.amazon.lambda.durable.plugin.PluginRunner; import software.amazon.lambda.durable.retry.PollingStrategies; import software.amazon.lambda.durable.retry.PollingStrategy; +import software.amazon.lambda.durable.serde.ComposableSerDes; import software.amazon.lambda.durable.serde.JacksonSerDes; import software.amazon.lambda.durable.serde.SerDes; @@ -94,7 +95,9 @@ public final class DurableConfig { private final DurableExecutionClient durableExecutionClient; private final SerDes serDes; + private final SerDes inputSerDes; private final ExecutorService executorService; + private final ExecutorService serDesExecutorService; private final LoggerConfig loggerConfig; private final PollingStrategy pollingStrategy; private final Duration checkpointDelay; @@ -107,8 +110,10 @@ private DurableConfig(Builder builder) { this.durableExecutionClient = Objects.requireNonNullElseGet( builder.durableExecutionClient, DurableConfig::createDefaultDurableExecutionClient); this.serDes = Objects.requireNonNullElseGet(builder.serDes, JacksonSerDes::new); + this.inputSerDes = inputValueCodec(builder.inputSerDes, this.serDes); this.executorService = Objects.requireNonNullElseGet(builder.executorService, DurableConfig::createDefaultExecutor); + this.serDesExecutorService = builder.serDesExecutorService; this.loggerConfig = Objects.requireNonNullElseGet(builder.loggerConfig, LoggerConfig::defaults); this.pollingStrategy = Objects.requireNonNullElse(builder.pollingStrategy, PollingStrategies.Presets.DEFAULT); this.checkpointDelay = Objects.requireNonNullElseGet(builder.checkpointDelay, () -> Duration.ofSeconds(0)); @@ -155,6 +160,19 @@ public SerDes getSerDes() { return serDes; } + /** + * Gets the context-free value codec used to deserialize the initial Lambda invocation payload. + * + *

This codec is separate from the persisted SerDes pipeline because the initial payload is received before a + * durable execution context exists. If it is not explicitly configured, a plain persisted SerDes is reused, while a + * composable persisted SerDes contributes only its root value codec. + * + * @return the initial invocation value codec + */ + public SerDes getInputSerDes() { + return inputSerDes; + } + /** * Gets the configured ExecutorService. * @@ -164,6 +182,15 @@ public ExecutorService getExecutorService() { return executorService; } + /** + * Gets the executor used for customer SerDes calls and payload storage I/O. + * + * @return the configured executor, or {@code null} when SerDes calls execute inline + */ + public ExecutorService getSerDesExecutorService() { + return serDesExecutorService; + } + /** * Gets the configured LoggerConfig. * @@ -232,9 +259,16 @@ public void validateConfiguration() { if (getSerDes() == null) { throw new IllegalStateException("SerDes configuration failed"); } + if (getInputSerDes() == null) { + throw new IllegalStateException("Input SerDes configuration failed"); + } if (getExecutorService() == null) { throw new IllegalStateException("ExecutorService configuration failed"); } + if (getSerDesExecutorService() != null && getSerDesExecutorService() == getExecutorService()) { + throw new IllegalStateException( + "SerDes ExecutorService must be different from the user operation ExecutorService"); + } } /** @@ -311,11 +345,26 @@ private static ExecutorService createDefaultExecutor() { return DEFAULT_USER_THREAD_POOL; } + private static SerDes inputValueCodec(SerDes inputSerDes, SerDes persistedSerDes) { + var codec = inputSerDes; + if (codec == null) { + codec = persistedSerDes instanceof ComposableSerDes composable + ? composable.getValueCodec() + : persistedSerDes; + } + if (codec instanceof ComposableSerDes) { + throw new IllegalArgumentException("inputSerDes must be a value codec, not a composable pipeline"); + } + return codec; + } + /** Builder for DurableConfig. Provides fluent API for configuring SDK components. */ public static final class Builder { private DurableExecutionClient durableExecutionClient; private SerDes serDes; + private SerDes inputSerDes; private ExecutorService executorService; + private ExecutorService serDesExecutorService; private LoggerConfig loggerConfig; private PollingStrategy pollingStrategy; private Duration checkpointDelay; @@ -381,6 +430,27 @@ public Builder withSerDes(SerDes serDes) { return this; } + /** + * Sets the context-free value codec used to deserialize the initial Lambda invocation payload. + * + *

The initial input codec is independent of the SerDes used for persisted execution payloads and must not be + * a {@link ComposableSerDes}. If not set, a plain persisted SerDes is reused, while a composable persisted + * SerDes contributes only its root value codec. + * + * @param inputSerDes initial invocation value codec + * @return this builder + * @throws NullPointerException if inputSerDes is null + * @throws IllegalArgumentException if inputSerDes is a composable pipeline + */ + public Builder withInputSerDes(SerDes inputSerDes) { + inputSerDes = Objects.requireNonNull(inputSerDes, "inputSerDes cannot be null"); + if (inputSerDes instanceof ComposableSerDes) { + throw new IllegalArgumentException("inputSerDes must be a value codec, not a composable pipeline"); + } + this.inputSerDes = inputSerDes; + return this; + } + /** * Sets a custom ExecutorService for running user-defined operations. If not set, a default cached thread pool * will be created. @@ -396,6 +466,22 @@ public Builder withExecutorService(ExecutorService executorService) { return this; } + /** + * Sets the executor used for customer SerDes calls and blocking payload storage I/O. If not set, SerDes calls + * execute inline on the calling thread. + * + *

This executor must be different from the user operation executor to prevent synchronous SerDes dispatch + * from deadlocking a saturated operation pool. + * + * @param executorService the dedicated SerDes executor + * @return this builder + */ + public Builder withSerDesExecutorService(ExecutorService executorService) { + this.serDesExecutorService = + Objects.requireNonNull(executorService, "SerDes ExecutorService cannot be null"); + return this; + } + /** * Sets a custom LoggerConfig. If not set, defaults to suppressing replay logs. * diff --git a/sdk/src/main/java/software/amazon/lambda/durable/config/InvokeConfig.java b/sdk/src/main/java/software/amazon/lambda/durable/config/InvokeConfig.java index e9dc7af24..5eeaf9ee4 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/config/InvokeConfig.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/config/InvokeConfig.java @@ -13,11 +13,13 @@ public class InvokeConfig { private final SerDes payloadSerDes; private final SerDes resultSerDes; private final String tenantId; + private final boolean usePersistedSerDesForPayload; public InvokeConfig(Builder builder) { this.payloadSerDes = builder.payloadSerDes; this.resultSerDes = builder.resultSerDes; this.tenantId = builder.tenantId; + this.usePersistedSerDesForPayload = builder.usePersistedSerDesForPayload; } public SerDes payloadSerDes() { @@ -32,12 +34,17 @@ public String tenantId() { return tenantId; } + /** Returns whether the target should decode this invoke payload with its persisted SerDes pipeline. */ + public boolean usePersistedSerDesForPayload() { + return usePersistedSerDesForPayload; + } + public static Builder builder() { - return new Builder(null, null, null); + return new Builder(null, null, null, false); } public Builder toBuilder() { - return new Builder(payloadSerDes, resultSerDes, tenantId); + return new Builder(payloadSerDes, resultSerDes, tenantId, usePersistedSerDesForPayload); } /** Builder for creating InvokeConfig instances. */ @@ -45,11 +52,14 @@ public static class Builder { private SerDes payloadSerDes; private SerDes resultSerDes; private String tenantId; + private boolean usePersistedSerDesForPayload; - private Builder(SerDes payloadSerDes, SerDes resultSerDes, String tenantId) { + private Builder( + SerDes payloadSerDes, SerDes resultSerDes, String tenantId, boolean usePersistedSerDesForPayload) { this.payloadSerDes = payloadSerDes; this.resultSerDes = resultSerDes; this.tenantId = tenantId; + this.usePersistedSerDesForPayload = usePersistedSerDesForPayload; } /** @@ -69,9 +79,13 @@ public Builder tenantId(String tenantId) { /** * Sets a custom serializer for the invoke operation payload. * - *

If not specified, the invoke operation will use the default SerDes configured for the handler. This allows - * per-invoke customization of serialization behavior, useful for invoke operations that need special handling - * (e.g., custom date formats, encryption, compression). + *

If not specified, the invoke operation uses the handler's context-free input codec by default, or its + * persisted SerDes when {@link #usePersistedSerDesForPayload(boolean)} is enabled. This method allows + * per-invoke customization of serialization behavior, useful for invoke operations that need special handling. + * + *

By default, the serialized value is sent unchanged and must match the target function's ordinary input + * wire format. When {@link #usePersistedSerDesForPayload(boolean)} is enabled, it must instead be compatible + * with the target durable handler's persisted SerDes. * * @param payloadSerDes the custom serializer to use, or null to use the default * @return this builder for method chaining @@ -81,6 +95,26 @@ public Builder payloadSerDes(SerDes payloadSerDes) { return this; } + /** + * Selects whether a compatible durable target should deserialize the invoke payload with its persisted SerDes + * pipeline. + * + *

This is disabled by default so standard Lambda functions, non-Java durable functions, and older Java SDK + * versions continue to receive the configured serialized payload unchanged. Enable it only when the target is a + * Java durable handler that supports the SDK's chained-invoke payload frame and configures a compatible + * persisted SerDes pipeline. + * + *

When enabled without an explicit {@link #payloadSerDes(SerDes)}, the caller's persisted SerDes is used. + * Otherwise, the caller's context-free input codec is the default payload serializer. + * + * @param enabled whether the compatible target should use its persisted SerDes pipeline + * @return this builder for method chaining + */ + public Builder usePersistedSerDesForPayload(boolean enabled) { + this.usePersistedSerDesForPayload = enabled; + return this; + } + /** * Sets a custom serializer for the invoke result. * diff --git a/sdk/src/main/java/software/amazon/lambda/durable/context/DurableContextImpl.java b/sdk/src/main/java/software/amazon/lambda/durable/context/DurableContextImpl.java index 0c79165ec..a5b19480f 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/context/DurableContextImpl.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/context/DurableContextImpl.java @@ -177,9 +177,10 @@ public DurableFuture invokeAsync( config = config.toBuilder().serDes(getDurableConfig().getSerDes()).build(); } if (config.payloadSerDes() == null) { - config = config.toBuilder() - .payloadSerDes(getDurableConfig().getSerDes()) - .build(); + var payloadSerDes = config.usePersistedSerDesForPayload() + ? getDurableConfig().getSerDes() + : getDurableConfig().getInputSerDes(); + config = config.toBuilder().payloadSerDes(payloadSerDes).build(); } var operationId = nextOperationId(); diff --git a/sdk/src/main/java/software/amazon/lambda/durable/exception/CallbackException.java b/sdk/src/main/java/software/amazon/lambda/durable/exception/CallbackException.java index a8fb6a011..92d745e0f 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/exception/CallbackException.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/exception/CallbackException.java @@ -3,6 +3,7 @@ package software.amazon.lambda.durable.exception; import software.amazon.awssdk.services.lambda.model.Operation; +import software.amazon.lambda.durable.util.ExceptionHelper; /** Thrown when a callback operation encounters an error. */ public class CallbackException extends DurableOperationException { @@ -13,7 +14,20 @@ public CallbackException(Operation operation, String message) { } public CallbackException(Operation operation, String message, Throwable cause) { - super(operation, operation.callbackDetails().error(), message, cause); + this(operation, message, cause, null); + } + + protected CallbackException(Operation operation, String message, Throwable cause, Throwable deserializedError) { + super( + operation, + operation.callbackDetails().error(), + message, + operation.callbackDetails().error() != null + ? ExceptionHelper.deserializeStackTrace( + operation.callbackDetails().error().stackTrace()) + : null, + cause, + deserializedError); this.callbackId = operation.callbackDetails().callbackId(); } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/exception/CallbackFailedException.java b/sdk/src/main/java/software/amazon/lambda/durable/exception/CallbackFailedException.java index e3fb9e177..d966bbc3f 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/exception/CallbackFailedException.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/exception/CallbackFailedException.java @@ -8,7 +8,11 @@ /** Exception thrown when a callback fails due to an error from the external system. */ public class CallbackFailedException extends CallbackException { public CallbackFailedException(Operation operation) { - super(operation, buildMessage(operation.callbackDetails().error())); + this(operation, null); + } + + public CallbackFailedException(Operation operation, Throwable deserializedError) { + super(operation, buildMessage(operation.callbackDetails().error()), deserializedError, deserializedError); } private static String buildMessage(ErrorObject error) { diff --git a/sdk/src/main/java/software/amazon/lambda/durable/exception/DurableOperationException.java b/sdk/src/main/java/software/amazon/lambda/durable/exception/DurableOperationException.java index 73078ea1d..5eec194db 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/exception/DurableOperationException.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/exception/DurableOperationException.java @@ -11,6 +11,7 @@ public class DurableOperationException extends DurableExecutionException { private final Operation operation; private final ErrorObject errorObject; + private final transient Throwable deserializedError; public DurableOperationException(Operation operation, ErrorObject errorObject) { this(operation, errorObject, errorObject != null ? errorObject.errorMessage() : null); @@ -36,9 +37,20 @@ public DurableOperationException( String errorMessage, StackTraceElement[] stackTrace, Throwable cause) { + this(operation, errorObject, errorMessage, stackTrace, cause, null); + } + + protected DurableOperationException( + Operation operation, + ErrorObject errorObject, + String errorMessage, + StackTraceElement[] stackTrace, + Throwable cause, + Throwable deserializedError) { super(errorMessage, cause, stackTrace); this.operation = operation; this.errorObject = errorObject; + this.deserializedError = deserializedError; } /** Returns the error details from the failed operation. */ @@ -60,4 +72,13 @@ public OperationStatus getOperationStatus() { public String getOperationId() { return operation.id(); } + + /** + * Returns the original error reconstructed by the operation that produced this exception, when available. + * + *

This is used internally when a child context forwards an operation failure through a different SerDes. + */ + public Throwable deserializedError() { + return deserializedError; + } } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeException.java b/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeException.java index 37bbf2ff9..e88a3a34e 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeException.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeException.java @@ -3,14 +3,30 @@ package software.amazon.lambda.durable.exception; import software.amazon.awssdk.services.lambda.model.Operation; +import software.amazon.lambda.durable.util.ExceptionHelper; /** Base exception for chained invoke operation failures. */ public class InvokeException extends DurableOperationException { public InvokeException(Operation operation) { + this(operation, null); + } + + protected InvokeException(Operation operation, Throwable deserializedError) { super( operation, operation.chainedInvokeDetails() != null ? operation.chainedInvokeDetails().error() - : null); + : null, + operation.chainedInvokeDetails() != null + && operation.chainedInvokeDetails().error() != null + ? operation.chainedInvokeDetails().error().errorMessage() + : null, + operation.chainedInvokeDetails() != null + && operation.chainedInvokeDetails().error() != null + ? ExceptionHelper.deserializeStackTrace( + operation.chainedInvokeDetails().error().stackTrace()) + : null, + deserializedError, + deserializedError); } } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeFailedException.java b/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeFailedException.java index 45f84c341..9bd5b5e01 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeFailedException.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeFailedException.java @@ -8,6 +8,10 @@ public class InvokeFailedException extends InvokeException { public InvokeFailedException(Operation operation) { - super(operation); + this(operation, null); + } + + public InvokeFailedException(Operation operation, Throwable deserializedError) { + super(operation, deserializedError); } } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeStoppedException.java b/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeStoppedException.java index 01dd3e22c..7786e00f7 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeStoppedException.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeStoppedException.java @@ -10,4 +10,8 @@ public class InvokeStoppedException extends InvokeException { public InvokeStoppedException(Operation operation) { super(operation); } + + public InvokeStoppedException(Operation operation, Throwable deserializedError) { + super(operation, deserializedError); + } } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeTimedOutException.java b/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeTimedOutException.java index a0c36c623..df2241924 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeTimedOutException.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/exception/InvokeTimedOutException.java @@ -10,4 +10,8 @@ public class InvokeTimedOutException extends InvokeException { public InvokeTimedOutException(Operation operation) { super(operation); } + + public InvokeTimedOutException(Operation operation, Throwable deserializedError) { + super(operation, deserializedError); + } } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/exception/RetryableSerDesException.java b/sdk/src/main/java/software/amazon/lambda/durable/exception/RetryableSerDesException.java new file mode 100644 index 000000000..d40ad067a --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/exception/RetryableSerDesException.java @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.exception; + +/** + * Indicates a transient serialization or deserialization failure that may succeed when retried. + * + *

{@link software.amazon.lambda.durable.serde.RetrySerDesStage} and + * {@link software.amazon.lambda.durable.serde.RetryBinarySerDesStage} retry only this exception type. Other + * {@link SerDesException} instances are treated as permanent failures. + */ +public class RetryableSerDesException extends SerDesException { + public RetryableSerDesException(String message, Throwable cause) { + super(message, cause); + } + + public RetryableSerDesException(String message) { + super(message); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java b/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java index 649c7a600..f7928942b 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java @@ -12,7 +12,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.services.lambda.model.ErrorObject; -import software.amazon.awssdk.services.lambda.model.Operation; import software.amazon.awssdk.services.lambda.model.OperationAction; import software.amazon.awssdk.services.lambda.model.OperationType; import software.amazon.awssdk.services.lambda.model.OperationUpdate; @@ -31,6 +30,9 @@ import software.amazon.lambda.durable.plugin.InvocationStatus; import software.amazon.lambda.durable.plugin.PluginRunner; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.internal.ChainedInvokePayloadFrame; import software.amazon.lambda.durable.util.ExceptionHelper; /** @@ -76,8 +78,7 @@ public static DurableExecutionOutput execute( I userInput = null; Throwable inputFailure = null; try { - userInput = extractUserInput( - executionManager.getExecutionOperation(), config.getSerDes(), inputType); + userInput = extractUserInput(executionManager, config, inputType); } catch (Throwable t) { inputFailure = t; } @@ -159,11 +160,17 @@ public static DurableExecutionOutput execute( cause, pluginExecutionInput.get(), null); - return DurableExecutionOutput.failure(buildErrorObject(cause, config.getSerDes())); + return DurableExecutionOutput.failure( + buildErrorObject(cause, executionManager, config.getSerDes())); } // user handler complete successfully logger.debug("Execution completed"); - var outputPayload = config.getSerDes().serialize(result); + var outputPayload = executionManager + .getSerDesRunner() + .serialize( + config.getSerDes(), + result, + executionContext(executionManager, SerDesPayloadKind.OUTPUT)); var output = DurableExecutionOutput.success(handleLargePayload(executionManager, outputPayload)); fireOnInvocationEnd( @@ -228,7 +235,7 @@ private static String handleLargePayload(ExecutionManager executionManager, Stri return outputPayload; } - private static ErrorObject buildErrorObject(Throwable e, SerDes serDes) { + private static ErrorObject buildErrorObject(Throwable e, ExecutionManager executionManager, SerDes serDes) { // exceptions thrown from operations, e.g. Step if (e instanceof DurableOperationException durableOperationException) { return durableOperationException.getErrorObject(); @@ -237,16 +244,39 @@ private static ErrorObject buildErrorObject(Throwable e, SerDes serDes) { return unrecoverableDurableExecutionException.getErrorObject(); } // exceptions thrown from non-operation code - return ExceptionHelper.buildErrorObject(e, serDes); + return ErrorObject.builder() + .errorType(e.getClass().getName()) + .errorMessage(e.getMessage()) + .errorData(executionManager + .getSerDesRunner() + .serialize(serDes, e, executionContext(executionManager, SerDesPayloadKind.EXCEPTION))) + .stackTrace(ExceptionHelper.serializeStackTrace(e.getStackTrace())) + .build(); } - private static I extractUserInput(Operation executionOp, SerDes serDes, TypeToken inputType) { + private static I extractUserInput( + ExecutionManager executionManager, DurableConfig config, TypeToken inputType) { + var executionOp = executionManager.getExecutionOperation(); if (executionOp.executionDetails() == null) { throw new IllegalDurableOperationException("EXECUTION operation missing executionDetails"); } var inputPayload = executionOp.executionDetails().inputPayload(); - return serDes.deserialize(inputPayload, inputType); + var serDes = config.getInputSerDes(); + if (ChainedInvokePayloadFrame.isFramed(inputPayload)) { + inputPayload = ChainedInvokePayloadFrame.decode(inputPayload); + serDes = config.getSerDes(); + } + return executionManager + .getSerDesRunner() + .deserialize( + serDes, inputPayload, inputType, executionContext(executionManager, SerDesPayloadKind.INPUT)); + } + + private static SerDesContext executionContext(ExecutionManager executionManager, SerDesPayloadKind payloadKind) { + var operation = executionManager.getExecutionOperation(); + return SerDesContext.forExecution( + executionManager.getDurableExecutionArn(), operation.id(), operation.name(), payloadKind); } /** diff --git a/sdk/src/main/java/software/amazon/lambda/durable/execution/ExecutionManager.java b/sdk/src/main/java/software/amazon/lambda/durable/execution/ExecutionManager.java index 1c45cb0d6..6888ef931 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/execution/ExecutionManager.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/execution/ExecutionManager.java @@ -29,6 +29,7 @@ import software.amazon.lambda.durable.model.SafeCloseable; import software.amazon.lambda.durable.operation.BaseDurableOperation; import software.amazon.lambda.durable.plugin.PluginInfoConverter; +import software.amazon.lambda.durable.serde.SerDesRunner; /** * Central manager for durable execution coordination. @@ -63,6 +64,7 @@ public class ExecutionManager implements SafeCloseable { private final AtomicReference executionMode; private final DurableConfig durableConfig; private final Set updatedOperationIdsSinceLastInvocation; + private final SerDesRunner serDesRunner; // ===== Thread Coordination ===== private final Map registeredOperations = new ConcurrentHashMap<>(); @@ -77,6 +79,7 @@ public ExecutionManager(DurableExecutionInput input, DurableConfig config, Conte durableConfig = config; this.durableExecutionArn = input.durableExecutionArn(); this.lambdaContext = lambdaContext; + this.serDesRunner = new SerDesRunner(config.getSerDesExecutorService()); // Store the set of operation IDs updated since the last successful invocation this.updatedOperationIdsSinceLastInvocation = @@ -115,6 +118,11 @@ public String getDurableExecutionArn() { return durableExecutionArn; } + /** Returns the invocation-scoped SerDes runner. */ + public SerDesRunner getSerDesRunner() { + return serDesRunner; + } + /** Returns {@code true} if the execution is currently replaying completed operations. */ public boolean isReplaying() { return executionMode.get() == ExecutionMode.REPLAY; diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java index 35a71f0da..19e271fb9 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java @@ -30,6 +30,9 @@ import software.amazon.lambda.durable.plugin.PluginInfoConverter; import software.amazon.lambda.durable.plugin.PluginRunner; import software.amazon.lambda.durable.plugin.UserFunctionOutcome; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.SerDesRunner; import software.amazon.lambda.durable.util.ExceptionHelper; /** @@ -60,6 +63,7 @@ public abstract class BaseDurableOperation { protected final boolean isVirtual; protected final AtomicBoolean replayCompletedOperation = new AtomicBoolean(false); private final DurableContextImpl durableContext; + private final SerDesRunner serDesRunner; private final AtomicReference> runningUserHandler = new AtomicReference<>(null); protected BaseDurableOperation( @@ -86,6 +90,8 @@ protected BaseDurableOperation( this.parentOperation = parentOperation; this.durableContext = durableContext; this.executionManager = durableContext.getExecutionManager(); + var invocationSerDesRunner = executionManager.getSerDesRunner(); + this.serDesRunner = invocationSerDesRunner != null ? invocationSerDesRunner : new SerDesRunner(null); this.isVirtual = isVirtual; this.completionFuture = new CompletableFuture<>(); @@ -118,6 +124,24 @@ protected DurableContextImpl getContext() { return durableContext; } + /** Builds the SerDes context for a payload owned by this operation. */ + protected SerDesContext createSerDesContext(SerDesPayloadKind payloadKind, Integer attempt) { + return SerDesContext.forOperation( + executionManager.getDurableExecutionArn(), + getOperationId(), + getName(), + durableContext.getParentId(), + getType(), + getSubType(), + payloadKind, + attempt); + } + + /** Returns the invocation-scoped SerDes runner. */ + protected SerDesRunner getSerDesRunner() { + return serDesRunner; + } + /** Gets the operation type. */ public OperationType getType() { return operationIdentifier.operationType(); diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/CallbackOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/CallbackOperation.java index 9d9481fb9..305b04fd9 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/CallbackOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/CallbackOperation.java @@ -77,7 +77,9 @@ public T get() { return switch (op.status()) { case SUCCEEDED -> deserializeResult(op.callbackDetails().result()); - case FAILED -> throw new CallbackFailedException(op); + case FAILED -> + throw new CallbackFailedException( + op, deserializeException(op.callbackDetails().error())); case TIMED_OUT -> throw new CallbackTimeoutException(op); default -> throw terminateExecutionWithIllegalDurableOperationException( diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java index 8c299cfa4..d8d6b91db 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java @@ -199,7 +199,7 @@ private void handleChildContextFailure(Throwable exception) { final ErrorObject errorObject; if (exception instanceof DurableOperationException opEx) { - errorObject = opEx.getErrorObject(); + errorObject = rebindForwardedException(opEx); } else { errorObject = serializeException(exception); } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/InvokeOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/InvokeOperation.java index 9e2c54ace..9d60609e9 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/InvokeOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/InvokeOperation.java @@ -15,6 +15,8 @@ import software.amazon.lambda.durable.exception.InvokeTimedOutException; import software.amazon.lambda.durable.model.OperationIdentifier; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.internal.ChainedInvokePayloadFrame; /** * Durable operation that invokes another Lambda function and waits for its result. @@ -64,13 +66,18 @@ protected void replay(Operation existing) { } private void startInvocation() { + var serializedPayload = getSerDesRunner() + .serialize(payloadSerDes, this.payload, createSerDesContext(SerDesPayloadKind.INVOKE_PAYLOAD, null)); var update = OperationUpdate.builder() .action(OperationAction.START) .chainedInvokeOptions(ChainedInvokeOptions.builder() .functionName(functionName) .tenantId(invokeConfig.tenantId()) .build()) - .payload(payloadSerDes.serialize(this.payload)); + .payload( + invokeConfig.usePersistedSerDesForPayload() + ? ChainedInvokePayloadFrame.encode(serializedPayload) + : serializedPayload); sendOperationUpdate(update); } @@ -85,11 +92,12 @@ public T get() { var op = waitForOperationCompletion(); var invokeDetails = op.chainedInvokeDetails(); var result = invokeDetails != null ? invokeDetails.result() : null; + var error = invokeDetails != null ? invokeDetails.error() : null; return switch (op.status()) { case SUCCEEDED -> deserializeResult(result); - case FAILED -> throw new InvokeFailedException(op); - case TIMED_OUT -> throw new InvokeTimedOutException(op); - case STOPPED -> throw new InvokeStoppedException(op); + case FAILED -> throw new InvokeFailedException(op, deserializeException(error)); + case TIMED_OUT -> throw new InvokeTimedOutException(op, deserializeException(error)); + case STOPPED -> throw new InvokeStoppedException(op, deserializeException(error)); // Unexpected status which should not happen. This is added for forward-compatibility. default -> throw new InvokeException(op); }; diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/SerializableDurableOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/SerializableDurableOperation.java index 6457c996d..5c3552bcf 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/SerializableDurableOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/SerializableDurableOperation.java @@ -8,9 +8,13 @@ import software.amazon.lambda.durable.DurableFuture; import software.amazon.lambda.durable.TypeToken; import software.amazon.lambda.durable.context.DurableContextImpl; +import software.amazon.lambda.durable.exception.DurableOperationException; +import software.amazon.lambda.durable.exception.RetryableSerDesException; import software.amazon.lambda.durable.exception.SerDesException; import software.amazon.lambda.durable.model.OperationIdentifier; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; import software.amazon.lambda.durable.util.ExceptionHelper; /** @@ -85,8 +89,14 @@ protected SerializableDurableOperation( * @throws SerDesException if deserialization fails */ protected T deserializeResult(String result) { + return deserializeResult(result, SerDesPayloadKind.RESULT, null); + } + + /** Deserializes a result with explicit payload kind and attempt metadata. */ + protected T deserializeResult(String result, SerDesPayloadKind payloadKind, Integer attempt) { try { - return resultSerDes.deserialize(result, resultTypeToken); + return getSerDesRunner() + .deserialize(resultSerDes, result, resultTypeToken, createSerDesContext(payloadKind, attempt)); } catch (SerDesException e) { logger.warn( "Failed to deserialize {} result for operation name '{}'. Ensure the result is properly encoded.", @@ -106,8 +116,17 @@ protected T deserializeResult(String result) { * @return the serialized string and the deserialized result */ protected SerializedResult serializeAndDeserializeResult(T result) { - var serialized = resultSerDes.serialize(result); - var deserialized = shouldDeserializeAfterSerialization() ? deserializeResult(serialized) : result; + return serializeAndDeserializeResult(result, SerDesPayloadKind.RESULT, null); + } + + /** Serializes a result with explicit payload kind and attempt metadata. */ + protected SerializedResult serializeAndDeserializeResult( + T result, SerDesPayloadKind payloadKind, Integer attempt) { + var context = createSerDesContext(payloadKind, attempt); + var serialized = getSerDesRunner().serialize(resultSerDes, result, context); + var deserialized = shouldDeserializeAfterSerialization() + ? getSerDesRunner().deserialize(resultSerDes, serialized, resultTypeToken, context) + : result; return new SerializedResult<>(serialized, deserialized); } @@ -119,13 +138,52 @@ protected SerializedResult serializeAndDeserializeResult(T result) { */ @SuppressWarnings("ThrowableNotThrown") protected ErrorObject serializeException(Throwable throwable) { - var error = ExceptionHelper.buildErrorObject(throwable, resultSerDes); + return serializeException(throwable, null); + } + + /** Serializes a throwable with attempt metadata. */ + protected ErrorObject serializeException(Throwable throwable, Integer attempt) { + var context = createSerDesContext(SerDesPayloadKind.EXCEPTION, attempt); + var error = ErrorObject.builder() + .errorType(throwable.getClass().getName()) + .errorMessage(throwable.getMessage()) + .errorData(getSerDesRunner().serialize(resultSerDes, throwable, context)) + .stackTrace(ExceptionHelper.serializeStackTrace(throwable.getStackTrace())) + .build(); if (shouldDeserializeAfterSerialization()) { - deserializeException(error); + deserializeException(error, attempt); } return error; } + /** + * Re-serializes an exception forwarded from another durable operation under this operation's context. + * + *

Context-dependent SerDes implementations may store the source error data under the producing operation or + * invoked execution. Rebinding reconstructable exceptions prevents a parent checkpoint from later trying to read + * that data using the parent's unrelated entity identity. + */ + protected ErrorObject rebindForwardedException(DurableOperationException exception) { + return rebindForwardedException(exception, null); + } + + /** + * Re-serializes an exception forwarded from another durable operation under this operation's attempt context. + * + * @param exception the forwarded durable operation exception + * @param attempt the receiving operation's attempt, or {@code null} when attempts do not apply + * @return error data owned by this operation when the original exception can be reconstructed; otherwise the + * forwarded error data + */ + protected ErrorObject rebindForwardedException(DurableOperationException exception, Integer attempt) { + var error = exception.getErrorObject(); + if (error == null || exception.getOperation() == null) { + return error; + } + var original = exception.deserializedError(); + return original != null ? serializeException(original, attempt) : error; + } + private boolean shouldDeserializeAfterSerialization() { var config = getContext().getDurableConfig(); return config == null || config.shouldDeserializeAfterSerialization(); @@ -139,6 +197,15 @@ private boolean shouldDeserializeAfterSerialization() { * @return the reconstructed throwable, or null if reconstruction is not possible */ protected Throwable deserializeException(ErrorObject errorObject) { + return deserializeException(errorObject, null); + } + + /** Deserializes a throwable with attempt metadata. */ + protected Throwable deserializeException(ErrorObject errorObject, Integer attempt) { + return deserializeExceptionWithContext(errorObject, createSerDesContext(SerDesPayloadKind.EXCEPTION, attempt)); + } + + private Throwable deserializeExceptionWithContext(ErrorObject errorObject, SerDesContext context) { Throwable original = null; if (errorObject == null) { return original; @@ -153,8 +220,12 @@ protected Throwable deserializeException(ErrorObject errorObject) { Class exceptionClass = Class.forName(errorType); if (Throwable.class.isAssignableFrom(exceptionClass)) { - original = - resultSerDes.deserialize(errorData, TypeToken.get(exceptionClass.asSubclass(Throwable.class))); + original = getSerDesRunner() + .deserialize( + resultSerDes, + errorData, + TypeToken.get(exceptionClass.asSubclass(Throwable.class)), + context); if (original != null) { original.setStackTrace(ExceptionHelper.deserializeStackTrace(errorObject.stackTrace())); @@ -162,6 +233,8 @@ protected Throwable deserializeException(ErrorObject errorObject) { } } catch (ClassNotFoundException e) { logger.warn("Cannot re-construct original exception type. Falling back to generic StepFailedException."); + } catch (RetryableSerDesException e) { + throw e; } catch (SerDesException e) { logger.warn("Cannot deserialize original exception data. Falling back to generic StepFailedException.", e); } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/StepOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/StepOperation.java index 467a87b94..52cfdb98d 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/StepOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/StepOperation.java @@ -25,6 +25,7 @@ import software.amazon.lambda.durable.execution.ThreadType; import software.amazon.lambda.durable.logging.DurableLogger; import software.amazon.lambda.durable.model.OperationIdentifier; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; import software.amazon.lambda.durable.util.ExceptionHelper; /** @@ -117,7 +118,7 @@ private void executeStepLogic(int attempt) { // through onUserFunctionEnd; retry/checkpoint handling stays outside the boundary. T result = runUserFunction(attempt, () -> function.apply(stepContext)); - handleStepSucceeded(result); + handleStepSucceeded(result, attempt); } catch (Throwable e) { handleStepFailure(e, attempt); } @@ -144,8 +145,8 @@ private void checkpointStarted() { } } - private void handleStepSucceeded(T result) { - var serializedResult = serializeAndDeserializeResult(result); + private void handleStepSucceeded(T result, int attempt) { + var serializedResult = serializeAndDeserializeResult(result, SerDesPayloadKind.RESULT, attempt); // Send SUCCEED var successUpdate = @@ -168,9 +169,9 @@ private void handleStepFailure(Throwable exception, int attempt) { final ErrorObject errorObject; if (exception instanceof DurableOperationException durableOperationException) { - errorObject = durableOperationException.getErrorObject(); + errorObject = rebindForwardedException(durableOperationException, attempt); } else { - errorObject = serializeException(exception); + errorObject = serializeException(exception, attempt); } var retryDecision = config.retryStrategy().makeRetryDecision(exception, attempt); @@ -205,8 +206,9 @@ public T get() { if (op.status() == OperationStatus.SUCCEEDED) { var stepDetails = op.stepDetails(); var result = (stepDetails != null) ? stepDetails.result() : null; + var attempt = stepDetails != null ? stepDetails.attempt() : null; - return deserializeResult(result); + return deserializeResult(result, SerDesPayloadKind.RESULT, attempt); } else { var errorObject = op.stepDetails().error(); @@ -216,7 +218,8 @@ public T get() { } // Attempt to reconstruct and throw the original exception - Throwable original = deserializeException(errorObject); + var attempt = op.stepDetails() != null ? op.stepDetails().attempt() : null; + Throwable original = deserializeException(errorObject, attempt); if (original != null) { ExceptionHelper.sneakyThrow(original); } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/WaitForConditionOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/WaitForConditionOperation.java index 6a653c37b..b3a51c7c7 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/WaitForConditionOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/WaitForConditionOperation.java @@ -23,6 +23,7 @@ import software.amazon.lambda.durable.logging.DurableLogger; import software.amazon.lambda.durable.model.OperationIdentifier; import software.amazon.lambda.durable.model.WaitForConditionResult; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; import software.amazon.lambda.durable.util.ExceptionHelper; /** @@ -76,12 +77,14 @@ public T get() { if (op.status() == OperationStatus.SUCCEEDED) { var stepDetails = op.stepDetails(); var result = (stepDetails != null) ? stepDetails.result() : null; - return deserializeResult(result); + var attempt = stepDetails != null ? stepDetails.attempt() : null; + return deserializeResult(result, SerDesPayloadKind.STATE, attempt); } else { var errorObject = op.stepDetails().error(); // Attempt to reconstruct and throw the original exception - Throwable original = deserializeException(errorObject); + var attempt = op.stepDetails() != null ? op.stepDetails().attempt() : null; + Throwable original = deserializeException(errorObject, attempt); if (original != null) { ExceptionHelper.sneakyThrow(original); } @@ -97,7 +100,7 @@ private void resumeCheckLoop(Operation existing) { var checkpointData = stepDetails != null ? stepDetails.result() : null; T currentState; // Get current state if (checkpointData != null) { - currentState = deserializeResult(checkpointData); + currentState = deserializeResult(checkpointData, SerDesPayloadKind.STATE, attempt - 1); } else { currentState = config.initialState(); } @@ -131,7 +134,8 @@ private void executeCheckLogic(T currentState, int attempt) { runUserFunction(attempt, () -> checkFunc.apply(currentState, stepContext)); // Normalize the value through SerDes so first execution matches replay. - var serializedState = serializeAndDeserializeResult(result.value()); + var serializedState = + serializeAndDeserializeResult(result.value(), SerDesPayloadKind.STATE, attempt); T deserializedValue = serializedState.deserialized(); if (result.isDone()) { @@ -161,7 +165,7 @@ private void executeCheckLogic(T currentState, int attempt) { .thenRun(() -> executeCheckLogic(deserializedValue, attempt + 1)); } } catch (Throwable e) { - handleCheckFailure(e); + handleCheckFailure(e, attempt); } } }; @@ -169,7 +173,7 @@ private void executeCheckLogic(T currentState, int attempt) { runUserHandler(userHandler, ThreadType.STEP); } - private void handleCheckFailure(Throwable exception) { + private void handleCheckFailure(Throwable exception, int attempt) { exception = ExceptionHelper.unwrapCompletableFuture(exception); if (exception instanceof SuspendExecutionException suspendExecutionException) { throw suspendExecutionException; @@ -179,8 +183,8 @@ private void handleCheckFailure(Throwable exception) { } final var errorObject = (exception instanceof DurableOperationException durableOpEx) - ? durableOpEx.getErrorObject() - : serializeException(exception); + ? rebindForwardedException(durableOpEx, attempt) + : serializeException(exception, attempt); // Checkpoint FAIL var failUpdate = OperationUpdate.builder().action(OperationAction.FAIL).error(errorObject); diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/Base64StringBinaryCodec.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/Base64StringBinaryCodec.java new file mode 100644 index 000000000..6b4812c84 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/Base64StringBinaryCodec.java @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import java.util.Base64; + +/** Converts bytes to and from standard Base64 strings. */ +public final class Base64StringBinaryCodec implements StringBinaryCodec { + public static final Base64StringBinaryCodec INSTANCE = new Base64StringBinaryCodec(); + + private Base64StringBinaryCodec() {} + + @Override + public byte[] toBytes(String value) { + return Base64.getDecoder().decode(value); + } + + @Override + public String fromBytes(byte[] data) { + return Base64.getEncoder().encodeToString(data); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/BinarySerDesStage.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/BinarySerDesStage.java new file mode 100644 index 000000000..4baac5d31 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/BinarySerDesStage.java @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +/** + * A reversible binary stage used inside a {@link ComposableBinarySerDesStage}. + * + *

Implementations must include any metadata needed for deserialization, such as format versions or encryption + * initialization vectors, in the returned bytes. + * + *

The enclosing composable stage passes the same durable payload context to each binary stage. During serialization, + * {@link SerDesContext#originalValue()} is the object supplied to the root value codec. During deserialization it is + * {@code null}. Stages must treat the original value as read-only. The context itself may be {@code null} only when the + * stage is invoked outside an SDK-managed SerDes call. + */ +public interface BinarySerDesStage { + /** + * Applies this transformation during forward serialization. + * + * @param value the non-null input bytes + * @param context the current durable payload context, or {@code null} outside SDK-managed calls + * @return the non-null transformed bytes + */ + byte[] serialize(byte[] value, SerDesContext context); + + /** + * Reverses this transformation during deserialization. + * + * @param data the non-null bytes produced by this transformation + * @param context the current durable payload context, or {@code null} outside SDK-managed calls + * @return the non-null bytes expected by the preceding transformation + */ + byte[] deserialize(byte[] data, SerDesContext context); +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/ComposableBinarySerDesStage.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/ComposableBinarySerDesStage.java new file mode 100644 index 000000000..bd4ef868f --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/ComposableBinarySerDesStage.java @@ -0,0 +1,183 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.exception.SerDesException; + +/** + * A string SerDes stage containing an ordered chain of binary transformations. + * + *

Serialization converts the input string with the starting codec, applies binary stages in declaration order, + * converts the final bytes to a string with the ending codec, and adds a versioned frame. Deserialization reverses the + * complete process when that frame is present and passes unrecognized input through unchanged. The context supplied to + * this string stage is forwarded unchanged to every binary stage. + */ +public final class ComposableBinarySerDesStage implements SerDesStage { + private static final String FRAME_MARKER = "__durable_execution_composable_binary_serdes:"; + private static final String FRAME_PREFIX = FRAME_MARKER + "1:"; + + private final StringBinaryCodec startingCodec; + private final List binaryStages; + private final StringBinaryCodec endingCodec; + + private ComposableBinarySerDesStage( + StringBinaryCodec startingCodec, List binaryStages, StringBinaryCodec endingCodec) { + this.startingCodec = startingCodec; + this.binaryStages = List.copyOf(binaryStages); + this.endingCodec = endingCodec; + } + + /** Creates a builder whose methods follow forward serialization order. */ + public static StartBuilder builder() { + return new Builder(); + } + + @Override + public String serialize(String value, SerDesContext context) { + Objects.requireNonNull(value, "value cannot be null"); + var current = invokeToBytes(startingCodec, value, "starting codec"); + for (int index = 0; index < binaryStages.size(); index++) { + current = invokeSerialize(binaryStages.get(index), current, context, index); + } + return FRAME_PREFIX + invokeFromBytes(endingCodec, current, "ending codec"); + } + + @Override + public String deserialize(String data, SerDesContext context) { + Objects.requireNonNull(data, "data cannot be null"); + if (!data.startsWith(FRAME_MARKER)) { + return data; + } + if (!data.startsWith(FRAME_PREFIX)) { + throw new SerDesException("Unsupported or malformed composable binary SerDes frame"); + } + var current = invokeToBytes(endingCodec, data.substring(FRAME_PREFIX.length()), "ending codec"); + for (int index = binaryStages.size() - 1; index >= 0; index--) { + current = invokeDeserialize(binaryStages.get(index), current, context, index); + } + return invokeFromBytes(startingCodec, current, "starting codec"); + } + + private static byte[] invokeToBytes(StringBinaryCodec codec, String value, String name) { + try { + return requireResult(codec.toBytes(value), name); + } catch (Throwable failure) { + throw componentFailure(name, "convert string to bytes", failure); + } + } + + private static String invokeFromBytes(StringBinaryCodec codec, byte[] data, String name) { + try { + return requireResult(codec.fromBytes(data), name); + } catch (Throwable failure) { + throw componentFailure(name, "convert bytes to string", failure); + } + } + + private static byte[] invokeSerialize(BinarySerDesStage stage, byte[] value, SerDesContext context, int index) { + try { + return requireResult(stage.serialize(value, context), binaryStageName(index, stage)); + } catch (Throwable failure) { + throw componentFailure(binaryStageName(index, stage), "serialize", failure); + } + } + + private static byte[] invokeDeserialize(BinarySerDesStage stage, byte[] data, SerDesContext context, int index) { + try { + return requireResult(stage.deserialize(data, context), binaryStageName(index, stage)); + } catch (Throwable failure) { + throw componentFailure(binaryStageName(index, stage), "deserialize", failure); + } + } + + private static T requireResult(T result, String component) { + if (result == null) { + throw new SerDesException(component + " returned null for non-null input"); + } + return result; + } + + private static String binaryStageName(int index, BinarySerDesStage stage) { + return String.format("binary stage %d (%s)", index, stage.getClass().getName()); + } + + private static RuntimeException componentFailure(String component, String action, Throwable failure) { + if (failure instanceof Error error) { + throw error; + } + var message = String.format("Composable binary SerDes stage %s failed to %s", component, action); + if (failure instanceof RetryableSerDesException) { + return new RetryableSerDesException(message, failure); + } + return new SerDesException(message, failure); + } + + /** Builder stage that requires the starting string/binary codec. */ + public interface StartBuilder { + /** + * Sets the codec that converts the input string to bytes during serialization. + * + * @param codec the starting boundary codec + * @return the binary-stage builder + */ + BinaryStagesBuilder startWith(StringBinaryCodec codec); + } + + /** Builder stage that accepts binary stages in processing order. */ + public interface BinaryStagesBuilder { + /** + * Appends a binary transformation. + * + * @param stage the binary stage + * @return this builder stage + */ + BinaryStagesBuilder then(BinarySerDesStage stage); + + /** + * Sets the codec that converts the final bytes to a string during serialization. + * + * @param codec the ending boundary codec + * @return the completed builder + */ + CompletedBuilder endWith(StringBinaryCodec codec); + } + + /** Builder stage that permits only construction of the completed binary pipeline. */ + public interface CompletedBuilder { + /** Returns the immutable string stage. */ + ComposableBinarySerDesStage build(); + } + + private static final class Builder implements StartBuilder, BinaryStagesBuilder, CompletedBuilder { + private StringBinaryCodec startingCodec; + private final List binaryStages = new ArrayList<>(); + private StringBinaryCodec endingCodec; + + @Override + public BinaryStagesBuilder startWith(StringBinaryCodec codec) { + startingCodec = Objects.requireNonNull(codec, "starting codec cannot be null"); + return this; + } + + @Override + public BinaryStagesBuilder then(BinarySerDesStage stage) { + binaryStages.add(Objects.requireNonNull(stage, "binary stage cannot be null")); + return this; + } + + @Override + public CompletedBuilder endWith(StringBinaryCodec codec) { + endingCodec = Objects.requireNonNull(codec, "ending codec cannot be null"); + return this; + } + + @Override + public ComposableBinarySerDesStage build() { + return new ComposableBinarySerDesStage(startingCodec, binaryStages, endingCodec); + } + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/ComposableSerDes.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/ComposableSerDes.java new file mode 100644 index 000000000..385ae8bd5 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/ComposableSerDes.java @@ -0,0 +1,190 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.exception.SerDesException; + +/** + * An immutable SerDes processing pipeline. + * + *

The first component is the value codec. Every later component is a {@link SerDesStage} that consumes and produces + * a string. Serialization runs from first to last; deserialization runs from last to first. Each stage returns + * unrecognized input unchanged, allowing raw values to pass through to the root value codec. SDK-managed calls pass the + * same {@link SerDesContext} explicitly to every stage. During serialization that context also exposes the original + * object supplied to the value codec. + */ +public final class ComposableSerDes implements SerDes { + private final SerDes valueCodec; + private final List stages; + + private ComposableSerDes(SerDes valueCodec, List stages) { + this.valueCodec = Objects.requireNonNull(valueCodec, "valueCodec cannot be null"); + this.stages = List.copyOf(stages); + } + + /** + * Creates a pipeline with a value codec followed by zero or more string stages. + * + * @param valueCodec the value codec + * @param remaining reversible string stages + * @return an immutable pipeline + */ + public static ComposableSerDes of(SerDes valueCodec, SerDesStage... remaining) { + Objects.requireNonNull(remaining, "remaining stages cannot be null"); + valueCodec = Objects.requireNonNull(valueCodec, "valueCodec cannot be null"); + var stages = new ArrayList(); + if (valueCodec instanceof ComposableSerDes composable) { + valueCodec = composable.valueCodec; + stages.addAll(composable.stages); + } + for (var stage : remaining) { + stages.add(Objects.requireNonNull(stage, "pipeline stage cannot be null")); + } + return new ComposableSerDes(valueCodec, stages); + } + + /** + * Creates a pipeline builder. + * + * @param valueCodec the value codec which converts values to and from strings + * @return a new builder + */ + public static Builder builder(SerDes valueCodec) { + return new Builder(valueCodec); + } + + /** Returns the value codec at the start of this pipeline. */ + public SerDes getValueCodec() { + return valueCodec; + } + + /** Returns a new pipeline with the supplied string stage appended. */ + @Override + public SerDes then(SerDesStage stage) { + var combined = new ArrayList<>(stages); + combined.add(Objects.requireNonNull(stage, "stage cannot be null")); + return new ComposableSerDes(valueCodec, combined); + } + + @Override + public String serialize(Object value) { + return serialize(value, null); + } + + String serialize(Object value, SerDesContext context) { + if (value == null) { + return null; + } + var stageContext = context == null ? null : context.withOriginalValue(value); + String current = invokeValueCodecSerialize(valueCodec, value); + for (int index = 0; index < stages.size(); index++) { + current = invokeStageSerialize(stages.get(index), current, stageContext, index + 1); + } + return current; + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + return deserialize(data, typeToken, null); + } + + T deserialize(String data, TypeToken typeToken, SerDesContext context) { + if (data == null) { + return null; + } + Objects.requireNonNull(typeToken, "typeToken cannot be null"); + var stageContext = context == null ? null : context.withOriginalValue(null); + String current = data; + for (int index = stages.size() - 1; index >= 0; index--) { + current = invokeStageDeserialize(stages.get(index), current, stageContext, index + 1); + } + return invokeValueCodecDeserialize(valueCodec, current, typeToken); + } + + private static String invokeStageSerialize(SerDesStage stage, String value, SerDesContext context, int index) { + try { + var result = stage.serialize(value, context); + if (result == null) { + throw new SerDesException("Stage returned null for a non-null value"); + } + return result; + } catch (Throwable failure) { + throw stageFailure(index, stage, "serialize", failure); + } + } + + private static String invokeStageDeserialize(SerDesStage stage, String data, SerDesContext context, int index) { + try { + var result = stage.deserialize(data, context); + if (result == null) { + throw new SerDesException("Stage returned null for non-null input"); + } + return result; + } catch (Throwable failure) { + throw stageFailure(index, stage, "deserialize", failure); + } + } + + private static String invokeValueCodecSerialize(SerDes valueCodec, Object value) { + try { + var result = valueCodec.serialize(value); + if (result == null) { + throw new SerDesException("Value codec returned null for a non-null value"); + } + return result; + } catch (Throwable failure) { + throw stageFailure(0, valueCodec, "serialize", failure); + } + } + + private static T invokeValueCodecDeserialize(SerDes valueCodec, String data, TypeToken typeToken) { + try { + return valueCodec.deserialize(data, typeToken); + } catch (Throwable failure) { + throw stageFailure(0, valueCodec, "deserialize", failure); + } + } + + private static RuntimeException stageFailure(int index, Object stage, String action, Throwable failure) { + if (failure instanceof Error error) { + throw error; + } + var message = String.format( + "SerDes pipeline stage %d (%s) failed to %s", + index, stage.getClass().getName(), action); + if (failure instanceof RetryableSerDesException) { + return new RetryableSerDesException(message, failure); + } + return new SerDesException(message, failure); + } + + /** Builder for an immutable {@link ComposableSerDes}. */ + public static final class Builder { + private SerDes valueCodec; + private final List stages = new ArrayList<>(); + + private Builder(SerDes valueCodec) { + this.valueCodec = Objects.requireNonNull(valueCodec, "valueCodec cannot be null"); + if (valueCodec instanceof ComposableSerDes composable) { + this.valueCodec = composable.valueCodec; + stages.addAll(composable.stages); + } + } + + /** Appends a reversible string stage. */ + public Builder then(SerDesStage stage) { + stages.add(Objects.requireNonNull(stage, "stage cannot be null")); + return this; + } + + /** Returns the immutable pipeline. */ + public ComposableSerDes build() { + return new ComposableSerDes(valueCodec, stages); + } + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/RetryBinarySerDesStage.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/RetryBinarySerDesStage.java new file mode 100644 index 000000000..301791ce9 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/RetryBinarySerDesStage.java @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import java.util.Objects; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.retry.RetryStrategy; + +/** + * A binary-stage decorator that retries transient failures from another {@link BinarySerDesStage}. + * + *

Only {@link RetryableSerDesException} is retried. Other failures are propagated immediately. Retry delays block + * the thread executing the SerDes call: the caller by default or the configured SerDes executor thread. Every attempt + * receives the same {@link SerDesContext} supplied to this decorator and a fresh copy of the original input bytes. + */ +public final class RetryBinarySerDesStage implements BinarySerDesStage { + private final BinarySerDesStage delegate; + private final SerDesRetryExecutor retryExecutor; + + /** + * Creates a retrying binary-stage decorator. + * + * @param delegate the stage to invoke + * @param retryStrategy strategy that controls attempts and delays + */ + public RetryBinarySerDesStage(BinarySerDesStage delegate, RetryStrategy retryStrategy) { + this(delegate, retryStrategy, SerDesRetryExecutor.DEFAULT_SLEEPER); + } + + RetryBinarySerDesStage( + BinarySerDesStage delegate, RetryStrategy retryStrategy, SerDesRetryExecutor.Sleeper sleeper) { + this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null"); + retryExecutor = new SerDesRetryExecutor(retryStrategy, sleeper); + } + + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + var snapshot = Objects.requireNonNull(value, "value cannot be null").clone(); + return retryExecutor.execute("binary stage serialization", () -> delegate.serialize(snapshot.clone(), context)); + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + var snapshot = Objects.requireNonNull(data, "data cannot be null").clone(); + return retryExecutor.execute( + "binary stage deserialization", () -> delegate.deserialize(snapshot.clone(), context)); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/RetrySerDesStage.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/RetrySerDesStage.java new file mode 100644 index 000000000..82698e901 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/RetrySerDesStage.java @@ -0,0 +1,44 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import java.util.Objects; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.retry.RetryStrategy; + +/** + * A string-stage decorator that retries transient failures from another {@link SerDesStage}. + * + *

Only {@link RetryableSerDesException} is retried. Other failures are propagated immediately. Retry delays block + * the thread executing the SerDes call: the caller by default or the configured SerDes executor thread. Every attempt + * receives the same {@link SerDesContext} supplied to this decorator. + */ +public final class RetrySerDesStage implements SerDesStage { + private final SerDesStage delegate; + private final SerDesRetryExecutor retryExecutor; + + /** + * Creates a retrying string-stage decorator. + * + * @param delegate the stage to invoke + * @param retryStrategy strategy that controls attempts and delays + */ + public RetrySerDesStage(SerDesStage delegate, RetryStrategy retryStrategy) { + this(delegate, retryStrategy, SerDesRetryExecutor.DEFAULT_SLEEPER); + } + + RetrySerDesStage(SerDesStage delegate, RetryStrategy retryStrategy, SerDesRetryExecutor.Sleeper sleeper) { + this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null"); + retryExecutor = new SerDesRetryExecutor(retryStrategy, sleeper); + } + + @Override + public String serialize(String value, SerDesContext context) { + return retryExecutor.execute("string stage serialization", () -> delegate.serialize(value, context)); + } + + @Override + public String deserialize(String data, SerDesContext context) { + return retryExecutor.execute("string stage deserialization", () -> delegate.deserialize(data, context)); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDes.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDes.java index b8f39e1c1..4d7c9f14b 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDes.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDes.java @@ -5,9 +5,10 @@ import software.amazon.lambda.durable.TypeToken; /** - * Interface for serialization and deserialization of objects. + * Interface for serialization and deserialization of objects at the persisted string boundary. * - *

Implementations must support both simple types via {@link Class} and complex generic types via {@link TypeToken}. + *

A {@link ComposableSerDes} starts with one SerDes value codec and may be followed by {@link SerDesStage} + * transformations that consume and produce strings. */ public interface SerDes { /** @@ -36,4 +37,14 @@ public interface SerDes { * @return the deserialized object, or null if data is null */ T deserialize(String data, TypeToken typeToken); + + /** + * Returns an immutable processing pipeline with a string stage appended. + * + * @param nextStage the reversible string stage to append + * @return a SerDes backed by an immutable processing pipeline + */ + default SerDes then(SerDesStage nextStage) { + return ComposableSerDes.builder(this).then(nextStage).build(); + } } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesContext.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesContext.java new file mode 100644 index 000000000..fdb371b3f --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesContext.java @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import software.amazon.awssdk.services.lambda.model.OperationType; +import software.amazon.lambda.durable.model.OperationSubType; + +/** + * Describes the durable payload currently being processed by a {@link SerDesStage} or {@link BinarySerDesStage}. + * + *

The SDK passes this context explicitly only to pipeline stages. Root {@link SerDes} value codecs remain + * context-free. During serialization, {@link #originalValue()} contains the object supplied to the root value codec; + * during deserialization it is {@code null}. Stages must treat the original value as read-only. + */ +public record SerDesContext( + String durableExecutionArn, + String entityId, + SerDesPayloadKind payloadKind, + String operationId, + String operationName, + String parentId, + OperationType operationType, + OperationSubType operationSubType, + Integer attempt, + Object originalValue) { + + /** Creates context for a root execution payload. */ + public static SerDesContext forExecution( + String durableExecutionArn, + String executionOperationId, + String executionOperationName, + SerDesPayloadKind payloadKind) { + return new SerDesContext( + durableExecutionArn, + "execution/" + executionOperationId + "/" + payloadKind.getEntitySuffix(), + payloadKind, + executionOperationId, + executionOperationName, + null, + OperationType.EXECUTION, + null, + null, + null); + } + + /** Creates context for an operation payload. */ + public static SerDesContext forOperation( + String durableExecutionArn, + String operationId, + String operationName, + String parentId, + OperationType operationType, + OperationSubType operationSubType, + SerDesPayloadKind payloadKind, + Integer attempt) { + var entityId = "operation/" + operationId + "/" + payloadKind.getEntitySuffix(); + if (attempt != null) { + entityId += "/attempt-" + attempt; + } + return new SerDesContext( + durableExecutionArn, + entityId, + payloadKind, + operationId, + operationName, + parentId, + operationType, + operationSubType, + attempt, + null); + } + + SerDesContext withOriginalValue(Object originalValue) { + if (this.originalValue == originalValue) { + return this; + } + return new SerDesContext( + durableExecutionArn, + entityId, + payloadKind, + operationId, + operationName, + parentId, + operationType, + operationSubType, + attempt, + originalValue); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesPayloadKind.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesPayloadKind.java new file mode 100644 index 000000000..098e2b565 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesPayloadKind.java @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +/** Identifies the durable payload being serialized or deserialized. */ +public enum SerDesPayloadKind { + INPUT("input"), + OUTPUT("output"), + RESULT("result"), + INVOKE_PAYLOAD("invoke-payload"), + STATE("state"), + EXCEPTION("exception"); + + private final String entitySuffix; + + SerDesPayloadKind(String entitySuffix) { + this.entitySuffix = entitySuffix; + } + + /** Returns the stable suffix used in external payload entity identifiers. */ + public String getEntitySuffix() { + return entitySuffix; + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesRetryExecutor.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesRetryExecutor.java new file mode 100644 index 000000000..aaf014ff3 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesRetryExecutor.java @@ -0,0 +1,87 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.exception.SerDesException; +import software.amazon.lambda.durable.retry.RetryDecision; +import software.amazon.lambda.durable.retry.RetryStrategy; + +final class SerDesRetryExecutor { + static final Sleeper DEFAULT_SLEEPER = delay -> { + if (delay.getSeconds() > 0) { + TimeUnit.SECONDS.sleep(delay.getSeconds()); + } + if (delay.getNano() > 0) { + TimeUnit.NANOSECONDS.sleep(delay.getNano()); + } + }; + + private final RetryStrategy retryStrategy; + private final Sleeper sleeper; + + SerDesRetryExecutor(RetryStrategy retryStrategy, Sleeper sleeper) { + this.retryStrategy = Objects.requireNonNull(retryStrategy, "retryStrategy cannot be null"); + this.sleeper = Objects.requireNonNull(sleeper, "sleeper cannot be null"); + } + + T execute(String action, Supplier operation) { + int attempt = 1; + while (true) { + try { + return operation.get(); + } catch (RetryableSerDesException failure) { + var decision = makeRetryDecision(action, failure, attempt); + if (!decision.shouldRetry()) { + throw failure; + } + waitForRetry(action, failure, attempt, decision.delay()); + attempt++; + } + } + } + + private RetryDecision makeRetryDecision(String action, RetryableSerDesException failure, int attempt) { + try { + var decision = retryStrategy.makeRetryDecision(failure, attempt); + if (decision == null) { + throw new SerDesException( + String.format("Retry strategy returned null for SerDes %s attempt %d", action, attempt)); + } + return decision; + } catch (SerDesException e) { + throw e; + } catch (RuntimeException e) { + throw new SerDesException( + String.format("Retry strategy failed for SerDes %s attempt %d", action, attempt), e); + } + } + + private void waitForRetry(String action, RetryableSerDesException failure, int attempt, Duration delay) { + if (delay == null || delay.isNegative()) { + throw new SerDesException(String.format( + "Retry strategy returned an invalid delay for SerDes %s attempt %d", action, attempt)); + } + if (delay.isZero()) { + return; + } + try { + sleeper.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + var interrupted = new SerDesException( + String.format("Interrupted while waiting to retry SerDes %s after attempt %d", action, attempt), e); + interrupted.addSuppressed(failure); + throw interrupted; + } + } + + @FunctionalInterface + interface Sleeper { + void sleep(Duration delay) throws InterruptedException; + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesRunner.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesRunner.java new file mode 100644 index 000000000..c1f55b959 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesRunner.java @@ -0,0 +1,224 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import java.lang.ref.WeakReference; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.function.Supplier; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.exception.SerDesException; +import software.amazon.lambda.durable.util.ExceptionHelper; + +/** + * Runs customer SerDes calls and passes {@link SerDesContext} explicitly to composable pipeline stages. + * + *

Calls execute inline unless an executor is configured. Instances are invocation-scoped so successful + * deserialization results are cached only for one Lambda invocation. Completed values use a bounded weak-reference + * cache, while concurrent calls for the same value share one in-flight deserialization. + */ +public final class SerDesRunner { + static final int MAX_COMPLETED_DESERIALIZATIONS = 256; + private static final Object CACHE_MISS = new Object(); + private static final Object NULL_VALUE = new Object(); + + private final ExecutorService executorService; + private final Map> inFlightDeserializations = new ConcurrentHashMap<>(); + private final Map> completedDeserializations = + Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + return size() > MAX_COMPLETED_DESERIALIZATIONS; + } + }); + + /** + * Creates an invocation-scoped runner. + * + * @param executorService executor for SerDes calls, or {@code null} to execute inline + */ + public SerDesRunner(ExecutorService executorService) { + this.executorService = executorService; + } + + /** Serializes a value with the supplied durable payload context. */ + public String serialize(SerDes serDes, Object value, SerDesContext context) { + Objects.requireNonNull(serDes, "serDes cannot be null"); + return run("serialize", context, () -> serializeWithContext(serDes, value, context)); + } + + /** Deserializes a value with invocation-scoped caching. */ + @SuppressWarnings("unchecked") + public T deserialize(SerDes serDes, String data, TypeToken typeToken, SerDesContext context) { + Objects.requireNonNull(serDes, "serDes cannot be null"); + Objects.requireNonNull(typeToken, "typeToken cannot be null"); + Objects.requireNonNull(context, "SerDesContext cannot be null"); + var key = new CacheKey( + serDes, + context.durableExecutionArn(), + context.entityId(), + context.payloadKind(), + context.attempt(), + typeToken, + hash(data)); + var cached = getCompleted(key); + if (cached != CACHE_MISS) { + return (T) unmaskNull(cached); + } + + var pending = new CompletableFuture(); + var existing = inFlightDeserializations.putIfAbsent(key, pending); + if (existing != null) { + return (T) unmaskNull(join(existing)); + } + + try { + // A deserialization may have completed between the first cache lookup and this caller claiming + // the in-flight slot. + cached = getCompleted(key); + if (cached != CACHE_MISS) { + pending.complete(cached); + return (T) unmaskNull(cached); + } + + T value = run("deserialize", context, () -> deserializeWithContext(serDes, data, typeToken, context)); + var cacheValue = maskNull(value); + putCompleted(key, cacheValue); + pending.complete(cacheValue); + return value; + } catch (Throwable failure) { + pending.completeExceptionally(failure); + ExceptionHelper.sneakyThrow(failure); + return null; + } finally { + inFlightDeserializations.remove(key, pending); + } + } + + private Object getCompleted(CacheKey key) { + synchronized (completedDeserializations) { + var reference = completedDeserializations.get(key); + if (reference == null) { + return CACHE_MISS; + } + var value = reference.get(); + if (value == null) { + completedDeserializations.remove(key); + return CACHE_MISS; + } + return value; + } + } + + private void putCompleted(CacheKey key, Object value) { + completedDeserializations.put(key, new WeakReference<>(value)); + } + + private static Object maskNull(Object value) { + return value == null ? NULL_VALUE : value; + } + + private static Object unmaskNull(Object value) { + return value == NULL_VALUE ? null : value; + } + + private static String serializeWithContext(SerDes serDes, Object value, SerDesContext context) { + if (serDes instanceof ComposableSerDes composable) { + return composable.serialize(value, context); + } + return serDes.serialize(value); + } + + private static T deserializeWithContext( + SerDes serDes, String data, TypeToken typeToken, SerDesContext context) { + if (serDes instanceof ComposableSerDes composable) { + return composable.deserialize(data, typeToken, context); + } + return serDes.deserialize(data, typeToken); + } + + private T run(String action, SerDesContext context, Supplier supplier) { + Objects.requireNonNull(supplier, "supplier cannot be null"); + Objects.requireNonNull(context, "SerDesContext cannot be null"); + try { + if (executorService == null) { + return supplier.get(); + } + return CompletableFuture.supplyAsync(supplier, executorService).join(); + } catch (Throwable throwable) { + var cause = ExceptionHelper.unwrapCompletableFuture(throwable); + if (cause instanceof Error error) { + throw error; + } + var message = String.format( + "Failed to %s %s payload for entity '%s'", action, context.payloadKind(), context.entityId()); + if (cause instanceof RetryableSerDesException) { + throw new RetryableSerDesException(message, cause); + } + throw new SerDesException(message, cause); + } + } + + private static Object join(CompletableFuture future) { + try { + return future.join(); + } catch (Throwable failure) { + ExceptionHelper.sneakyThrow(ExceptionHelper.unwrapCompletableFuture(failure)); + return null; + } + } + + private static String hash(String data) { + if (data == null) { + return "null"; + } + try { + var digest = MessageDigest.getInstance("SHA-256").digest(data.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + private record CacheKey( + SerDes serDes, + String durableExecutionArn, + String entityId, + SerDesPayloadKind payloadKind, + Integer attempt, + TypeToken typeToken, + String serializedHash) { + @Override + public boolean equals(Object other) { + return other instanceof CacheKey that + && serDes == that.serDes + && Objects.equals(durableExecutionArn, that.durableExecutionArn) + && Objects.equals(entityId, that.entityId) + && payloadKind == that.payloadKind + && Objects.equals(attempt, that.attempt) + && Objects.equals(typeToken, that.typeToken) + && Objects.equals(serializedHash, that.serializedHash); + } + + @Override + public int hashCode() { + int result = System.identityHashCode(serDes); + result = 31 * result + Objects.hashCode(durableExecutionArn); + result = 31 * result + Objects.hashCode(entityId); + result = 31 * result + Objects.hashCode(payloadKind); + result = 31 * result + Objects.hashCode(attempt); + result = 31 * result + Objects.hashCode(typeToken); + return 31 * result + Objects.hashCode(serializedHash); + } + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesStage.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesStage.java new file mode 100644 index 000000000..fc4dcedfc --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/SerDesStage.java @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +/** + * A reversible string stage in a {@link ComposableSerDes} pipeline. + * + *

Every top-level stage consumes and produces a string, so stages can be composed in any order without intermediate + * type mismatches. Use {@link ComposableBinarySerDesStage} to perform an efficient chain of binary transformations + * inside one string stage. + * + *

A stage must serialize values into a self-identifying format. During deserialization, it must: + * + *

    + *
  • reverse input that is in its recognized, valid format; + *
  • throw a SerDes failure when input identifies itself as this stage's format but is malformed or unsupported; + *
  • return input unchanged when it is not in this stage's format. + *
+ * + *

This pass-through behavior allows raw external payloads to traverse a configured pipeline and reach its root value + * codec without special pipeline control flow. Implementations should inspect an explicit marker or versioned envelope + * before decoding rather than treating any successfully decodable value as recognized. + * + *

The SDK passes the durable payload context explicitly to every stage invocation. During serialization, + * {@link SerDesContext#originalValue()} is the object supplied to the root value codec. During deserialization it is + * {@code null}. Stages must treat the original value as read-only. The context itself may be {@code null} only when a + * pipeline or stage is invoked directly outside an SDK-managed SerDes call. + */ +public interface SerDesStage { + /** + * Applies this stage during forward serialization. + * + * @param value the non-null input string + * @param context the current durable payload context including the original value, or {@code null} outside + * SDK-managed calls + * @return the non-null transformed string + */ + String serialize(String value, SerDesContext context); + + /** + * Reverses this stage during deserialization. + * + *

If {@code data} is not in this stage's self-identifying format, implementations must return it unchanged. If + * it identifies this stage's format but is malformed or unsupported, implementations must throw a SerDes failure. + * + * @param data the non-null input string + * @param context the current durable payload context with a {@code null} original value, or {@code null} outside + * SDK-managed calls + * @return the non-null string expected by the preceding stage, or {@code data} unchanged when unrecognized + */ + String deserialize(String data, SerDesContext context); +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/StringBinaryCodec.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/StringBinaryCodec.java new file mode 100644 index 000000000..92315ccb2 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/StringBinaryCodec.java @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +/** + * A reversible conversion between strings and bytes at a {@link ComposableBinarySerDesStage} boundary. + * + *

The neutral {@code toBytes}/{@code fromBytes} names allow the same contract to be used at both ends of the binary + * processing chain. + */ +public interface StringBinaryCodec { + /** + * Converts a non-null string to bytes. + * + * @param value the string value + * @return the non-null byte representation + */ + byte[] toBytes(String value); + + /** + * Converts non-null bytes to a string. + * + * @param data the byte representation + * @return the non-null string value + */ + String fromBytes(byte[] data); +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/Utf8StringBinaryCodec.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/Utf8StringBinaryCodec.java new file mode 100644 index 000000000..1d78fe42a --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/Utf8StringBinaryCodec.java @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import software.amazon.lambda.durable.exception.SerDesException; + +/** Converts strings to and from UTF-8 bytes. */ +public final class Utf8StringBinaryCodec implements StringBinaryCodec { + public static final Utf8StringBinaryCodec INSTANCE = new Utf8StringBinaryCodec(); + + private Utf8StringBinaryCodec() {} + + @Override + public byte[] toBytes(String value) { + var encoder = StandardCharsets.UTF_8 + .newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + var encoded = encoder.encode(CharBuffer.wrap(value)); + var result = new byte[encoded.remaining()]; + encoded.get(result); + return result; + } catch (CharacterCodingException e) { + throw new SerDesException("Failed to encode string as UTF-8", e); + } + } + + @Override + public String fromBytes(byte[] data) { + var decoder = StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + return decoder.decode(ByteBuffer.wrap(data)).toString(); + } catch (CharacterCodingException e) { + throw new SerDesException("Failed to decode UTF-8 bytes", e); + } + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FieldMatchMode.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FieldMatchMode.java new file mode 100644 index 000000000..ec07cf52c --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FieldMatchMode.java @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.filesystem; + +/** Controls how a {@link PreviewField} matches a field in a structured value. */ +public enum FieldMatchMode { + /** Matches the field name at any depth in the object tree. */ + ANYWHERE, + + /** Matches the exact dot-separated path from the root object. */ + PATH +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FileSystemPathEncoding.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FileSystemPathEncoding.java new file mode 100644 index 000000000..4e4c4e41b --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FileSystemPathEncoding.java @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.filesystem; + +/** Controls how durable execution and entity identifiers are encoded as filesystem paths. */ +public enum FileSystemPathEncoding { + URI, + HASH +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FileSystemSerDesStage.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FileSystemSerDesStage.java new file mode 100644 index 000000000..de5e05df7 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FileSystemSerDesStage.java @@ -0,0 +1,802 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.filesystem; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.file.DirectoryStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.SecureDirectoryStream; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiFunction; +import java.util.regex.Pattern; +import software.amazon.awssdk.services.lambda.model.OperationType; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.exception.SerDesException; +import software.amazon.lambda.durable.serde.ComposableBinarySerDesStage; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.SerDesStage; +import software.amazon.lambda.durable.serde.Utf8StringBinaryCodec; + +/** + * A string stage that stores payloads on a durable shared filesystem. + * + *

Do not use Lambda's ephemeral {@code /tmp} storage. Use a durable shared mount such as EFS, or S3 Files only when + * its synchronization and crash-durability tradeoffs are acceptable for the workload. + * + *

Payload files are immutable and created with a single {@code CREATE_NEW} write. Publication does not require hard + * links or renames, so the write path is compatible with S3 Files. + * + *

The mounted filesystem provider must support {@link SecureDirectoryStream}. The stage traverses relative directory + * handles with symbolic-link following disabled and keeps those handles open through file I/O, preventing a checked + * path from being redirected between validation and access. + * + *

Every filesystem envelope includes a SHA-256 payload digest. Deserialization verifies inline values and file + * contents against that digest, and file paths must contain the same digest. + * + *

Deserialization recognizes the reserved filesystem envelope marker. Input without that marker is returned + * unchanged; input with the marker must be a valid supported envelope. Filesystem operations use the explicit + * {@link SerDesContext} stage parameter for durable payload identity. + */ +public final class FileSystemSerDesStage implements SerDesStage { + private static final String ENVELOPE_MARKER = "__durable_execution_filesystem_serdes"; + private static final String PAYLOAD_DIGEST_FIELD = "payloadDigest"; + private static final String PAYLOAD_TYPE_FIELD = "payloadType"; + private static final int ENVELOPE_VERSION = 1; + private static final int DEFAULT_CHECKPOINT_ENVELOPE_LIMIT_BYTES = 256 * 1024 - 1024; + private static final ObjectMapper ENVELOPE_MAPPER = new ObjectMapper(); + private static final ObjectReader ENVELOPE_READER = + ENVELOPE_MAPPER.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + private static final Pattern DURABLE_EXECUTION_ARN_PATTERN = Pattern.compile( + "^arn:[^:]*:lambda:[^:]*:[^:]*:function:([^:/]+):[^:/]+/durable-execution/([^/]+)/([^/]+)$"); + private static final Pattern SHA_256_DIGEST_PATTERN = Pattern.compile("[0-9a-f]{64}"); + + private final Path basePath; + private final FileSystemStorageMode storageMode; + private final FileSystemPathEncoding pathEncoding; + private final int checkpointEnvelopeLimitBytes; + private final BiFunction> previewGenerator; + + private FileSystemSerDesStage(Builder builder) { + basePath = builder.basePath.toAbsolutePath().normalize(); + storageMode = builder.storageMode; + pathEncoding = builder.pathEncoding; + checkpointEnvelopeLimitBytes = builder.checkpointEnvelopeLimitBytes; + previewGenerator = builder.previewGenerator; + } + + /** + * Creates a filesystem stage builder for use after a value codec in a composable SerDes pipeline. + * + *

Use {@link ComposableBinarySerDesStage} before this stage when binary transformations are required. + * + * @param basePath durable shared filesystem root + * @return a filesystem stage builder + */ + public static Builder builder(Path basePath) { + return new Builder(basePath); + } + + @Override + public String serialize(String value, SerDesContext context) { + if (value == null) { + return null; + } + context = requireContext(context); + var payload = SerializedPayload.fromString(value); + var payloadDigest = sha256(payload.data()); + if (storageMode == FileSystemStorageMode.OVERFLOW) { + var inlineEnvelope = encodeEnvelope(payload, payloadDigest, null, null, context); + if (fitsCheckpoint(inlineEnvelope)) { + return inlineEnvelope; + } + } + + var file = resolvePayloadPath(payloadDigest, context); + var preview = generatePreview(value, context); + var fileEnvelope = encodeEnvelope(payload.withoutData(), payloadDigest, file, preview, context); + if (!fitsCheckpoint(fileEnvelope)) { + throw new SerDesException("Filesystem SerDes envelope exceeds the checkpoint payload limit for entity '" + + context.entityId() + + "'"); + } + try { + writePayload(payload, file); + return fileEnvelope; + } catch (IOException e) { + throw new RetryableSerDesException( + "Failed to store filesystem payload for entity '" + context.entityId() + "'", e); + } + } + + @Override + public String deserialize(String data, SerDesContext context) { + if (data == null) { + return null; + } + return resolveSerializedPayload(data, context); + } + + private String resolveSerializedPayload(String data, SerDesContext context) { + final JsonNode envelope; + try { + envelope = ENVELOPE_READER.readTree(data); + } catch (JsonProcessingException e) { + if (containsFilesystemMarkerField(data)) { + throw malformedEnvelope(requireContext(context), e); + } + return data; + } + + if (!hasFilesystemMarker(envelope)) { + return data; + } + context = requireContext(context); + var marker = envelope.get(ENVELOPE_MARKER); + if (!marker.isIntegralNumber()) { + throw malformedEnvelope(context, null); + } + if (!marker.canConvertToInt() || marker.intValue() != ENVELOPE_VERSION) { + throw unsupportedEnvelopeVersion(context, marker.asText()); + } + if (!isFilesystemEnvelope(envelope)) { + throw malformedEnvelope(context, null); + } + + var hasData = envelope.has("data") && envelope.get("data").isTextual(); + var hasFile = envelope.has("file") && envelope.get("file").isTextual(); + var payloadType = payloadType(envelope, context); + var payloadDigest = payloadDigest(envelope, context); + var owner = payloadOwner(envelope, context); + if (hasData) { + try { + var payload = SerializedPayload.fromInlineValue( + payloadType, envelope.get("data").textValue()); + verifyPayloadDigest(payload, payloadDigest, context); + return payload.value(); + } catch (IllegalArgumentException e) { + throw malformedEnvelope(context, e); + } + } + return readPayload(envelope.get("file").textValue(), payloadType, payloadDigest, owner, context) + .value(); + } + + private SerializedPayload readPayload( + String fileValue, + PayloadType payloadType, + String payloadDigest, + PayloadOwner owner, + SerDesContext context) { + var file = basePath.getFileSystem().getPath(fileValue).toAbsolutePath().normalize(); + validatePayloadPath(file, owner); + var expectedFileName = payloadFileName(payloadDigest, owner.entityId()); + if (!matchesPublishedPayloadFileName(file.getFileName().toString(), expectedFileName)) { + throw new SerDesException("Filesystem SerDes file path does not match its payload digest"); + } + try { + byte[] storedData; + try (var directory = openSecureDirectory(file.getParent(), false); + var channel = directory + .directory() + .newByteChannel( + file.getFileName(), Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)); + var input = Channels.newInputStream(channel)) { + storedData = input.readAllBytes(); + } + var serialized = new SerializedPayload(payloadType, storedData); + verifyPayloadDigest(serialized, payloadDigest, context); + return serialized; + } catch (IOException e) { + throw new RetryableSerDesException( + "Failed to load filesystem payload for entity '" + context.entityId() + "'", e); + } + } + + private void validatePayloadPath(Path file, PayloadOwner owner) { + var expectedDirectory = resolveExecutionDirectory(owner.durableExecutionArn()); + var fileName = file.getFileName(); + if (fileName == null + || file.getParent() == null + || !file.getParent().equals(expectedDirectory) + || !fileName.toString() + .matches(Pattern.quote(encode(owner.entityId())) + + "-[0-9a-f]{64}(?:-[A-Za-z0-9_-]+)?\\.payload")) { + throw new SerDesException("Filesystem SerDes file is not valid for its declared durable entity"); + } + } + + private static PayloadType payloadType(JsonNode envelope, SerDesContext context) { + var node = envelope.get(PAYLOAD_TYPE_FIELD); + if (node == null || !node.isTextual()) { + throw malformedEnvelope(context, null); + } + try { + return PayloadType.valueOf(node.textValue()); + } catch (IllegalArgumentException e) { + throw malformedEnvelope(context, e); + } + } + + private static String payloadDigest(JsonNode envelope, SerDesContext context) { + var node = envelope.get(PAYLOAD_DIGEST_FIELD); + if (node == null + || !node.isTextual() + || !SHA_256_DIGEST_PATTERN.matcher(node.textValue()).matches()) { + throw malformedEnvelope(context, null); + } + return node.textValue(); + } + + private static void verifyPayloadDigest(SerializedPayload payload, String expectedDigest, SerDesContext context) { + if (!sha256(payload.data()).equals(expectedDigest)) { + throw new SerDesException("Filesystem SerDes payload digest does not match stored content for entity '" + + context.entityId() + + "'"); + } + } + + private static PayloadOwner payloadOwner(JsonNode envelope, SerDesContext context) { + var hasOwnerArn = envelope.has("ownerDurableExecutionArn") + && envelope.get("ownerDurableExecutionArn").isTextual(); + var hasOwnerEntity = + envelope.has("ownerEntityId") && envelope.get("ownerEntityId").isTextual(); + if (!hasOwnerArn || !hasOwnerEntity) { + throw malformedEnvelope(context, null); + } + + var owner = new PayloadOwner( + envelope.get("ownerDurableExecutionArn").textValue(), + envelope.get("ownerEntityId").textValue()); + if (owner.durableExecutionArn().isBlank() || owner.entityId().isBlank()) { + throw malformedEnvelope(context, null); + } + + var sameOwner = owner.durableExecutionArn().equals(context.durableExecutionArn()) + && owner.entityId().equals(context.entityId()); + if (!sameOwner && !acceptsCrossExecutionReference(context)) { + throw new SerDesException("Filesystem SerDes file belongs to a different durable entity"); + } + return owner; + } + + private static boolean acceptsCrossExecutionReference(SerDesContext context) { + return context.payloadKind() == SerDesPayloadKind.INPUT + || context.operationType() == OperationType.CHAINED_INVOKE; + } + + private static boolean isFilesystemEnvelope(JsonNode envelope) { + if (envelope == null + || !envelope.isObject() + || !envelope.has(ENVELOPE_MARKER) + || !envelope.get(ENVELOPE_MARKER).isIntegralNumber() + || !envelope.get(ENVELOPE_MARKER).canConvertToInt() + || envelope.get(ENVELOPE_MARKER).intValue() != ENVELOPE_VERSION + || !envelope.has("ownerDurableExecutionArn") + || !envelope.get("ownerDurableExecutionArn").isTextual() + || envelope.get("ownerDurableExecutionArn").textValue().isBlank() + || !envelope.has("ownerEntityId") + || !envelope.get("ownerEntityId").isTextual() + || envelope.get("ownerEntityId").textValue().isBlank() + || !envelope.has(PAYLOAD_TYPE_FIELD) + || !envelope.get(PAYLOAD_TYPE_FIELD).isTextual() + || !isPayloadType(envelope.get(PAYLOAD_TYPE_FIELD).textValue()) + || !envelope.has(PAYLOAD_DIGEST_FIELD) + || !envelope.get(PAYLOAD_DIGEST_FIELD).isTextual() + || !SHA_256_DIGEST_PATTERN + .matcher(envelope.get(PAYLOAD_DIGEST_FIELD).textValue()) + .matches()) { + return false; + } + + var hasData = envelope.has("data") && envelope.get("data").isTextual(); + var hasFile = envelope.has("file") && envelope.get("file").isTextual(); + if (hasData == hasFile) { + return false; + } + + var hasPreview = envelope.has("preview"); + if (hasPreview && (hasData || !envelope.get("preview").isObject())) { + return false; + } + return envelope.size() == (hasPreview ? 7 : 6); + } + + private static boolean isPayloadType(String value) { + try { + PayloadType.valueOf(value); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + private static boolean hasFilesystemMarker(JsonNode envelope) { + return envelope != null && envelope.isObject() && envelope.has(ENVELOPE_MARKER); + } + + private static boolean containsFilesystemMarkerField(String data) { + var index = 0; + while (index < data.length() && Character.isWhitespace(data.charAt(index))) { + index++; + } + if (index == data.length() || data.charAt(index) != '{') { + return false; + } + + var containerDepth = 1; + for (index++; index < data.length() && containerDepth > 0; index++) { + var current = data.charAt(index); + if (current == '{' || current == '[') { + containerDepth++; + } else if (current == '}' || current == ']') { + containerDepth--; + } else if (current == '"') { + var literalStart = index; + var valueStart = index + 1; + var escaped = false; + while (++index < data.length()) { + current = data.charAt(index); + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == '"') { + break; + } + } + if (index == data.length()) { + return false; + } + + var delimiter = index + 1; + while (delimiter < data.length() && Character.isWhitespace(data.charAt(delimiter))) { + delimiter++; + } + if (containerDepth == 1 + && delimiter < data.length() + && data.charAt(delimiter) == ':' + && isFilesystemMarkerLiteral(data, literalStart, valueStart, index)) { + return true; + } + } + } + return false; + } + + private static boolean isFilesystemMarkerLiteral(String data, int literalStart, int valueStart, int literalEnd) { + if (literalEnd - valueStart == ENVELOPE_MARKER.length() + && data.regionMatches(valueStart, ENVELOPE_MARKER, 0, ENVELOPE_MARKER.length())) { + return true; + } + try { + return ENVELOPE_MARKER.equals( + ENVELOPE_MAPPER.readValue(data.substring(literalStart, literalEnd + 1), String.class)); + } catch (JsonProcessingException ignored) { + return false; + } + } + + private static SerDesException malformedEnvelope(SerDesContext context, Throwable cause) { + var message = "Invalid filesystem SerDes envelope for entity '" + context.entityId() + "'"; + return cause == null ? new SerDesException(message) : new SerDesException(message, cause); + } + + private static SerDesException unsupportedEnvelopeVersion(SerDesContext context, String version) { + return new SerDesException("Unsupported filesystem SerDes envelope version " + + version + + " for entity '" + + context.entityId() + + "'"); + } + + private String encodeEnvelope( + SerializedPayload payload, + String payloadDigest, + Path file, + Map preview, + SerDesContext context) { + var envelope = new LinkedHashMap(); + envelope.put(ENVELOPE_MARKER, ENVELOPE_VERSION); + envelope.put("ownerDurableExecutionArn", context.durableExecutionArn()); + envelope.put("ownerEntityId", context.entityId()); + envelope.put(PAYLOAD_TYPE_FIELD, payload.type().name()); + envelope.put(PAYLOAD_DIGEST_FIELD, payloadDigest); + if (payload.hasData()) { + envelope.put("data", payload.inlineValue()); + } else { + envelope.put("file", file.toString()); + if (preview != null) { + envelope.put("preview", preview); + } + } + try { + return ENVELOPE_MAPPER.writeValueAsString(envelope); + } catch (JsonProcessingException e) { + throw new SerDesException( + "Failed to encode filesystem payload envelope for entity '" + context.entityId() + "'", e); + } + } + + private Map generatePreview(String value, SerDesContext context) { + if (previewGenerator == null) { + return null; + } + try { + return previewGenerator.apply(value, context); + } catch (RetryableSerDesException e) { + throw e; + } catch (RuntimeException e) { + throw new SerDesException( + "Failed to generate filesystem payload preview for entity '" + context.entityId() + "'", e); + } + } + + private boolean fitsCheckpoint(String envelope) { + return Utf8StringBinaryCodec.INSTANCE.toBytes(envelope).length <= checkpointEnvelopeLimitBytes; + } + + private SerDesContext requireContext(SerDesContext context) { + if (context == null + || context.durableExecutionArn() == null + || context.durableExecutionArn().isBlank() + || context.entityId() == null + || context.entityId().isBlank()) { + throw new SerDesException( + "FileSystemSerDesStage requires an SDK-managed SerDesContext with durableExecutionArn and entityId"); + } + return context; + } + + private Path resolvePayloadPath(String payloadDigest, SerDesContext context) { + var directory = resolveExecutionDirectory(context.durableExecutionArn()); + var deterministicName = payloadFileName(payloadDigest, context.entityId()); + var suffix = ".payload"; + var fileName = deterministicName.substring(0, deterministicName.length() - suffix.length()) + + "-" + + UUID.randomUUID() + + suffix; + var file = directory.resolve(fileName).normalize(); + if (!file.startsWith(directory)) { + throw new SerDesException("Resolved filesystem payload path is outside the execution directory"); + } + return file; + } + + private String payloadFileName(String payloadDigest, String entityId) { + return encode(entityId) + "-" + payloadDigest + ".payload"; + } + + private void writePayload(SerializedPayload payload, Path file) throws IOException { + var directory = file.getParent(); + try (var secureDirectory = openSecureDirectory(directory, true)) { + var created = false; + try (var channel = secureDirectory + .directory() + .newByteChannel( + file.getFileName(), + Set.of( + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS))) { + created = true; + var buffer = ByteBuffer.wrap(payload.data()); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } catch (FileAlreadyExistsException failure) { + throw failure; + } catch (IOException failure) { + if (created) { + try { + secureDirectory.directory().deleteFile(file.getFileName()); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + throw failure; + } + } + } + + private SecureDirectoryHandle openSecureDirectory(Path directory, boolean createMissing) throws IOException { + if (directory == null || !directory.startsWith(basePath)) { + throw new SerDesException("Filesystem SerDes directory is outside the configured base path"); + } + var root = basePath.getRoot(); + if (root == null) { + throw new SerDesException("Filesystem SerDes base path must be absolute"); + } + + var openedStreams = new ArrayList>(); + try { + var current = requireSecureDirectoryStream(Files.newDirectoryStream(root), openedStreams); + var currentPath = root; + for (var component : root.relativize(directory)) { + var nextPath = currentPath.resolve(component); + DirectoryStream next; + try { + next = current.newDirectoryStream(component, LinkOption.NOFOLLOW_LINKS); + } catch (NoSuchFileException missing) { + if (!createMissing) { + throw missing; + } + try { + Files.createDirectory(nextPath); + } catch (FileAlreadyExistsException ignored) { + // Validate and open the entry relative to the held parent directory below. + } + next = current.newDirectoryStream(component, LinkOption.NOFOLLOW_LINKS); + } + current = requireSecureDirectoryStream(next, openedStreams); + currentPath = nextPath; + } + return new SecureDirectoryHandle(current, openedStreams); + } catch (IOException | RuntimeException failure) { + closeDirectoryStreams(openedStreams, failure); + throw failure; + } + } + + @SuppressWarnings("unchecked") + private static SecureDirectoryStream requireSecureDirectoryStream( + DirectoryStream stream, List> openedStreams) { + openedStreams.add(stream); + if (stream instanceof SecureDirectoryStream secureStream) { + return (SecureDirectoryStream) secureStream; + } + throw new SerDesException( + "FileSystemSerDesStage requires a filesystem provider with SecureDirectoryStream support"); + } + + private static void closeDirectoryStreams(List> streams, Throwable failure) { + for (int index = streams.size() - 1; index >= 0; index--) { + try { + streams.get(index).close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + } + + private static boolean matchesPublishedPayloadFileName(String actualFileName, String expectedFileName) { + if (actualFileName.equals(expectedFileName)) { + return true; + } + var suffix = ".payload"; + var expectedPrefix = expectedFileName.substring(0, expectedFileName.length() - suffix.length()); + return actualFileName.startsWith(expectedPrefix + "-") && actualFileName.endsWith(suffix); + } + + private Path resolveExecutionDirectory(String durableExecutionArn) { + Path directory; + if (pathEncoding == FileSystemPathEncoding.URI) { + var matcher = DURABLE_EXECUTION_ARN_PATTERN.matcher(durableExecutionArn); + if (matcher.matches()) { + directory = basePath.resolve(encode(matcher.group(1))) + .resolve(encode(matcher.group(2))) + .resolve(encode(matcher.group(3))) + .normalize(); + if (!directory.startsWith(basePath)) { + throw new SerDesException("Resolved filesystem execution path is outside the configured base path"); + } + return directory; + } + } + directory = basePath.resolve(encode(durableExecutionArn)).normalize(); + if (!directory.startsWith(basePath)) { + throw new SerDesException("Resolved filesystem execution path is outside the configured base path"); + } + return directory; + } + + private String encode(String value) { + if (pathEncoding == FileSystemPathEncoding.HASH) { + return sha256(value); + } + var encoded = new StringBuilder(); + for (byte valueByte : Utf8StringBinaryCodec.INSTANCE.toBytes(value)) { + int current = valueByte & 0xff; + if (current >= 'a' && current <= 'z' + || current >= 'A' && current <= 'Z' + || current >= '0' && current <= '9' + || current == '-' + || current == '_' + || current == '.' + || current == '~') { + encoded.append((char) current); + } else { + encoded.append('%'); + encoded.append(Character.toUpperCase(Character.forDigit(current >>> 4, 16))); + encoded.append(Character.toUpperCase(Character.forDigit(current & 0xf, 16))); + } + } + return encoded.toString(); + } + + private static String sha256(String value) { + return sha256(Utf8StringBinaryCodec.INSTANCE.toBytes(value)); + } + + private static String sha256(byte[] value) { + try { + var digest = MessageDigest.getInstance("SHA-256").digest(value); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + private record PayloadOwner(String durableExecutionArn, String entityId) {} + + private static final class SecureDirectoryHandle implements AutoCloseable { + private final SecureDirectoryStream directory; + private final List> openedStreams; + + private SecureDirectoryHandle( + SecureDirectoryStream directory, List> openedStreams) { + this.directory = directory; + this.openedStreams = List.copyOf(openedStreams); + } + + private SecureDirectoryStream directory() { + return directory; + } + + @Override + public void close() throws IOException { + IOException failure = null; + for (int index = openedStreams.size() - 1; index >= 0; index--) { + try { + openedStreams.get(index).close(); + } catch (IOException closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } + } + if (failure != null) { + throw failure; + } + } + } + + private enum PayloadType { + STRING + } + + private record SerializedPayload(PayloadType type, byte[] data) { + private SerializedPayload { + Objects.requireNonNull(type, "type cannot be null"); + data = data == null ? null : data.clone(); + } + + private static SerializedPayload fromString(String value) { + return new SerializedPayload(PayloadType.STRING, Utf8StringBinaryCodec.INSTANCE.toBytes(value)); + } + + private static SerializedPayload fromInlineValue(PayloadType type, String value) { + return fromString(value); + } + + @Override + public byte[] data() { + return data == null ? null : data.clone(); + } + + private boolean hasData() { + return data != null; + } + + private SerializedPayload withoutData() { + return new SerializedPayload(type, null); + } + + private String inlineValue() { + if (data == null) { + throw new IllegalStateException("Serialized payload does not contain inline data"); + } + return Utf8StringBinaryCodec.INSTANCE.fromBytes(data); + } + + private String value() { + if (data == null) { + throw new IllegalStateException("Serialized payload does not contain data"); + } + return Utf8StringBinaryCodec.INSTANCE.fromBytes(data); + } + } + + /** Builder for {@link FileSystemSerDesStage}. */ + public static final class Builder { + private final Path basePath; + private FileSystemStorageMode storageMode = FileSystemStorageMode.ALWAYS; + private FileSystemPathEncoding pathEncoding = FileSystemPathEncoding.URI; + private int checkpointEnvelopeLimitBytes = DEFAULT_CHECKPOINT_ENVELOPE_LIMIT_BYTES; + private BiFunction> previewGenerator; + + private Builder(Path basePath) { + this.basePath = Objects.requireNonNull(basePath, "basePath cannot be null"); + } + + public Builder storageMode(FileSystemStorageMode storageMode) { + this.storageMode = Objects.requireNonNull(storageMode, "storageMode cannot be null"); + return this; + } + + public Builder pathEncoding(FileSystemPathEncoding pathEncoding) { + this.pathEncoding = Objects.requireNonNull(pathEncoding, "pathEncoding cannot be null"); + return this; + } + + /** + * Configures the maximum UTF-8 size of an inline or file checkpoint envelope. + * + *

{@link FileSystemStorageMode#OVERFLOW} offloads an inline envelope that exceeds this limit. A final file + * envelope that exceeds the limit is rejected. The configured value should not exceed the payload limit + * accepted by the durable execution service. The default is 255 KiB. + * + * @param checkpointEnvelopeLimitBytes positive envelope limit in bytes + * @return this builder + * @throws IllegalArgumentException if {@code checkpointEnvelopeLimitBytes} is not positive + */ + public Builder checkpointEnvelopeLimitBytes(int checkpointEnvelopeLimitBytes) { + if (checkpointEnvelopeLimitBytes <= 0) { + throw new IllegalArgumentException("checkpointEnvelopeLimitBytes must be positive"); + } + this.checkpointEnvelopeLimitBytes = checkpointEnvelopeLimitBytes; + return this; + } + + /** + * Configures a custom preview generator that receives the string produced by the preceding pipeline stage and + * its serialization context. + * + *

{@link SerDesContext#originalValue()} contains the object supplied to the pipeline's root value codec. The + * returned preview is included only when the payload is stored in a file. Preview generators are responsible + * for avoiding disclosure of sensitive fields from either input. + */ + public Builder previewGenerator(BiFunction> previewGenerator) { + this.previewGenerator = Objects.requireNonNull(previewGenerator, "previewGenerator cannot be null"); + return this; + } + + /** + * Configures structured preview generation for JSON produced by the preceding stage. + * + *

Use {@link #previewGenerator(BiFunction)} for non-JSON stage values or fully custom preview logic. + */ + public Builder previewConfig(PreviewConfig previewConfig) { + Objects.requireNonNull(previewConfig, "previewConfig cannot be null"); + this.previewGenerator = (value, context) -> SerDesPreview.buildPreviewFromJson(value, previewConfig); + return this; + } + + public FileSystemSerDesStage build() { + return new FileSystemSerDesStage(this); + } + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FileSystemStorageMode.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FileSystemStorageMode.java new file mode 100644 index 000000000..c5df70af9 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/FileSystemStorageMode.java @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.filesystem; + +/** Controls when serialized payloads are written to the filesystem. */ +public enum FileSystemStorageMode { + ALWAYS, + OVERFLOW +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/PreviewConfig.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/PreviewConfig.java new file mode 100644 index 000000000..7f546a6c4 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/PreviewConfig.java @@ -0,0 +1,113 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.filesystem; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * Configuration for {@link SerDesPreview#buildPreview(Object, PreviewConfig)}. + * + * @param mode whether fields are included or excluded by default + * @param include fields made visible in {@link PreviewMode#EXCLUDE_ALL} mode + * @param exclude fields hidden from the preview + * @param mask fields made visible with their values replaced by {@code maskString} + * @param maskString replacement for masked field values + * @param maxPreviewBytes maximum estimated UTF-8 size of accepted preview entries + */ +public record PreviewConfig( + PreviewMode mode, + List include, + List exclude, + List mask, + String maskString, + int maxPreviewBytes) { + public static final String DEFAULT_MASK_STRING = "***"; + public static final int DEFAULT_MAX_PREVIEW_BYTES = 4096; + + public PreviewConfig { + Objects.requireNonNull(mode, "mode cannot be null"); + include = immutableFields(include, "include"); + exclude = immutableFields(exclude, "exclude"); + mask = immutableFields(mask, "mask"); + Objects.requireNonNull(maskString, "maskString cannot be null"); + if (maxPreviewBytes < 0) { + throw new IllegalArgumentException("maxPreviewBytes cannot be negative"); + } + } + + /** Creates a preview configuration builder. */ + public static Builder builder(PreviewMode mode) { + return new Builder(mode); + } + + private static List immutableFields(List fields, String name) { + Objects.requireNonNull(fields, name + " cannot be null"); + if (fields.stream().anyMatch(Objects::isNull)) { + throw new NullPointerException(name + " cannot contain null"); + } + return List.copyOf(fields); + } + + /** Builder for {@link PreviewConfig}. */ + public static final class Builder { + private final PreviewMode mode; + private final List include = new ArrayList<>(); + private final List exclude = new ArrayList<>(); + private final List mask = new ArrayList<>(); + private String maskString = DEFAULT_MASK_STRING; + private int maxPreviewBytes = DEFAULT_MAX_PREVIEW_BYTES; + + private Builder(PreviewMode mode) { + this.mode = Objects.requireNonNull(mode, "mode cannot be null"); + } + + /** Adds fields that should be visible. */ + public Builder include(PreviewField... fields) { + include.addAll(validFields(fields, "include")); + return this; + } + + /** Adds fields that should be hidden. */ + public Builder exclude(PreviewField... fields) { + exclude.addAll(validFields(fields, "exclude")); + return this; + } + + /** Adds fields whose values should be masked. */ + public Builder mask(PreviewField... fields) { + mask.addAll(validFields(fields, "mask")); + return this; + } + + /** Sets the value used for masked fields. */ + public Builder maskString(String maskString) { + this.maskString = Objects.requireNonNull(maskString, "maskString cannot be null"); + return this; + } + + /** Sets the maximum estimated UTF-8 preview size. */ + public Builder maxPreviewBytes(int maxPreviewBytes) { + if (maxPreviewBytes < 0) { + throw new IllegalArgumentException("maxPreviewBytes cannot be negative"); + } + this.maxPreviewBytes = maxPreviewBytes; + return this; + } + + /** Returns the immutable preview configuration. */ + public PreviewConfig build() { + return new PreviewConfig(mode, include, exclude, mask, maskString, maxPreviewBytes); + } + + private static List validFields(PreviewField[] fields, String name) { + Objects.requireNonNull(fields, name + " cannot be null"); + if (Arrays.stream(fields).anyMatch(Objects::isNull)) { + throw new NullPointerException(name + " cannot contain null"); + } + return List.of(fields); + } + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/PreviewField.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/PreviewField.java new file mode 100644 index 000000000..171e055aa --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/PreviewField.java @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.filesystem; + +import java.util.Objects; + +/** + * A field selector used by {@link PreviewConfig}. + * + * @param name a field name for {@link FieldMatchMode#ANYWHERE}, or a dot-separated path for {@link FieldMatchMode#PATH} + * @param match how the selector is matched + */ +public record PreviewField(String name, FieldMatchMode match) { + public PreviewField { + Objects.requireNonNull(name, "name cannot be null"); + Objects.requireNonNull(match, "match cannot be null"); + if (name.isBlank()) { + throw new IllegalArgumentException("name cannot be blank"); + } + } + + /** Creates a selector that matches this field name at any depth. */ + public PreviewField(String name) { + this(name, FieldMatchMode.ANYWHERE); + } + + /** Creates a selector that matches this field name at any depth. */ + public static PreviewField anywhere(String name) { + return new PreviewField(name, FieldMatchMode.ANYWHERE); + } + + /** Creates a selector that matches an exact dot-separated path. */ + public static PreviewField path(String name) { + return new PreviewField(name, FieldMatchMode.PATH); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/PreviewMode.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/PreviewMode.java new file mode 100644 index 000000000..e855f7d4a --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/PreviewMode.java @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.filesystem; + +/** Controls which fields are visible by default in a structured payload preview. */ +public enum PreviewMode { + /** Includes every field unless an exclude rule removes it. */ + INCLUDE_ALL, + + /** Excludes every field unless an include or mask rule selects it. */ + EXCLUDE_ALL +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/SerDesPreview.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/SerDesPreview.java new file mode 100644 index 000000000..2479a20e1 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/filesystem/SerDesPreview.java @@ -0,0 +1,195 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.filesystem; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import software.amazon.lambda.durable.exception.SerDesException; + +/** Utilities for building compact structured previews for externally stored SerDes payloads. */ +public final class SerDesPreview { + private static final ObjectMapper MAPPER = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + + private SerDesPreview() {} + + /** + * Builds a preview from an object using include, exclude, mask, path-matching, and byte-budget rules. + * + *

Object fields are traversed in their Jackson serialization order. Object arrays are flattened into their + * containing path, while scalar arrays are preserved as field values, matching the Python and TypeScript preview + * behavior. Fields whose names contain dots are skipped because they cannot be distinguished from dot-separated + * paths. + * + * @return a nested preview map, or {@code null} when no fields are visible + */ + public static Map buildPreview(Object value, PreviewConfig config) { + Objects.requireNonNull(config, "config cannot be null"); + final JsonNode root; + try { + root = MAPPER.valueToTree(value); + } catch (IllegalArgumentException e) { + throw new SerDesException("Failed to convert value for preview generation", e); + } + return buildPreview(root, config); + } + + /** + * Builds a preview from a JSON string. + * + *

This is used by {@link FileSystemSerDesStage.Builder#previewConfig(PreviewConfig)}, because a pipeline stage + * receives the serialized string produced by the preceding stage. + * + * @return a nested preview map, or {@code null} when no fields are visible + */ + public static Map buildPreviewFromJson(String value, PreviewConfig config) { + Objects.requireNonNull(value, "value cannot be null"); + Objects.requireNonNull(config, "config cannot be null"); + try { + return buildPreview(MAPPER.readTree(value), config); + } catch (JsonProcessingException e) { + throw new SerDesException("Built-in preview generation requires a JSON stage value", e); + } + } + + private static Map buildPreview(JsonNode root, PreviewConfig config) { + if (root == null || !root.isObject()) { + return null; + } + + var pairs = new ArrayList(); + collect(root, "", config, pairs); + if (pairs.isEmpty()) { + return null; + } + + Map result = new LinkedHashMap<>(); + for (var pair : pairs) { + var candidate = copy(result); + insert(candidate, pair.path(), pair.value()); + if (serializedSize(candidate) > config.maxPreviewBytes()) { + break; + } + result = candidate; + } + return result.isEmpty() ? null : result; + } + + private static void collect(JsonNode node, String pathPrefix, PreviewConfig config, List pairs) { + if (node == null || node.isNull()) { + return; + } + if (node.isArray()) { + for (var item : node) { + collect(item, pathPrefix, config, pairs); + } + return; + } + if (!node.isObject()) { + return; + } + + for (var field : node.properties()) { + var name = field.getKey(); + if (name.contains(".")) { + continue; + } + var path = pathPrefix.isEmpty() ? name : pathPrefix + "." + name; + var masked = isMatched(path, config.mask()); + var excluded = isMatched(path, config.exclude()); + var visible = !excluded + && (masked || config.mode() == PreviewMode.INCLUDE_ALL || isMatched(path, config.include())); + + if (!visible) { + if (!excluded) { + collect(field.getValue(), path, config, pairs); + } + continue; + } + if (masked) { + pairs.add(new PreviewEntry(path, config.maskString())); + } else if (isScalarArray(field.getValue())) { + pairs.add(new PreviewEntry(path, MAPPER.convertValue(field.getValue(), Object.class))); + } else if (field.getValue().isContainerNode()) { + collect(field.getValue(), path, config, pairs); + } else { + pairs.add(new PreviewEntry(path, MAPPER.convertValue(field.getValue(), Object.class))); + } + } + } + + private static boolean isScalarArray(JsonNode node) { + if (!node.isArray()) { + return false; + } + for (var item : node) { + if (item.isContainerNode()) { + return false; + } + } + return true; + } + + private static boolean isMatched(String path, List fields) { + for (var field : fields) { + if (field.match() == FieldMatchMode.PATH) { + if (path.equals(field.name())) { + return true; + } + } else { + for (var segment : path.split("\\.")) { + if (segment.equals(field.name())) { + return true; + } + } + } + } + return false; + } + + private static int serializedSize(Map preview) { + try { + return MAPPER.writeValueAsBytes(preview).length; + } catch (JsonProcessingException e) { + throw new SerDesException("Failed to measure preview size", e); + } + } + + @SuppressWarnings("unchecked") + private static Map copy(Map source) { + var copy = new LinkedHashMap(); + for (var entry : source.entrySet()) { + var value = entry.getValue(); + copy.put(entry.getKey(), value instanceof Map nested ? copy((Map) nested) : value); + } + return copy; + } + + @SuppressWarnings("unchecked") + private static void insert(Map result, String path, Object value) { + var parts = path.split("\\."); + Map current = result; + for (int index = 0; index < parts.length - 1; index++) { + var existing = current.get(parts[index]); + if (!(existing instanceof Map)) { + existing = new LinkedHashMap(); + current.put(parts[index], existing); + } + current = (Map) existing; + } + current.put(parts[parts.length - 1], value); + } + + private record PreviewEntry(String path, Object value) {} +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/serde/internal/ChainedInvokePayloadFrame.java b/sdk/src/main/java/software/amazon/lambda/durable/serde/internal/ChainedInvokePayloadFrame.java new file mode 100644 index 000000000..66a711173 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/serde/internal/ChainedInvokePayloadFrame.java @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.internal; + +import software.amazon.lambda.durable.exception.SerDesException; + +/** + * SDK-internal framing that identifies an execution input as the output of a chained-invoke SerDes pipeline. + * + *

The frame sits outside the serialized payload so the callee can distinguish it from an external invocation and + * select the persisted SerDes pipeline without inspecting or altering the pipeline's own format. + */ +public final class ChainedInvokePayloadFrame { + private static final String FRAME_MARKER = "__durable_execution_chained_invoke_payload:"; + private static final String FRAME_PREFIX = FRAME_MARKER + "1:"; + + private ChainedInvokePayloadFrame() {} + + /** Adds the current chained-invoke frame to a non-null serialized payload. */ + public static String encode(String payload) { + return payload == null ? null : FRAME_PREFIX + payload; + } + + /** Returns whether the payload uses the reserved chained-invoke frame marker. */ + public static boolean isFramed(String payload) { + return payload != null && payload.startsWith(FRAME_MARKER); + } + + /** Removes and validates the current chained-invoke frame. */ + public static String decode(String payload) { + if (payload == null || !payload.startsWith(FRAME_PREFIX)) { + throw new SerDesException("Unsupported or malformed chained-invoke payload frame"); + } + return payload.substring(FRAME_PREFIX.length()); + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/DurableConfigTest.java b/sdk/src/test/java/software/amazon/lambda/durable/DurableConfigTest.java index c0bd54147..667349981 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/DurableConfigTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/DurableConfigTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -27,18 +28,21 @@ import software.amazon.lambda.durable.retry.PollingStrategies; import software.amazon.lambda.durable.serde.JacksonSerDes; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesStage; class DurableConfigTest { private DurableExecutionClient mockClient; private SerDes mockSerDes; private ExecutorService mockExecutor; + private ExecutorService mockSerDesExecutor; @BeforeEach void setUp() { mockClient = mock(DurableExecutionClient.class); mockSerDes = mock(SerDes.class); mockExecutor = mock(ExecutorService.class); + mockSerDesExecutor = mock(ExecutorService.class); } @Test @@ -50,8 +54,10 @@ void testDefaultConfig_CreatesWithDefaults() { assertInstanceOf(LambdaDurableFunctionsClient.class, config.getDurableExecutionClient()); assertNotNull(config.getSerDes()); assertInstanceOf(JacksonSerDes.class, config.getSerDes()); + assertSame(config.getSerDes(), config.getInputSerDes()); assertNotNull(config.getExecutorService()); assertInstanceOf(ExecutorService.class, config.getExecutorService()); + assertNull(config.getSerDesExecutorService()); } @Test @@ -74,9 +80,42 @@ void testBuilder_WithCustomSerDes() { assertNotNull(config); assertNotNull(config.getDurableExecutionClient()); assertEquals(mockSerDes, config.getSerDes()); + assertSame(mockSerDes, config.getInputSerDes()); assertNotNull(config.getExecutorService()); } + @Test + void testBuilder_ComposableSerDesDefaultsInputToRootValueCodec() { + var valueCodec = new JacksonSerDes(); + var config = DurableConfig.builder() + .withSerDes(valueCodec.then(mock(SerDesStage.class))) + .build(); + + assertSame(valueCodec, config.getInputSerDes()); + } + + @Test + void testBuilder_WithCustomInputSerDes() { + var inputSerDes = mock(SerDes.class); + var config = DurableConfig.builder() + .withSerDes(mockSerDes) + .withInputSerDes(inputSerDes) + .build(); + + assertSame(inputSerDes, config.getInputSerDes()); + assertSame(mockSerDes, config.getSerDes()); + } + + @Test + void testBuilder_RejectsComposableInputSerDes() { + var inputPipeline = new JacksonSerDes().then(mock(SerDesStage.class)); + + var exception = assertThrows( + IllegalArgumentException.class, () -> DurableConfig.builder().withInputSerDes(inputPipeline)); + + assertTrue(exception.getMessage().contains("value codec")); + } + @Test void testBuilder_WithCustomExecutorService() { var config = DurableConfig.builder().withExecutorService(mockExecutor).build(); @@ -87,6 +126,25 @@ void testBuilder_WithCustomExecutorService() { assertNotNull(config.getSerDes()); } + @Test + void testBuilder_WithCustomSerDesExecutorService() { + var config = DurableConfig.builder() + .withSerDesExecutorService(mockSerDesExecutor) + .build(); + + assertSame(mockSerDesExecutor, config.getSerDesExecutorService()); + } + + @Test + void testBuilder_RejectsAliasedOperationAndSerDesExecutors() { + var exception = assertThrows(IllegalStateException.class, () -> DurableConfig.builder() + .withExecutorService(mockExecutor) + .withSerDesExecutorService(mockExecutor) + .build()); + + assertTrue(exception.getMessage().contains("must be different")); + } + @Test void testBuilder_DeserializeAfterSerializationDefaultsToTrue() { var config = @@ -131,12 +189,14 @@ void testBuilder_WithAllCustomComponents() { .withDurableExecutionClient(mockClient) .withSerDes(mockSerDes) .withExecutorService(mockExecutor) + .withSerDesExecutorService(mockSerDesExecutor) .build(); assertNotNull(config); assertEquals(mockClient, config.getDurableExecutionClient()); assertEquals(mockSerDes, config.getSerDes()); assertEquals(mockExecutor, config.getExecutorService()); + assertEquals(mockSerDesExecutor, config.getSerDesExecutorService()); } @Test @@ -161,6 +221,24 @@ void testBuilder_NullSerDes_ThrowsException() { assertEquals("SerDes cannot be null", exception.getMessage()); } + @Test + void testBuilder_NullInputSerDes_ThrowsException() { + var builder = DurableConfig.builder(); + + var exception = assertThrows(NullPointerException.class, () -> builder.withInputSerDes(null)); + + assertEquals("inputSerDes cannot be null", exception.getMessage()); + } + + @Test + void testBuilder_NullSerDesExecutorService_ThrowsException() { + var builder = DurableConfig.builder(); + + var exception = assertThrows(NullPointerException.class, () -> builder.withSerDesExecutorService(null)); + + assertEquals("SerDes ExecutorService cannot be null", exception.getMessage()); + } + @Test void testBuilder_FluentAPI() { var builder = DurableConfig.builder(); @@ -168,7 +246,9 @@ void testBuilder_FluentAPI() { // Verify fluent API returns builder assertSame(builder, builder.withDurableExecutionClient(mockClient)); assertSame(builder, builder.withSerDes(mockSerDes)); + assertSame(builder, builder.withInputSerDes(mock(SerDes.class))); assertSame(builder, builder.withExecutorService(mockExecutor)); + assertSame(builder, builder.withSerDesExecutorService(mockSerDesExecutor)); assertSame(builder, builder.withDeserializeAfterSerialization(false)); } diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/CallbackOperationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/CallbackOperationTest.java index c8cea7934..b772ee4ad 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/CallbackOperationTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/CallbackOperationTest.java @@ -223,6 +223,38 @@ void getThrowsCallbackExceptionWhenFailed() { assertTrue(exception.getMessage().contains("ValidationError")); } + @Test + void getThrowsCallbackFailedExceptionWhenErrorTypeIsMissing() { + var existingCallback = Operation.builder() + .id(OPERATION_ID) + .name(OPERATION_NAME) + .type(OperationType.CALLBACK) + .subType(OperationSubType.CALLBACK.getValue()) + .status(OperationStatus.FAILED) + .callbackDetails(CallbackDetails.builder() + .callbackId("callback-id") + .error(ErrorObject.builder() + .errorMessage("untyped callback failure") + .errorData("not-json") + .build()) + .build()) + .build(); + var executionManager = createExecutionManager(List.of(existingCallback)); + when(durableContext.getExecutionManager()).thenReturn(executionManager); + + var operation = new CallbackOperation<>( + OPERATION_IDENTIFIER, + TypeToken.get(String.class), + CallbackConfig.builder().serDes(new JacksonSerDes()).build(), + durableContext); + operation.execute(); + + var exception = assertThrows(CallbackFailedException.class, operation::get); + assertEquals("untyped callback failure", exception.getMessage()); + assertNull(exception.getErrorObject().errorType()); + assertNull(exception.deserializedError()); + } + @Test void getThrowsCallbackTimeoutExceptionWhenTimedOut() { var existingCallback = Operation.builder() @@ -365,6 +397,7 @@ void getThrowsSerDesExceptionWithHelpfulMessageWhenDeserializationFails() { operation.execute(); var exception = assertThrows(SerDesException.class, operation::get); - assertEquals("Invalid base64 encoding", exception.getMessage()); + assertTrue(exception.getMessage().contains("RESULT")); + assertEquals("Invalid base64 encoding", exception.getCause().getMessage()); } } diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/InvokeOperationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/InvokeOperationTest.java index 2c1d76c74..c3a1b2dfc 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/InvokeOperationTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/InvokeOperationTest.java @@ -3,12 +3,18 @@ package software.amazon.lambda.durable.operation; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import software.amazon.awssdk.services.lambda.model.ChainedInvokeDetails; import software.amazon.awssdk.services.lambda.model.ErrorObject; import software.amazon.awssdk.services.lambda.model.Operation; @@ -16,6 +22,7 @@ import software.amazon.lambda.durable.TypeToken; import software.amazon.lambda.durable.config.InvokeConfig; import software.amazon.lambda.durable.context.DurableContextImpl; +import software.amazon.lambda.durable.exception.DurableOperationException; import software.amazon.lambda.durable.exception.InvokeException; import software.amazon.lambda.durable.exception.InvokeFailedException; import software.amazon.lambda.durable.exception.InvokeStoppedException; @@ -26,6 +33,11 @@ import software.amazon.lambda.durable.model.OperationIdentifier; import software.amazon.lambda.durable.model.OperationSubType; import software.amazon.lambda.durable.serde.JacksonSerDes; +import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.SerDesRunner; +import software.amazon.lambda.durable.serde.filesystem.FileSystemSerDesStage; class InvokeOperationTest { private static final String OPERATION_ID = "2"; @@ -36,6 +48,9 @@ class InvokeOperationTest { private ExecutionManager executionManager; private DurableContextImpl durableContext; + @TempDir + Path basePath; + @BeforeEach void setUp() { executionManager = mock(ExecutionManager.class); @@ -71,15 +86,18 @@ void getDoesNotThrowWhenCalledFromHandlerContext() { @Test void getInvokeFailedExceptionWhenInvocationFailed() { + var serDes = new JacksonSerDes(); + var original = new IllegalStateException("errorMessage"); + var errorData = serDes.serialize(original); var op = Operation.builder() .id(OPERATION_ID) .name(OPERATION_NAME) .status(OperationStatus.FAILED) .chainedInvokeDetails(ChainedInvokeDetails.builder() .error(ErrorObject.builder() - .errorType("errorType") + .errorType(original.getClass().getName()) .errorMessage("errorMessage") - .errorData("errorData") + .errorData(errorData) .build()) .build()) .build(); @@ -90,14 +108,69 @@ void getInvokeFailedExceptionWhenInvocationFailed() { "test-function", "{}", TypeToken.get(String.class), - InvokeConfig.builder().serDes(new JacksonSerDes()).build(), + InvokeConfig.builder().serDes(serDes).build(), durableContext); operation.onCheckpointComplete(op); InvokeFailedException ex = assertThrows(InvokeFailedException.class, () -> operation.get()); - assertEquals("errorData", ex.getErrorObject().errorData()); - assertEquals("errorType", ex.getErrorObject().errorType()); + assertEquals(errorData, ex.getErrorObject().errorData()); + assertEquals(original.getClass().getName(), ex.getErrorObject().errorType()); assertEquals("errorMessage", ex.getMessage()); + assertInstanceOf(IllegalStateException.class, ex.deserializedError()); + assertEquals("errorMessage", ex.deserializedError().getMessage()); + } + + @Test + void getInvokeFailedExceptionWhenInvocationDetailsAreMissing() { + var op = Operation.builder() + .id(OPERATION_ID) + .name(OPERATION_NAME) + .status(OperationStatus.FAILED) + .build(); + when(executionManager.getOperationAndUpdateReplayState(OPERATION_ID)).thenReturn(op); + + var operation = new InvokeOperation<>( + OPERATION_IDENTIFIER, + "test-function", + "{}", + TypeToken.get(String.class), + InvokeConfig.builder().serDes(new JacksonSerDes()).build(), + durableContext); + operation.onCheckpointComplete(op); + + var exception = assertThrows(InvokeFailedException.class, operation::get); + assertNull(exception.getErrorObject()); + assertNull(exception.deserializedError()); + } + + @Test + void getInvokeFailedExceptionWhenErrorTypeIsMissing() { + var op = Operation.builder() + .id(OPERATION_ID) + .name(OPERATION_NAME) + .status(OperationStatus.FAILED) + .chainedInvokeDetails(ChainedInvokeDetails.builder() + .error(ErrorObject.builder() + .errorMessage("untyped failure") + .errorData("not-json") + .build()) + .build()) + .build(); + when(executionManager.getOperationAndUpdateReplayState(OPERATION_ID)).thenReturn(op); + + var operation = new InvokeOperation<>( + OPERATION_IDENTIFIER, + "test-function", + "{}", + TypeToken.get(String.class), + InvokeConfig.builder().serDes(new JacksonSerDes()).build(), + durableContext); + operation.onCheckpointComplete(op); + + var exception = assertThrows(InvokeFailedException.class, operation::get); + assertEquals("untyped failure", exception.getMessage()); + assertNull(exception.getErrorObject().errorType()); + assertNull(exception.deserializedError()); } @Test @@ -162,6 +235,60 @@ void getInvokeStoppedExceptionWhenInvocationTimedOut() { assertEquals("errorMessage", ex.getMessage()); } + @ParameterizedTest + @EnumSource( + value = OperationStatus.class, + names = {"TIMED_OUT", "STOPPED"}) + void nestedTerminalInvokeRebindsFilesystemErrorForReplay(OperationStatus status) { + var callerArn = "arn:aws:lambda:us-east-1:123456789012:function:caller/durable-execution/caller/invocation"; + var calleeArn = "arn:aws:lambda:us-east-1:123456789012:function:callee/durable-execution/callee/invocation"; + when(executionManager.getDurableExecutionArn()).thenReturn(callerArn); + + var serDes = + new JacksonSerDes().then(FileSystemSerDesStage.builder(basePath).build()); + var original = new IllegalStateException("callee failed"); + var errorData = new SerDesRunner(null) + .serialize( + serDes, + original, + SerDesContext.forExecution( + calleeArn, "callee-invocation", "callee-execution", SerDesPayloadKind.EXCEPTION)); + var op = Operation.builder() + .id(OPERATION_ID) + .name(OPERATION_NAME) + .status(status) + .chainedInvokeDetails(ChainedInvokeDetails.builder() + .error(ErrorObject.builder() + .errorType(original.getClass().getName()) + .errorMessage(original.getMessage()) + .errorData(errorData) + .build()) + .build()) + .build(); + when(executionManager.getOperationAndUpdateReplayState(OPERATION_ID)).thenReturn(op); + + var invoke = new InvokeOperation<>( + OPERATION_IDENTIFIER, + "test-function", + "{}", + TypeToken.get(String.class), + InvokeConfig.builder().serDes(serDes).build(), + durableContext); + invoke.onCheckpointComplete(op); + + DurableOperationException forwarded = status == OperationStatus.TIMED_OUT + ? assertThrows(InvokeTimedOutException.class, invoke::get) + : assertThrows(InvokeStoppedException.class, invoke::get); + assertInstanceOf(IllegalStateException.class, forwarded.deserializedError()); + + var child = new ChildContextRebindingOperation(serDes, durableContext); + var rebound = child.rebind(forwarded); + var replayed = child.deserialize(rebound); + + assertInstanceOf(IllegalStateException.class, replayed); + assertEquals("callee failed", replayed.getMessage()); + } + @Test void getInvokeFailedExceptionWhenInvocationEndedUnexpectedly() { var op = Operation.builder() @@ -189,4 +316,33 @@ void getInvokeFailedExceptionWhenInvocationEndedUnexpectedly() { assertThrows(InvokeException.class, () -> operation.get()); } + + private static final class ChildContextRebindingOperation extends SerializableDurableOperation { + private ChildContextRebindingOperation(SerDes serDes, DurableContextImpl durableContext) { + super( + OperationIdentifier.of("child", "child", OperationSubType.RUN_IN_CHILD_CONTEXT), + TypeToken.get(String.class), + serDes, + durableContext); + } + + private ErrorObject rebind(DurableOperationException exception) { + return rebindForwardedException(exception); + } + + private Throwable deserialize(ErrorObject error) { + return deserializeException(error); + } + + @Override + protected void start() {} + + @Override + protected void replay(Operation existing) {} + + @Override + public String get() { + return null; + } + } } diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/SerializableDurableOperationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/SerializableDurableOperationTest.java index bc9e940b8..af0c921d2 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/SerializableDurableOperationTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/SerializableDurableOperationTest.java @@ -15,14 +15,20 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.awssdk.services.lambda.model.CallbackDetails; import software.amazon.awssdk.services.lambda.model.ErrorObject; import software.amazon.awssdk.services.lambda.model.Operation; import software.amazon.awssdk.services.lambda.model.OperationStatus; @@ -31,9 +37,12 @@ import software.amazon.lambda.durable.DurableConfig; import software.amazon.lambda.durable.TypeToken; import software.amazon.lambda.durable.client.DurableExecutionClient; +import software.amazon.lambda.durable.config.CallbackConfig; import software.amazon.lambda.durable.context.DurableContextImpl; +import software.amazon.lambda.durable.exception.CallbackFailedException; import software.amazon.lambda.durable.exception.IllegalDurableOperationException; import software.amazon.lambda.durable.exception.NonDeterministicExecutionException; +import software.amazon.lambda.durable.exception.RetryableSerDesException; import software.amazon.lambda.durable.exception.SerDesException; import software.amazon.lambda.durable.execution.ExecutionManager; import software.amazon.lambda.durable.execution.ThreadContext; @@ -42,6 +51,7 @@ import software.amazon.lambda.durable.model.OperationSubType; import software.amazon.lambda.durable.serde.JacksonSerDes; import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.filesystem.FileSystemSerDesStage; class SerializableDurableOperationTest { @@ -84,6 +94,27 @@ public T deserialize(String data, TypeToken typeToken) { } } + private static final class PrefixedSerDes extends JacksonSerDes { + private final String prefix; + + private PrefixedSerDes(String prefix) { + this.prefix = prefix; + } + + @Override + public String serialize(Object value) { + return prefix + super.serialize(value); + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + if (data == null || !data.startsWith(prefix)) { + throw new SerDesException("Expected SerDes prefix " + prefix); + } + return super.deserialize(data.substring(prefix.length()), typeToken); + } + } + private static final String OPERATION_ID = "1"; private static final String CONTEXT_ID = "1-step"; private static final String OPERATION_NAME = "name"; @@ -94,8 +125,12 @@ public T deserialize(String data, TypeToken typeToken) { private static final TypeToken RESULT_TYPE = TypeToken.get(String.class); private static final SerDes SER_DES = new JacksonSerDes(); private static final String RESULT = "name"; + private static final ObjectMapper MAPPER = new ObjectMapper(); private final ExecutorService internalExecutor = Executors.newFixedThreadPool(2); + @TempDir + Path basePath; + private ExecutionManager executionManager; private DurableContextImpl durableContext; @@ -420,7 +455,8 @@ protected void replay(Operation existing) {} @Override public String get() { var thrown = assertThrows(SerDesException.class, () -> serializeAndDeserializeResult("abc")); - assertEquals("cannot deserialize", thrown.getMessage()); + assertTrue(thrown.getMessage().contains("RESULT")); + assertEquals("cannot deserialize", thrown.getCause().getMessage()); return RESULT; } }; @@ -502,6 +538,109 @@ public String get() { op.get(); } + @Test + void deserializeExceptionPreservesRetryableStorageFailure() { + when(executionManager.getDurableExecutionArn()) + .thenReturn( + "arn:aws:lambda:us-east-1:123456789012:function:test:1/durable-execution/execution/invocation"); + var serDes = + new JacksonSerDes().then(FileSystemSerDesStage.builder(basePath).build()); + var checkpointedError = new AtomicReference(); + SerializableDurableOperation producer = + new SerializableDurableOperation<>(OPERATION_IDENTIFIER, RESULT_TYPE, serDes, durableContext) { + @Override + protected void start() {} + + @Override + protected void replay(Operation existing) {} + + @Override + public String get() { + checkpointedError.set(serializeException(new RuntimeException("test exception"), 1)); + return RESULT; + } + }; + producer.get(); + try { + Files.delete(Path.of(MAPPER.readTree(checkpointedError.get().errorData()) + .get("file") + .textValue())); + } catch (Exception e) { + throw new AssertionError(e); + } + + SerializableDurableOperation replay = + new SerializableDurableOperation<>(OPERATION_IDENTIFIER, RESULT_TYPE, serDes, durableContext) { + @Override + protected void start() {} + + @Override + protected void replay(Operation existing) {} + + @Override + public String get() { + assertThrows( + RetryableSerDesException.class, () -> deserializeException(checkpointedError.get(), 1)); + return RESULT; + } + }; + replay.get(); + } + + @Test + void rebindForwardedExceptionUsesProducingOperationSerDesBeforeParentSerDes() { + var producerSerDes = new PrefixedSerDes("producer:"); + var parentSerDes = new PrefixedSerDes("parent:"); + var original = new IllegalStateException("callback failed"); + var producerOperation = Operation.builder() + .id("callback-1") + .name("callback") + .type(OperationType.CALLBACK) + .subType(OperationSubType.CALLBACK.getValue()) + .status(OperationStatus.FAILED) + .callbackDetails(CallbackDetails.builder() + .callbackId("callback-id") + .error(ErrorObject.builder() + .errorType(original.getClass().getName()) + .errorMessage(original.getMessage()) + .errorData(producerSerDes.serialize(original)) + .build()) + .build()) + .build(); + when(executionManager.getOperationAndUpdateReplayState("callback-1")).thenReturn(producerOperation); + + var producer = new CallbackOperation<>( + OperationIdentifier.of("callback-1", "callback", OperationSubType.CALLBACK), + TypeToken.get(String.class), + CallbackConfig.builder().serDes(producerSerDes).build(), + durableContext); + producer.onCheckpointComplete(producerOperation); + var forwarded = assertThrows(CallbackFailedException.class, producer::get); + assertInstanceOf(IllegalStateException.class, forwarded.deserializedError()); + + var rebound = new AtomicReference(); + SerializableDurableOperation parent = + new SerializableDurableOperation<>(OPERATION_IDENTIFIER, RESULT_TYPE, parentSerDes, durableContext) { + @Override + protected void start() {} + + @Override + protected void replay(Operation existing) {} + + @Override + public String get() { + rebound.set(rebindForwardedException(forwarded)); + return RESULT; + } + }; + + parent.get(); + + assertTrue(rebound.get().errorData().startsWith("parent:")); + var decoded = parentSerDes.deserialize(rebound.get().errorData(), TypeToken.get(IllegalStateException.class)); + assertEquals("callback failed", decoded.getMessage()); + } + @Test void serializeExceptionValidatesRoundTrip() { var serDes = new TrackingSerDes(); diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/StepOperationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/StepOperationTest.java index be4962d71..0e4922a22 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/StepOperationTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/StepOperationTest.java @@ -5,18 +5,27 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import java.nio.file.Path; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.awssdk.services.lambda.model.CallbackDetails; import software.amazon.awssdk.services.lambda.model.ErrorObject; import software.amazon.awssdk.services.lambda.model.Operation; +import software.amazon.awssdk.services.lambda.model.OperationAction; import software.amazon.awssdk.services.lambda.model.OperationStatus; +import software.amazon.awssdk.services.lambda.model.OperationType; +import software.amazon.awssdk.services.lambda.model.OperationUpdate; import software.amazon.awssdk.services.lambda.model.StepDetails; import software.amazon.lambda.durable.DurableConfig; import software.amazon.lambda.durable.TypeToken; import software.amazon.lambda.durable.config.StepConfig; import software.amazon.lambda.durable.context.DurableContextImpl; +import software.amazon.lambda.durable.exception.CallbackFailedException; import software.amazon.lambda.durable.exception.StepFailedException; import software.amazon.lambda.durable.exception.StepInterruptedException; import software.amazon.lambda.durable.execution.ExecutionManager; @@ -24,10 +33,19 @@ import software.amazon.lambda.durable.execution.ThreadType; import software.amazon.lambda.durable.model.OperationIdentifier; import software.amazon.lambda.durable.model.OperationSubType; +import software.amazon.lambda.durable.retry.RetryStrategies; import software.amazon.lambda.durable.serde.JacksonSerDes; +import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.SerDesRunner; +import software.amazon.lambda.durable.serde.SerDesStage; +import software.amazon.lambda.durable.serde.filesystem.FileSystemSerDesStage; class StepOperationTest { + private static final String DURABLE_EXECUTION_ARN = + "arn:aws:lambda:us-east-1:123456789012:function:test:1/durable-execution/execution/invocation"; private static final String OPERATION_ID = "1"; private static final String OPERATION_NAME = "test-step"; private static final String RESULT = "result"; @@ -36,6 +54,9 @@ class StepOperationTest { private ExecutionManager executionManager; private DurableContextImpl durableContext; + @TempDir + Path basePath; + @BeforeEach void setUp() { executionManager = mock(ExecutionManager.class); @@ -94,6 +115,123 @@ void getDoesNotThrowWhenCalledFromHandlerContext() { assertEquals("cached-result", result); } + @Test + void successfulReplayUsesCheckpointedAttemptInSerDesContext() { + var observedContext = new AtomicReference(); + var serDes = new JacksonSerDes().then(new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return value; + } + + @Override + public String deserialize(String data, SerDesContext context) { + observedContext.set(context); + return data; + } + }); + var op = Operation.builder() + .id(OPERATION_ID) + .name(OPERATION_NAME) + .status(OperationStatus.SUCCEEDED) + .stepDetails(StepDetails.builder() + .result("\"cached-result\"") + .attempt(3) + .build()) + .build(); + when(executionManager.getOperationAndUpdateReplayState(OPERATION_ID)).thenReturn(op); + + var operation = new StepOperation<>( + OPERATION_IDENTIFIER, + (ctx) -> RESULT, + TypeToken.get(String.class), + StepConfig.builder().serDes(serDes).build(), + durableContext); + operation.onCheckpointComplete(op); + + assertEquals("cached-result", operation.get()); + assertEquals(3, observedContext.get().attempt()); + assertNull(observedContext.get().originalValue()); + } + + @Test + void forwardedFilesystemExceptionIsReboundForFirstExecutionAndReplay() { + when(executionManager.getDurableExecutionArn()).thenReturn(DURABLE_EXECUTION_ARN); + var serDes = + new JacksonSerDes().then(FileSystemSerDesStage.builder(basePath).build()); + var original = new IllegalArgumentException("callback failed"); + var forwarded = forwardedCallbackFailure(serDes, original); + var failedUpdate = new AtomicReference(); + doAnswer(invocation -> { + var update = invocation.getArgument(0); + if (update.action() == OperationAction.FAIL) { + failedUpdate.set(update); + } + return CompletableFuture.completedFuture(null); + }) + .when(executionManager) + .sendOperationUpdate(any()); + when(executionManager.getOperationAndUpdateReplayState(OPERATION_ID)).thenReturn(null); + + var operation = new StepOperation<>( + OPERATION_IDENTIFIER, + ctx -> { + throw forwarded; + }, + TypeToken.get(String.class), + StepConfig.builder() + .retryStrategy(RetryStrategies.Presets.NO_RETRY) + .serDes(serDes) + .build(), + durableContext); + + operation.execute(); + + verify(executionManager, timeout(5_000)) + .sendOperationUpdate(argThat(update -> update.action() == OperationAction.FAIL)); + var checkpointedError = failedUpdate.get().error(); + var stepContext = SerDesContext.forOperation( + DURABLE_EXECUTION_ARN, + OPERATION_ID, + OPERATION_NAME, + null, + OperationType.STEP, + OperationSubType.STEP, + SerDesPayloadKind.EXCEPTION, + 1); + var rebound = new SerDesRunner(null) + .deserialize( + serDes, + checkpointedError.errorData(), + TypeToken.get(IllegalArgumentException.class), + stepContext); + assertEquals("callback failed", rebound.getMessage()); + + var replayedOperation = Operation.builder() + .id(OPERATION_ID) + .name(OPERATION_NAME) + .type(OperationType.STEP) + .subType(OperationSubType.STEP.getValue()) + .status(OperationStatus.FAILED) + .stepDetails(StepDetails.builder() + .attempt(1) + .error(checkpointedError) + .build()) + .build(); + when(executionManager.getOperationAndUpdateReplayState(OPERATION_ID)).thenReturn(replayedOperation); + var replay = new StepOperation<>( + OPERATION_IDENTIFIER, + ctx -> RESULT, + TypeToken.get(String.class), + StepConfig.builder().serDes(serDes).build(), + durableContext); + + replay.execute(); + + var thrown = assertThrows(IllegalArgumentException.class, replay::get); + assertEquals("callback failed", thrown.getMessage()); + } + @Test void getThrowsOriginalExceptionWhenClassIsAvailable() { var serDes = new JacksonSerDes(); @@ -244,4 +382,33 @@ public CustomTestException(String message) { super(message); } } + + private CallbackFailedException forwardedCallbackFailure(SerDes serDes, RuntimeException original) { + var sourceContext = SerDesContext.forOperation( + DURABLE_EXECUTION_ARN, + "callback-1", + "callback", + null, + OperationType.CALLBACK, + OperationSubType.CALLBACK, + SerDesPayloadKind.EXCEPTION, + null); + var error = ErrorObject.builder() + .errorType(original.getClass().getName()) + .errorMessage(original.getMessage()) + .errorData(new SerDesRunner(null).serialize(serDes, original, sourceContext)) + .build(); + var sourceOperation = Operation.builder() + .id("callback-1") + .name("callback") + .type(OperationType.CALLBACK) + .subType(OperationSubType.CALLBACK.getValue()) + .status(OperationStatus.FAILED) + .callbackDetails(CallbackDetails.builder() + .callbackId("callback-id") + .error(error) + .build()) + .build(); + return new CallbackFailedException(sourceOperation, original); + } } diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/WaitForConditionOperationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/WaitForConditionOperationTest.java index 69502a3c3..e2e02e0d3 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/WaitForConditionOperationTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/WaitForConditionOperationTest.java @@ -5,21 +5,28 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import java.nio.file.Path; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.awssdk.services.lambda.model.CallbackDetails; import software.amazon.awssdk.services.lambda.model.ErrorObject; import software.amazon.awssdk.services.lambda.model.Operation; +import software.amazon.awssdk.services.lambda.model.OperationAction; import software.amazon.awssdk.services.lambda.model.OperationStatus; import software.amazon.awssdk.services.lambda.model.OperationType; +import software.amazon.awssdk.services.lambda.model.OperationUpdate; import software.amazon.awssdk.services.lambda.model.StepDetails; import software.amazon.lambda.durable.DurableConfig; import software.amazon.lambda.durable.TypeToken; import software.amazon.lambda.durable.config.WaitForConditionConfig; import software.amazon.lambda.durable.context.DurableContextImpl; +import software.amazon.lambda.durable.exception.CallbackFailedException; import software.amazon.lambda.durable.exception.IllegalDurableOperationException; import software.amazon.lambda.durable.exception.NonDeterministicExecutionException; import software.amazon.lambda.durable.exception.SerDesException; @@ -31,9 +38,16 @@ import software.amazon.lambda.durable.model.OperationSubType; import software.amazon.lambda.durable.model.WaitForConditionResult; import software.amazon.lambda.durable.serde.JacksonSerDes; +import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.SerDesRunner; +import software.amazon.lambda.durable.serde.filesystem.FileSystemSerDesStage; class WaitForConditionOperationTest { + private static final String DURABLE_EXECUTION_ARN = + "arn:aws:lambda:us-east-1:123456789012:function:test:1/durable-execution/execution/invocation"; private static final String OPERATION_ID = "1"; private static final String OPERATION_NAME = "test-wait-for-condition"; private static final JacksonSerDes SERDES = new JacksonSerDes(); @@ -41,6 +55,9 @@ class WaitForConditionOperationTest { private ExecutionManager executionManager; private DurableContextImpl durableContext; + @TempDir + Path basePath; + @BeforeEach void setUp() { executionManager = mock(ExecutionManager.class); @@ -155,6 +172,81 @@ void replayFailedFallsBackToStepFailedException() { assertThrows(WaitForConditionFailedException.class, operation::get); } + @Test + void forwardedFilesystemExceptionIsReboundForFirstExecutionAndReplay() { + when(executionManager.getDurableExecutionArn()).thenReturn(DURABLE_EXECUTION_ARN); + var serDes = + new JacksonSerDes().then(FileSystemSerDesStage.builder(basePath).build()); + var original = new IllegalArgumentException("callback failed"); + var forwarded = forwardedCallbackFailure(serDes, original); + var failedUpdate = new AtomicReference(); + doAnswer(invocation -> { + var update = invocation.getArgument(0); + if (update.action() == OperationAction.FAIL) { + failedUpdate.set(update); + } + return CompletableFuture.completedFuture(null); + }) + .when(executionManager) + .sendOperationUpdate(any()); + when(executionManager.getOperationAndUpdateReplayState(OPERATION_ID)).thenReturn(null); + var config = WaitForConditionConfig.builder() + .initialState(0) + .serDes(serDes) + .build(); + var operation = createOperation( + (state, ctx) -> { + throw forwarded; + }, + config); + + operation.execute(); + + verify(executionManager, timeout(5_000)) + .sendOperationUpdate(argThat(update -> update.action() == OperationAction.FAIL)); + var checkpointedError = failedUpdate.get().error(); + var waitForConditionContext = SerDesContext.forOperation( + DURABLE_EXECUTION_ARN, + OPERATION_ID, + OPERATION_NAME, + null, + OperationType.STEP, + OperationSubType.WAIT_FOR_CONDITION, + SerDesPayloadKind.EXCEPTION, + 1); + var rebound = new SerDesRunner(null) + .deserialize( + serDes, + checkpointedError.errorData(), + TypeToken.get(IllegalArgumentException.class), + waitForConditionContext); + assertEquals("callback failed", rebound.getMessage()); + + var replayedOperation = Operation.builder() + .id(OPERATION_ID) + .name(OPERATION_NAME) + .type(OperationType.STEP) + .subType(OperationSubType.WAIT_FOR_CONDITION.getValue()) + .status(OperationStatus.FAILED) + .stepDetails(StepDetails.builder() + .attempt(1) + .error(checkpointedError) + .build()) + .build(); + when(executionManager.getOperationAndUpdateReplayState(OPERATION_ID)).thenReturn(replayedOperation); + var replay = createOperation( + (state, ctx) -> WaitForConditionResult.stopPolling(state), + WaitForConditionConfig.builder() + .initialState(0) + .serDes(serDes) + .build()); + + replay.execute(); + + var thrown = assertThrows(IllegalArgumentException.class, replay::get); + assertEquals("callback failed", thrown.getMessage()); + } + // ===== Replay STARTED ===== @Test @@ -389,4 +481,33 @@ void replayStartedWithCorruptCheckpointDataThrowsSerDesException() { assertThrows(SerDesException.class, operation::execute); } + + private CallbackFailedException forwardedCallbackFailure(SerDes serDes, RuntimeException original) { + var sourceContext = SerDesContext.forOperation( + DURABLE_EXECUTION_ARN, + "callback-1", + "callback", + null, + OperationType.CALLBACK, + OperationSubType.CALLBACK, + SerDesPayloadKind.EXCEPTION, + null); + var error = ErrorObject.builder() + .errorType(original.getClass().getName()) + .errorMessage(original.getMessage()) + .errorData(new SerDesRunner(null).serialize(serDes, original, sourceContext)) + .build(); + var sourceOperation = Operation.builder() + .id("callback-1") + .name("callback") + .type(OperationType.CALLBACK) + .subType(OperationSubType.CALLBACK.getValue()) + .status(OperationStatus.FAILED) + .callbackDetails(CallbackDetails.builder() + .callbackId("callback-id") + .error(error) + .build()) + .build(); + return new CallbackFailedException(sourceOperation, original); + } } diff --git a/sdk/src/test/java/software/amazon/lambda/durable/serde/ComposableBinarySerDesStageTest.java b/sdk/src/test/java/software/amazon/lambda/durable/serde/ComposableBinarySerDesStageTest.java new file mode 100644 index 000000000..1c3b6dc2f --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/serde/ComposableBinarySerDesStageTest.java @@ -0,0 +1,330 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.exception.SerDesException; + +class ComposableBinarySerDesStageTest { + private static final String BINARY_FRAME_MARKER = "__durable_execution_composable_binary_serdes:"; + private static final String BINARY_FRAME_PREFIX = BINARY_FRAME_MARKER + "1:"; + private static final SerDesContext CONTEXT = + SerDesContext.forExecution("arn", "invocation", "execution", SerDesPayloadKind.RESULT); + + @Test + void processesBoundariesAndBinaryStagesInDeclarationOrder() { + var calls = new ArrayList(); + var stage = ComposableBinarySerDesStage.builder() + .startWith(recordingCodec("starting", calls)) + .then(appendingStage("first", (byte) 1, calls)) + .then(appendingStage("second", (byte) 2, calls)) + .endWith(recordingCodec("ending", calls)) + .build(); + + var serialized = stage.serialize("value", CONTEXT); + var deserialized = stage.deserialize(serialized, CONTEXT); + + assertEquals( + BINARY_FRAME_PREFIX + Base64.getEncoder().encodeToString(new byte[] {'v', 'a', 'l', 'u', 'e', 1, 2}), + serialized); + assertEquals("value", deserialized); + assertEquals( + List.of( + "starting-to-bytes", + "first-serialize", + "second-serialize", + "ending-from-bytes", + "ending-to-bytes", + "second-deserialize", + "first-deserialize", + "starting-from-bytes"), + calls); + } + + @Test + void passesTheSameContextToEveryBinaryStageCall() { + var serializeContext = new AtomicReference(); + var deserializeContext = new AtomicReference(); + var stage = ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .then(new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + serializeContext.set(context); + return value; + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + deserializeContext.set(context); + return data; + } + }) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + + var serialized = stage.serialize("value", CONTEXT); + stage.deserialize(serialized, CONTEXT); + + assertSame(CONTEXT, serializeContext.get()); + assertSame(CONTEXT, deserializeContext.get()); + } + + @Test + void receivesTheOriginalValueFromTheRootPipeline() { + var serializeContext = new AtomicReference(); + var binaryStage = ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .then(new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + serializeContext.set(context); + return value; + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + return data; + } + }) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + var pipeline = new JacksonSerDes().then(binaryStage); + var originalValue = Map.of("id", 42); + + new SerDesRunner(null).serialize(pipeline, originalValue, CONTEXT); + + assertSame(originalValue, serializeContext.get().originalValue()); + } + + @Test + void composesWithRootSerDesAsOneStringStage() { + var stage = ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .then(xorStage((byte) 0x5A)) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + var pipeline = new JacksonSerDes().then(stage); + + var serialized = pipeline.serialize("value"); + + assertEquals("value", pipeline.deserialize(serialized, TypeToken.get(String.class))); + } + + @Test + void supportsCustomCodecsAtBothBoundaries() { + var reverseUtf8 = new StringBinaryCodec() { + @Override + public byte[] toBytes(String value) { + var bytes = value.getBytes(StandardCharsets.UTF_8); + reverse(bytes); + return bytes; + } + + @Override + public String fromBytes(byte[] data) { + var copy = Arrays.copyOf(data, data.length); + reverse(copy); + return new String(copy, StandardCharsets.UTF_8); + } + }; + var stage = ComposableBinarySerDesStage.builder() + .startWith(reverseUtf8) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + + var serialized = stage.serialize("value", CONTEXT); + + assertEquals( + BINARY_FRAME_PREFIX + Base64.getEncoder().encodeToString("eulav".getBytes(StandardCharsets.UTF_8)), + serialized); + assertEquals("value", stage.deserialize(serialized, CONTEXT)); + } + + @Test + void passesThroughUnrecognizedInputAndRejectsInvalidFrames() { + var stage = ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + + assertEquals("\"external\"", stage.deserialize("\"external\"", CONTEXT)); + assertThrows(SerDesException.class, () -> stage.deserialize(BINARY_FRAME_MARKER + "2:value", CONTEXT)); + assertThrows(SerDesException.class, () -> stage.deserialize(BINARY_FRAME_PREFIX + "not-base64!", CONTEXT)); + } + + @Test + void validatesConfigurationAndComponentResults() { + assertThrows(NullPointerException.class, () -> ComposableBinarySerDesStage.builder() + .startWith(null)); + assertThrows(NullPointerException.class, () -> ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .then(null)); + assertThrows(NullPointerException.class, () -> ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .endWith(null)); + + var nullStage = ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .then(new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + return null; + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + return data; + } + }) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + + var failure = assertThrows(SerDesException.class, () -> nullStage.serialize("value", CONTEXT)); + assertTrue(failure.getMessage().contains("binary stage 0")); + } + + @Test + void preservesRetryableFailuresAndFatalErrors() { + var retryableStage = ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .then(new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + throw new RetryableSerDesException("retry"); + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + return data; + } + }) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + + var retryable = assertThrows(RetryableSerDesException.class, () -> retryableStage.serialize("value", CONTEXT)); + assertTrue(retryable.getMessage().contains("binary stage 0")); + + var fatalError = new AssertionError("fatal"); + var fatalStage = ComposableBinarySerDesStage.builder() + .startWith(new StringBinaryCodec() { + @Override + public byte[] toBytes(String value) { + throw fatalError; + } + + @Override + public String fromBytes(byte[] data) { + return ""; + } + }) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + + assertSame(fatalError, assertThrows(AssertionError.class, () -> fatalStage.serialize("value", CONTEXT))); + } + + @Test + void providedCodecsRoundTrip() { + var value = "hello λ"; + var bytes = Utf8StringBinaryCodec.INSTANCE.toBytes(value); + + assertEquals(value, Utf8StringBinaryCodec.INSTANCE.fromBytes(bytes)); + assertArrayEquals( + bytes, Base64StringBinaryCodec.INSTANCE.toBytes(Base64StringBinaryCodec.INSTANCE.fromBytes(bytes))); + } + + @Test + void utf8CodecRejectsLossyConversions() { + assertThrows(SerDesException.class, () -> Utf8StringBinaryCodec.INSTANCE.toBytes("lone surrogate \uD800")); + assertThrows( + SerDesException.class, + () -> Utf8StringBinaryCodec.INSTANCE.fromBytes(new byte[] {(byte) 0xC3, (byte) 0x28})); + } + + private static StringBinaryCodec recordingCodec(String name, List calls) { + return new StringBinaryCodec() { + @Override + public byte[] toBytes(String value) { + calls.add(name + "-to-bytes"); + return name.equals("ending") + ? Base64.getDecoder().decode(value) + : value.getBytes(StandardCharsets.UTF_8); + } + + @Override + public String fromBytes(byte[] data) { + calls.add(name + "-from-bytes"); + return name.equals("ending") + ? Base64.getEncoder().encodeToString(data) + : new String(data, StandardCharsets.UTF_8); + } + }; + } + + private static BinarySerDesStage appendingStage(String name, byte suffix, List calls) { + return new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + calls.add(name + "-serialize"); + var result = Arrays.copyOf(value, value.length + 1); + result[value.length] = suffix; + return result; + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + calls.add(name + "-deserialize"); + if (data.length == 0 || data[data.length - 1] != suffix) { + throw new SerDesException("Unexpected suffix"); + } + return Arrays.copyOf(data, data.length - 1); + } + }; + } + + private static BinarySerDesStage xorStage(byte key) { + return new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + return xor(value, key); + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + return xor(data, key); + } + }; + } + + private static byte[] xor(byte[] value, byte key) { + var result = Arrays.copyOf(value, value.length); + for (int index = 0; index < result.length; index++) { + result[index] ^= key; + } + return result; + } + + private static void reverse(byte[] value) { + for (int left = 0, right = value.length - 1; left < right; left++, right--) { + var temporary = value[left]; + value[left] = value[right]; + value[right] = temporary; + } + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/serde/ComposableSerDesTest.java b/sdk/src/test/java/software/amazon/lambda/durable/serde/ComposableSerDesTest.java new file mode 100644 index 000000000..7b0972580 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/serde/ComposableSerDesTest.java @@ -0,0 +1,308 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.exception.SerDesException; + +class ComposableSerDesTest { + + @Test + void exposesStringStageCompositionThroughTheSerDesInterface() throws Exception { + assertThrows(NoSuchMethodException.class, () -> SerDes.class.getMethod("then", SerDes.class)); + assertEquals( + SerDes.class, SerDes.class.getMethod("then", SerDesStage.class).getReturnType()); + assertEquals( + SerDes.class, + ComposableSerDes.class.getMethod("then", SerDesStage.class).getReturnType()); + } + + @Test + void serializesForwardAndDeserializesInReverse() { + var calls = new ArrayList(); + var first = stringStage("first", "<", ">", calls); + var second = stringStage("second", "[", "]", calls); + var pipeline = new JacksonSerDes().then(first).then(second); + + var serialized = pipeline.serialize("value"); + var deserialized = pipeline.deserialize(serialized, TypeToken.get(String.class)); + + assertEquals("[<\"value\">]", serialized); + assertEquals("value", deserialized); + assertEquals(List.of("first-serialize", "second-serialize", "second-deserialize", "first-deserialize"), calls); + } + + @Test + void passesTheRunnerContextExplicitlyToEveryStageCall() { + var observedSerializeContext = new AtomicReference(); + var observedDeserializeContext = new AtomicReference(); + var stage = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + observedSerializeContext.set(context); + return value; + } + + @Override + public String deserialize(String data, SerDesContext context) { + observedDeserializeContext.set(context); + return data; + } + }; + var context = SerDesContext.forExecution("arn", "invocation", "execution", SerDesPayloadKind.RESULT); + var pipeline = new JacksonSerDes().then(stage); + var runner = new SerDesRunner(null); + var originalValue = new String("value"); + + var serialized = runner.serialize(pipeline, originalValue, context); + runner.deserialize(pipeline, serialized, TypeToken.get(String.class), context); + + assertSame(originalValue, observedSerializeContext.get().originalValue()); + assertEquals(context.entityId(), observedSerializeContext.get().entityId()); + assertSame(context, observedDeserializeContext.get()); + assertNull(observedDeserializeContext.get().originalValue()); + } + + @Test + void supportsDedicatedStringStages() { + var calls = new ArrayList(); + var first = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + calls.add("first-serialize"); + return "<" + value + ">"; + } + + @Override + public String deserialize(String data, SerDesContext context) { + calls.add("first-deserialize"); + return data.substring(1, data.length() - 1); + } + }; + var second = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + calls.add("second-serialize"); + return "[" + value + "]"; + } + + @Override + public String deserialize(String data, SerDesContext context) { + calls.add("second-deserialize"); + return data.substring(1, data.length() - 1); + } + }; + var pipeline = new JacksonSerDes().then(first).then(second); + + var serialized = pipeline.serialize("value"); + var deserialized = pipeline.deserialize(serialized, TypeToken.get(String.class)); + + assertEquals("[<\"value\">]", serialized); + assertEquals("value", deserialized); + assertEquals(List.of("first-serialize", "second-serialize", "second-deserialize", "first-deserialize"), calls); + } + + @Test + void factoryBuilderAndThenFlattenRootPipeline() { + var calls = new ArrayList(); + var nested = ComposableSerDes.builder(new JacksonSerDes()) + .then(stringStage("one", "1", "1", calls)) + .build(); + var pipeline = ComposableSerDes.of(nested).then(stringStage("two", "2", "2", calls)); + + assertEquals("21\"value\"12", pipeline.serialize("value")); + assertEquals(List.of("one-serialize", "two-serialize"), calls); + } + + @Test + void nullBoundarySkipsEveryStage() { + var calls = new AtomicInteger(); + var stage = new SerDes() { + @Override + public String serialize(Object value) { + calls.incrementAndGet(); + return "unexpected"; + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + calls.incrementAndGet(); + return null; + } + }; + var pipeline = ComposableSerDes.of(stage); + + assertNull(pipeline.serialize(null)); + assertNull(pipeline.deserialize(null, TypeToken.get(String.class))); + assertEquals(0, calls.get()); + } + + @Test + void valueCodecMayDecodeNonNullRepresentationToNull() { + var intermediateCalls = new AtomicInteger(); + var identityStage = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return value; + } + + @Override + public String deserialize(String data, SerDesContext context) { + intermediateCalls.incrementAndGet(); + return data; + } + }; + var pipeline = new JacksonSerDes().then(identityStage); + + assertNull(pipeline.deserialize("null", TypeToken.get(Object.class))); + assertEquals(1, intermediateCalls.get()); + } + + @Test + void unrecognizedInputPassesThroughEveryStage() { + var calls = new ArrayList(); + var pipeline = new JacksonSerDes() + .then(stringStage("first", "<", ">", calls)) + .then(stringStage("second", "[", "]", calls)); + + assertEquals("value", pipeline.deserialize("\"value\"", TypeToken.get(String.class))); + assertEquals(List.of("second-deserialize", "first-deserialize"), calls); + } + + @Test + void recognizedMalformedInputFailsAtTheOwningStage() { + var pipeline = new JacksonSerDes().then(stringStage("framed", "<", ">", new ArrayList<>())); + + var failure = assertThrows( + SerDesException.class, () -> pipeline.deserialize("<\"value\"", TypeToken.get(String.class))); + + assertTrue(failure.getMessage().contains("stage 1")); + assertTrue(failure.getCause().getMessage().contains("Malformed framed stage value")); + } + + @Test + void rejectsNullIntermediateValues() { + var nullStage = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return null; + } + + @Override + public String deserialize(String data, SerDesContext context) { + return null; + } + }; + var nullFailure = assertThrows( + SerDesException.class, () -> new JacksonSerDes().then(nullStage).serialize("value")); + assertTrue(nullFailure.getMessage().contains("stage 1")); + assertTrue(nullFailure.getMessage().contains(nullStage.getClass().getName())); + } + + @Test + void preservesRetryabilityWhenDecoratingStageFailures() { + var transientStage = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + throw new RetryableSerDesException("retry"); + } + + @Override + public String deserialize(String data, SerDesContext context) { + return null; + } + }; + + var failure = assertThrows( + RetryableSerDesException.class, + () -> new JacksonSerDes().then(transientStage).serialize("value")); + + assertInstanceOf(RetryableSerDesException.class, failure.getCause()); + assertTrue(failure.getMessage().contains("stage 1")); + } + + @Test + void preservesFatalErrorsFromEveryPipelineCall() { + var serializeError = new OutOfMemoryError("serialize"); + var serializeStage = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + throw serializeError; + } + + @Override + public String deserialize(String data, SerDesContext context) { + return null; + } + }; + assertSame(serializeError, assertThrows(OutOfMemoryError.class, () -> new JacksonSerDes() + .then(serializeStage) + .serialize("value"))); + + var stringStageError = new StackOverflowError("string-stage-deserialize"); + var stringStage = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return value; + } + + @Override + public String deserialize(String data, SerDesContext context) { + throw stringStageError; + } + }; + assertSame(stringStageError, assertThrows(StackOverflowError.class, () -> new JacksonSerDes() + .then(stringStage) + .deserialize("value", TypeToken.get(String.class)))); + + var valueCodecError = new AssertionError("value-codec-deserialize"); + var valueCodec = new SerDes() { + @Override + public String serialize(Object value) { + return value.toString(); + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + throw valueCodecError; + } + }; + assertSame(valueCodecError, assertThrows(AssertionError.class, () -> ComposableSerDes.of(valueCodec) + .deserialize("value", TypeToken.get(String.class)))); + } + + private static SerDesStage stringStage(String name, String prefix, String suffix, List calls) { + return new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + calls.add(name + "-serialize"); + return prefix + value + suffix; + } + + @Override + public String deserialize(String data, SerDesContext context) { + calls.add(name + "-deserialize"); + if (!data.startsWith(prefix)) { + return data; + } + if (!data.endsWith(suffix) || data.length() < prefix.length() + suffix.length()) { + throw new SerDesException("Malformed " + name + " stage value"); + } + return data.substring(prefix.length(), data.length() - suffix.length()); + } + }; + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/serde/RetryBinarySerDesStageTest.java b/sdk/src/test/java/software/amazon/lambda/durable/serde/RetryBinarySerDesStageTest.java new file mode 100644 index 000000000..2739c61c6 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/serde/RetryBinarySerDesStageTest.java @@ -0,0 +1,193 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.exception.SerDesException; +import software.amazon.lambda.durable.retry.RetryDecision; +import software.amazon.lambda.durable.retry.RetryStrategies; + +class RetryBinarySerDesStageTest { + private static final Object ORIGINAL_VALUE = new Object(); + private static final SerDesContext CONTEXT = SerDesContext.forExecution( + "arn", "invocation", "execution", SerDesPayloadKind.RESULT) + .withOriginalValue(ORIGINAL_VALUE); + + @Test + void retriesSerializationAndPreservesContext() { + var calls = new AtomicInteger(); + var observedContext = new AtomicReference(); + var delegate = new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + observedContext.set(context); + if (calls.incrementAndGet() == 1) { + throw new RetryableSerDesException("transient"); + } + return value; + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + return data; + } + }; + var stage = new RetryBinarySerDesStage( + delegate, (error, attempt) -> RetryDecision.retry(Duration.ZERO), delay -> {}); + var value = new byte[] {1, 2, 3}; + + assertArrayEquals(value, stage.serialize(value, CONTEXT)); + assertSame(CONTEXT, observedContext.get()); + assertSame(ORIGINAL_VALUE, observedContext.get().originalValue()); + } + + @Test + void retriesDeserialization() { + var calls = new AtomicInteger(); + var delegate = new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + return value; + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + if (calls.incrementAndGet() == 1) { + throw new RetryableSerDesException("transient"); + } + return data; + } + }; + var stage = new RetryBinarySerDesStage( + delegate, (error, attempt) -> RetryDecision.retry(Duration.ZERO), delay -> {}); + var value = new byte[] {1, 2, 3}; + + assertArrayEquals(value, stage.deserialize(value, CONTEXT)); + } + + @Test + void retriesSerializationWithFreshInputForEveryAttempt() { + var calls = new AtomicInteger(); + var delegate = new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + value[0]++; + if (calls.incrementAndGet() == 1) { + throw new RetryableSerDesException("transient"); + } + return value; + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + return data; + } + }; + var stage = new RetryBinarySerDesStage( + delegate, (error, attempt) -> RetryDecision.retry(Duration.ZERO), delay -> {}); + var value = new byte[] {1, 2, 3}; + + assertArrayEquals(new byte[] {2, 2, 3}, stage.serialize(value, CONTEXT)); + assertArrayEquals(new byte[] {1, 2, 3}, value); + } + + @Test + void retriesDeserializationWithFreshInputForEveryAttempt() { + var calls = new AtomicInteger(); + var delegate = new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + return value; + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + data[0]--; + if (calls.incrementAndGet() == 1) { + throw new RetryableSerDesException("transient"); + } + return data; + } + }; + var stage = new RetryBinarySerDesStage( + delegate, (error, attempt) -> RetryDecision.retry(Duration.ZERO), delay -> {}); + var value = new byte[] {3, 2, 1}; + + assertArrayEquals(new byte[] {2, 2, 1}, stage.deserialize(value, CONTEXT)); + assertArrayEquals(new byte[] {3, 2, 1}, value); + } + + @Test + void doesNotRetryPermanentFailures() { + var calls = new AtomicInteger(); + var delegate = new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + calls.incrementAndGet(); + throw new SerDesException("permanent"); + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + return data; + } + }; + var stage = + new RetryBinarySerDesStage(delegate, (error, attempt) -> RetryDecision.retry(Duration.ZERO), delay -> { + throw new AssertionError("permanent failures must not sleep"); + }); + + assertThrows(SerDesException.class, () -> stage.serialize(new byte[0], CONTEXT)); + assertEquals(1, calls.get()); + } + + @Test + void validatesConfigurationAndRethrowsExhaustedFailure() { + var delegate = identityStage(); + assertThrows( + NullPointerException.class, () -> new RetryBinarySerDesStage(null, RetryStrategies.Presets.NO_RETRY)); + assertThrows(NullPointerException.class, () -> new RetryBinarySerDesStage(delegate, null)); + assertFalse(SerDesStage.class.isAssignableFrom(RetryBinarySerDesStage.class)); + + var retryable = new RetryableSerDesException("transient"); + var failingDelegate = new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + throw retryable; + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + return data; + } + }; + var stage = new RetryBinarySerDesStage(failingDelegate, RetryStrategies.Presets.NO_RETRY, delay -> {}); + + assertSame( + retryable, assertThrows(RetryableSerDesException.class, () -> stage.serialize(new byte[0], CONTEXT))); + } + + private static BinarySerDesStage identityStage() { + return new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + return value; + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + return data; + } + }; + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/serde/RetrySerDesStageTest.java b/sdk/src/test/java/software/amazon/lambda/durable/serde/RetrySerDesStageTest.java new file mode 100644 index 000000000..588a6be12 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/serde/RetrySerDesStageTest.java @@ -0,0 +1,208 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.exception.SerDesException; +import software.amazon.lambda.durable.retry.RetryDecision; +import software.amazon.lambda.durable.retry.RetryStrategies; + +class RetrySerDesStageTest { + private static final Object ORIGINAL_VALUE = new Object(); + private static final SerDesContext CONTEXT = SerDesContext.forExecution( + "arn", "invocation", "execution", SerDesPayloadKind.RESULT) + .withOriginalValue(ORIGINAL_VALUE); + + @Test + void retriesSerializationWithStrategyDelays() { + var calls = new AtomicInteger(); + var strategyAttempts = new ArrayList(); + var delays = new ArrayList(); + var delegate = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + assertSame(CONTEXT, context); + assertSame(ORIGINAL_VALUE, context.originalValue()); + if (calls.incrementAndGet() < 3) { + throw new RetryableSerDesException("transient"); + } + return "serialized"; + } + + @Override + public String deserialize(String data, SerDesContext context) { + return data; + } + }; + var retrySerDes = new RetrySerDesStage( + delegate, + (error, attempt) -> { + strategyAttempts.add(attempt); + return RetryDecision.retry(Duration.ofMillis(attempt)); + }, + delays::add); + + assertEquals("serialized", retrySerDes.serialize("value", CONTEXT)); + assertEquals(3, calls.get()); + assertEquals(List.of(1, 2), strategyAttempts); + assertEquals(List.of(Duration.ofMillis(1), Duration.ofMillis(2)), delays); + } + + @Test + void retriesDeserialization() { + var calls = new AtomicInteger(); + var delegate = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return value; + } + + @Override + public String deserialize(String data, SerDesContext context) { + assertSame(CONTEXT, context); + if (calls.incrementAndGet() == 1) { + throw new RetryableSerDesException("transient"); + } + return data; + } + }; + var retrySerDes = + new RetrySerDesStage(delegate, (error, attempt) -> RetryDecision.retry(Duration.ZERO), delay -> {}); + + assertEquals("value", retrySerDes.deserialize("value", CONTEXT)); + assertEquals(2, calls.get()); + } + + @Test + void doesNotRetryPermanentSerDesFailure() { + var calls = new AtomicInteger(); + var delegate = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + calls.incrementAndGet(); + throw new SerDesException("permanent"); + } + + @Override + public String deserialize(String data, SerDesContext context) { + return data; + } + }; + var retrySerDes = + new RetrySerDesStage(delegate, (error, attempt) -> RetryDecision.retry(Duration.ZERO), delay -> { + throw new AssertionError("permanent failures must not sleep"); + }); + + var failure = assertThrows(SerDesException.class, () -> retrySerDes.serialize("value", CONTEXT)); + assertEquals("permanent", failure.getMessage()); + assertEquals(1, calls.get()); + } + + @Test + void rejectsInvalidConfigurationAndRetryDelay() { + var delegate = identityStage(); + + assertThrows(NullPointerException.class, () -> new RetrySerDesStage(null, RetryStrategies.Presets.NO_RETRY)); + assertThrows(NullPointerException.class, () -> new RetrySerDesStage(delegate, null)); + assertFalse(SerDes.class.isAssignableFrom(RetrySerDesStage.class)); + + var retrySerDes = new RetrySerDesStage( + new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + throw new RetryableSerDesException("transient"); + } + + @Override + public String deserialize(String data, SerDesContext context) { + return data; + } + }, + (error, attempt) -> RetryDecision.retry(Duration.ofSeconds(-1)), + delay -> {}); + + var failure = assertThrows(SerDesException.class, () -> retrySerDes.serialize("value", CONTEXT)); + assertTrue(failure.getMessage().contains("invalid delay")); + } + + @Test + void rethrowsLastRetryableFailureWhenRetriesAreExhausted() { + var calls = new AtomicInteger(); + var lastFailure = new AtomicReference(); + var delegate = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + var failure = new RetryableSerDesException("attempt-" + calls.incrementAndGet()); + lastFailure.set(failure); + throw failure; + } + + @Override + public String deserialize(String data, SerDesContext context) { + return data; + } + }; + var retrySerDes = + new RetrySerDesStage(delegate, RetryStrategies.fixedDelay(2, Duration.ofSeconds(1)), delay -> {}); + + var thrown = assertThrows(RetryableSerDesException.class, () -> retrySerDes.serialize("value", CONTEXT)); + assertSame(lastFailure.get(), thrown); + assertEquals("attempt-2", thrown.getMessage()); + assertEquals(2, calls.get()); + } + + @Test + void restoresInterruptStatusWhenBackoffIsInterrupted() { + var retryable = new RetryableSerDesException("transient"); + var delegate = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + throw retryable; + } + + @Override + public String deserialize(String data, SerDesContext context) { + return data; + } + }; + var retrySerDes = new RetrySerDesStage( + delegate, (error, attempt) -> RetryDecision.retry(Duration.ofSeconds(1)), delay -> { + throw new InterruptedException("stop"); + }); + + try { + var thrown = assertThrows(SerDesException.class, () -> retrySerDes.serialize("value", CONTEXT)); + assertTrue(thrown.getMessage().contains("Interrupted")); + assertTrue(Thread.currentThread().isInterrupted()); + assertSame(retryable, thrown.getSuppressed()[0]); + } finally { + Thread.interrupted(); + } + } + + private static SerDesStage identityStage() { + return new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return value; + } + + @Override + public String deserialize(String data, SerDesContext context) { + return data; + } + }; + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/serde/SerDesRunnerTest.java b/sdk/src/test/java/software/amazon/lambda/durable/serde/SerDesRunnerTest.java new file mode 100644 index 000000000..db53d4282 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/serde/SerDesRunnerTest.java @@ -0,0 +1,329 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.lambda.model.OperationType; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.exception.SerDesException; +import software.amazon.lambda.durable.model.OperationSubType; + +class SerDesRunnerTest { + private final java.util.concurrent.ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + var thread = new Thread(r); + thread.setName("test-serdes"); + return thread; + }); + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void passesContextToStagesInsideExecutor() { + var observedContext = new AtomicReference(); + var observedThread = new AtomicReference(); + var stage = new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + observedContext.set(context); + observedThread.set(Thread.currentThread().getName()); + return value; + } + + @Override + public String deserialize(String data, SerDesContext context) { + return data; + } + }; + var context = context("operation/1/result"); + + new SerDesRunner(executor).serialize(new JacksonSerDes().then(stage), "value", context); + + assertNotSame(context, observedContext.get()); + assertEquals(context.entityId(), observedContext.get().entityId()); + assertEquals("value", observedContext.get().originalValue()); + assertEquals("test-serdes", observedThread.get()); + } + + @Test + void executesInlineWhenNoExecutorIsConfigured() { + var observedThread = new AtomicReference(); + var serDes = new JacksonSerDes() { + @Override + public String serialize(Object value) { + observedThread.set(Thread.currentThread()); + return super.serialize(value); + } + }; + + new SerDesRunner(null).serialize(serDes, "value", context("operation/1/result")); + + assertSame(Thread.currentThread(), observedThread.get()); + } + + @Test + void doesNotExposeThreadLocalContextAccessor() { + assertThrows(NoSuchMethodException.class, () -> SerDesContext.class.getMethod("getCurrentContext")); + } + + @Test + void cachesByEntityTypeAndSerializedDataHash() { + var count = new AtomicInteger(); + var delegate = new JacksonSerDes(); + var serDes = new SerDes() { + @Override + public String serialize(Object value) { + return delegate.serialize(value); + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + count.incrementAndGet(); + return delegate.deserialize(data, typeToken); + } + }; + var runner = new SerDesRunner(executor); + var context = context("operation/1/result"); + + assertEquals("one", runner.deserialize(serDes, "\"one\"", TypeToken.get(String.class), context)); + assertEquals("one", runner.deserialize(serDes, "\"one\"", TypeToken.get(String.class), context)); + assertEquals("two", runner.deserialize(serDes, "\"two\"", TypeToken.get(String.class), context)); + var nextAttempt = new SerDesContext( + context.durableExecutionArn(), + context.entityId(), + context.payloadKind(), + context.operationId(), + context.operationName(), + context.parentId(), + context.operationType(), + context.operationSubType(), + 2, + null); + assertEquals("one", runner.deserialize(serDes, "\"one\"", TypeToken.get(String.class), nextAttempt)); + + assertEquals(3, count.get()); + } + + @Test + void cacheKeyIncludesSerDesIdentity() { + var runner = new SerDesRunner(null); + var context = context("operation/1/result"); + var first = fixedValueSerDes("first"); + var second = fixedValueSerDes("second"); + + assertEquals("first", runner.deserialize(first, "\"value\"", TypeToken.get(String.class), context)); + assertEquals("second", runner.deserialize(second, "\"value\"", TypeToken.get(String.class), context)); + } + + @Test + void completedCacheEvictsOldestEntries() { + var calls = new AtomicInteger(); + var serDes = new SerDes() { + @Override + public String serialize(Object value) { + return value.toString(); + } + + @Override + @SuppressWarnings("unchecked") + public T deserialize(String data, TypeToken typeToken) { + calls.incrementAndGet(); + return (T) data; + } + }; + var runner = new SerDesRunner(null); + var retainedValues = new ArrayList(); + + for (int index = 0; index <= SerDesRunner.MAX_COMPLETED_DESERIALIZATIONS; index++) { + retainedValues.add(runner.deserialize( + serDes, "value-" + index, TypeToken.get(String.class), context("operation/" + index + "/result"))); + } + assertEquals(SerDesRunner.MAX_COMPLETED_DESERIALIZATIONS + 1, calls.get()); + + assertEquals( + retainedValues.get(0), + runner.deserialize(serDes, "value-0", TypeToken.get(String.class), context("operation/0/result"))); + assertEquals(SerDesRunner.MAX_COMPLETED_DESERIALIZATIONS + 2, calls.get()); + } + + @Test + void concurrentCacheMissesDeserializeOnlyOnce() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var count = new AtomicInteger(); + var sharedValue = new Object(); + var serDes = new SerDes() { + @Override + public String serialize(Object value) { + return value.toString(); + } + + @Override + @SuppressWarnings("unchecked") + public T deserialize(String data, TypeToken typeToken) { + count.incrementAndGet(); + entered.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SerDesException("interrupted", e); + } + return (T) sharedValue; + } + }; + var runner = new SerDesRunner(null); + var callers = Executors.newFixedThreadPool(8); + try { + var futures = new ArrayList>(); + for (int index = 0; index < 8; index++) { + futures.add(CompletableFuture.supplyAsync( + () -> runner.deserialize( + serDes, "data", TypeToken.get(Object.class), context("operation/1/result")), + callers)); + } + entered.await(); + release.countDown(); + + for (var future : futures) { + assertSame(sharedValue, future.join()); + } + assertEquals(1, count.get()); + } finally { + release.countDown(); + callers.shutdownNow(); + } + } + + @Test + void failedDeserializationIsRemovedFromCache() { + var calls = new AtomicInteger(); + var serDes = new JacksonSerDes() { + @Override + public T deserialize(String data, TypeToken typeToken) { + if (calls.incrementAndGet() == 1) { + throw new SerDesException("first"); + } + return super.deserialize(data, typeToken); + } + }; + var runner = new SerDesRunner(null); + var context = context("operation/1/result"); + + assertThrows( + SerDesException.class, + () -> runner.deserialize(serDes, "\"value\"", TypeToken.get(String.class), context)); + assertEquals("value", runner.deserialize(serDes, "\"value\"", TypeToken.get(String.class), context)); + assertEquals(2, calls.get()); + } + + @Test + void wrapsFailuresWithPayloadMetadata() { + var runner = new SerDesRunner(executor); + var serDes = new SerDes() { + @Override + public String serialize(Object value) { + throw new IllegalStateException("boom"); + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + return null; + } + }; + + var exception = assertThrows(SerDesException.class, () -> runner.serialize(serDes, "value", context("entity"))); + + assertTrue(exception.getMessage().contains("RESULT")); + assertTrue(exception.getMessage().contains("entity")); + assertEquals("boom", exception.getCause().getMessage()); + } + + @Test + void preservesRetryableFailureType() { + var runner = new SerDesRunner(null); + var serDes = new SerDes() { + @Override + public String serialize(Object value) { + throw new RetryableSerDesException("transient"); + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + return null; + } + }; + + var exception = assertThrows( + RetryableSerDesException.class, () -> runner.serialize(serDes, "value", context("entity"))); + + assertInstanceOf(RetryableSerDesException.class, exception.getCause()); + } + + @Test + void preservesFatalErrorsWithAndWithoutExecutor() { + var fatal = new OutOfMemoryError("fatal"); + var serDes = new SerDes() { + @Override + public String serialize(Object value) { + throw fatal; + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + return null; + } + }; + + assertSame(fatal, assertThrows(OutOfMemoryError.class, () -> new SerDesRunner(null) + .serialize(serDes, "value", context("entity")))); + assertSame(fatal, assertThrows(OutOfMemoryError.class, () -> new SerDesRunner(executor) + .serialize(serDes, "value", context("entity")))); + } + + private static SerDes fixedValueSerDes(String value) { + return new SerDes() { + @Override + public String serialize(Object input) { + return input.toString(); + } + + @Override + @SuppressWarnings("unchecked") + public T deserialize(String data, TypeToken typeToken) { + return (T) value; + } + }; + } + + private static SerDesContext context(String entityId) { + return new SerDesContext( + "arn:test", + entityId, + SerDesPayloadKind.RESULT, + "1", + "step", + null, + OperationType.STEP, + OperationSubType.STEP, + 1, + null); + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/serde/filesystem/FileSystemSerDesStageTest.java b/sdk/src/test/java/software/amazon/lambda/durable/serde/filesystem/FileSystemSerDesStageTest.java new file mode 100644 index 000000000..9475a78bd --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/serde/filesystem/FileSystemSerDesStageTest.java @@ -0,0 +1,900 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.filesystem; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.awssdk.services.lambda.model.OperationType; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.exception.RetryableSerDesException; +import software.amazon.lambda.durable.exception.SerDesException; +import software.amazon.lambda.durable.model.OperationSubType; +import software.amazon.lambda.durable.retry.RetryDecision; +import software.amazon.lambda.durable.retry.RetryStrategies; +import software.amazon.lambda.durable.serde.Base64StringBinaryCodec; +import software.amazon.lambda.durable.serde.BinarySerDesStage; +import software.amazon.lambda.durable.serde.ComposableBinarySerDesStage; +import software.amazon.lambda.durable.serde.JacksonSerDes; +import software.amazon.lambda.durable.serde.RetrySerDesStage; +import software.amazon.lambda.durable.serde.SerDes; +import software.amazon.lambda.durable.serde.SerDesContext; +import software.amazon.lambda.durable.serde.SerDesPayloadKind; +import software.amazon.lambda.durable.serde.SerDesRunner; +import software.amazon.lambda.durable.serde.SerDesStage; +import software.amazon.lambda.durable.serde.Utf8StringBinaryCodec; + +class FileSystemSerDesStageTest { + private static final String ARN = + "arn:aws:lambda:us-east-1:123456789012:function:orders:1/durable-execution/execution-1/invocation-1"; + private static final String ENVELOPE_MARKER = "__durable_execution_filesystem_serdes"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TempDir + Path basePath; + + @Test + void writesValueCodecPayloadAndReplaysIt() throws Exception { + var serDes = + new JacksonSerDes().then(FileSystemSerDesStage.builder(basePath).build()); + var runner = new SerDesRunner(null); + + var envelope = runner.serialize(serDes, Map.of("id", 42), context()); + var json = MAPPER.readTree(envelope); + var file = Path.of(json.get("file").textValue()); + + assertEquals(1, json.get(ENVELOPE_MARKER).intValue()); + assertEquals("STRING", json.get("payloadType").textValue()); + assertEquals( + sha256("{\"id\":42}".getBytes(StandardCharsets.UTF_8)), + json.get("payloadDigest").textValue()); + assertTrue(file.startsWith(basePath.resolve("orders/execution-1/invocation-1"))); + assertEquals("{\"id\":42}", Files.readString(file)); + assertEquals( + Map.of("id", 42), + runner.deserialize(serDes, envelope, new TypeToken>() {}, context())); + } + + @Test + void isAStageThatCanBeFollowedByOtherStages() { + var stage = FileSystemSerDesStage.builder(basePath).build(); + var pipeline = new JacksonSerDes().then(stage).then(wrappingStage()); + var runner = new SerDesRunner(null); + + assertFalse(SerDes.class.isAssignableFrom(FileSystemSerDesStage.class)); + var checkpoint = runner.serialize(pipeline, Map.of("id", 42), context()); + assertTrue(checkpoint.startsWith("<")); + assertEquals( + Map.of("id", 42), + runner.deserialize(pipeline, checkpoint, new TypeToken>() {}, context())); + } + + @Test + void retryDecoratorComposesAsAFileSystemStage() { + var stage = FileSystemSerDesStage.builder(basePath) + .storageMode(FileSystemStorageMode.OVERFLOW) + .build(); + var pipeline = new JacksonSerDes().then(new RetrySerDesStage(stage, RetryStrategies.Presets.NO_RETRY)); + var runner = new SerDesRunner(null); + + var envelope = runner.serialize(pipeline, Map.of("id", 42), context()); + + assertEquals( + Map.of("id", 42), + runner.deserialize(pipeline, envelope, new TypeToken>() {}, context())); + } + + @Test + void storesAndRestoresComposableBinaryOutput() throws Exception { + var binaryStage = ComposableBinarySerDesStage.builder() + .startWith(Utf8StringBinaryCodec.INSTANCE) + .then(xorBinaryStage((byte) 0x5A)) + .endWith(Base64StringBinaryCodec.INSTANCE) + .build(); + var stage = FileSystemSerDesStage.builder(basePath).build(); + var pipeline = new JacksonSerDes().then(binaryStage).then(stage); + var runner = new SerDesRunner(null); + + var envelope = runner.serialize(pipeline, Map.of("id", 42), context()); + var json = MAPPER.readTree(envelope); + var file = Path.of(json.get("file").textValue()); + + assertEquals("STRING", json.get("payloadType").textValue()); + assertEquals( + "__durable_execution_composable_binary_serdes:1:" + + Base64.getEncoder() + .encodeToString(xor("{\"id\":42}".getBytes(StandardCharsets.UTF_8), (byte) 0x5A)), + Files.readString(file)); + assertEquals( + Map.of("id", 42), + runner.deserialize(pipeline, envelope, new TypeToken>() {}, context())); + } + + @Test + void overflowModeKeepsSmallPayloadInlineAndWritesLargePayload() throws Exception { + var stage = FileSystemSerDesStage.builder(basePath) + .storageMode(FileSystemStorageMode.OVERFLOW) + .build(); + var serDes = stringCodec().then(stage); + var runner = new SerDesRunner(null); + + var inline = runner.serialize(serDes, "small", context()); + assertTrue(MAPPER.readTree(inline).has("data")); + + var overflow = runner.serialize(serDes, "x".repeat(256 * 1024), context()); + assertTrue(MAPPER.readTree(overflow).has("file")); + } + + @Test + void checkpointEnvelopeLimitCanBeIncreasedForLargerInlinePayloads() throws Exception { + var value = "x".repeat(300 * 1024); + var runner = new SerDesRunner(null); + var defaultPipeline = stringCodec() + .then(FileSystemSerDesStage.builder(basePath) + .storageMode(FileSystemStorageMode.OVERFLOW) + .build()); + var largerEnvelopePipeline = stringCodec() + .then(FileSystemSerDesStage.builder(basePath) + .storageMode(FileSystemStorageMode.OVERFLOW) + .checkpointEnvelopeLimitBytes(512 * 1024) + .build()); + + assertTrue(MAPPER.readTree(runner.serialize(defaultPipeline, value, context())) + .has("file")); + assertTrue(MAPPER.readTree(runner.serialize(largerEnvelopePipeline, value, context())) + .has("data")); + } + + @Test + void checkpointEnvelopeLimitMustBePositive() { + var zeroFailure = assertThrows(IllegalArgumentException.class, () -> FileSystemSerDesStage.builder(basePath) + .checkpointEnvelopeLimitBytes(0)); + var negativeFailure = assertThrows(IllegalArgumentException.class, () -> FileSystemSerDesStage.builder(basePath) + .checkpointEnvelopeLimitBytes(-1)); + + assertEquals("checkpointEnvelopeLimitBytes must be positive", zeroFailure.getMessage()); + assertEquals("checkpointEnvelopeLimitBytes must be positive", negativeFailure.getMessage()); + } + + @Test + void checkpointEnvelopeLimitAlsoAppliesToFileEnvelopes() { + var pipeline = stringCodec() + .then(FileSystemSerDesStage.builder(basePath) + .checkpointEnvelopeLimitBytes(1) + .build()); + + var failure = assertThrows( + SerDesException.class, () -> new SerDesRunner(null).serialize(pipeline, "value", context())); + + assertCauseMessage(failure, "checkpoint payload limit"); + } + + @Test + void immutableContentAddressedFilesPreservePriorCheckpoints() throws Exception { + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + var runner = new SerDesRunner(null); + + var firstContext = context(1); + var secondContext = context(2); + var firstEnvelope = runner.serialize(serDes, "state-one", firstContext); + var secondEnvelope = runner.serialize(serDes, "state-two", secondContext); + var firstFile = Path.of(MAPPER.readTree(firstEnvelope).get("file").textValue()); + var secondFile = Path.of(MAPPER.readTree(secondEnvelope).get("file").textValue()); + + assertNotEquals(firstFile, secondFile); + assertEquals("state-one", Files.readString(firstFile)); + assertEquals("state-two", Files.readString(secondFile)); + assertEquals("state-one", runner.deserialize(serDes, firstEnvelope, TypeToken.get(String.class), firstContext)); + } + + @Test + void repeatedPayloadsUseDistinctImmutableFiles() throws Exception { + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + var runner = new SerDesRunner(null); + var firstEnvelope = runner.serialize(serDes, "expected", context()); + var secondEnvelope = runner.serialize(serDes, "expected", context()); + var firstFile = payloadFile(firstEnvelope); + var secondFile = payloadFile(secondEnvelope); + + assertNotEquals(firstFile, secondFile); + assertEquals("expected", Files.readString(firstFile)); + assertEquals("expected", Files.readString(secondFile)); + } + + @Test + void includesPayloadDigestAndVerifiesFileIntegrity() throws Exception { + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + var runner = new SerDesRunner(null); + var envelope = runner.serialize(serDes, "expected", context()); + var json = (ObjectNode) MAPPER.readTree(envelope); + var expectedDigest = sha256("expected".getBytes(StandardCharsets.UTF_8)); + + assertEquals(expectedDigest, json.get("payloadDigest").textValue()); + + var tampered = "tampered".getBytes(StandardCharsets.UTF_8); + Files.write(payloadFile(envelope), tampered); + + var failure = assertThrows( + SerDesException.class, + () -> runner.deserialize(serDes, json.toString(), TypeToken.get(String.class), context())); + assertCauseMessage(failure, "payload digest does not match stored content"); + } + + @Test + void verifiesFilePathUsesEnvelopePayloadDigest() throws Exception { + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + var runner = new SerDesRunner(null); + var envelope = runner.serialize(serDes, "expected", context()); + var json = (ObjectNode) MAPPER.readTree(envelope); + var tampered = "tampered".getBytes(StandardCharsets.UTF_8); + var tamperedFile = contentHashedPath(payloadFile(envelope), tampered); + Files.write(tamperedFile, tampered); + json.put("file", tamperedFile.toString()); + + var failure = assertThrows( + SerDesException.class, + () -> runner.deserialize(serDes, json.toString(), TypeToken.get(String.class), context())); + assertCauseMessage(failure, "file path does not match its payload digest"); + } + + @Test + void includesPayloadDigestAndVerifiesInlineIntegrity() throws Exception { + var serDes = stringCodec() + .then(FileSystemSerDesStage.builder(basePath) + .storageMode(FileSystemStorageMode.OVERFLOW) + .build()); + var runner = new SerDesRunner(null); + var envelope = (ObjectNode) MAPPER.readTree(runner.serialize(serDes, "expected", context())); + + assertEquals( + sha256("expected".getBytes(StandardCharsets.UTF_8)), + envelope.get("payloadDigest").textValue()); + + envelope.put("data", "tampered"); + var failure = assertThrows( + SerDesException.class, + () -> runner.deserialize(serDes, envelope.toString(), TypeToken.get(String.class), context())); + assertCauseMessage(failure, "payload digest does not match stored content"); + } + + @Test + void rejectsMissingOrMalformedPayloadDigest() throws Exception { + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + var runner = new SerDesRunner(null); + var envelope = (ObjectNode) MAPPER.readTree(runner.serialize(serDes, "expected", context())); + + envelope.remove("payloadDigest"); + var missingFailure = assertThrows( + SerDesException.class, + () -> runner.deserialize(serDes, envelope.toString(), TypeToken.get(String.class), context())); + assertCauseMessage(missingFailure, "Invalid filesystem SerDes envelope"); + + envelope.put("payloadDigest", "not-a-sha-256-digest"); + var malformedFailure = assertThrows( + SerDesException.class, + () -> runner.deserialize(serDes, envelope.toString(), TypeToken.get(String.class), context())); + assertCauseMessage(malformedFailure, "Invalid filesystem SerDes envelope"); + } + + @Test + void failsClosedWhenTheFileSystemProviderLacksSecureDirectoryStreams() throws Exception { + var archive = basePath.resolve("payloads.zip"); + try (var fileSystem = + FileSystems.newFileSystem(URI.create("jar:" + archive.toUri()), Map.of("create", "true"))) { + var archiveBasePath = fileSystem.getPath("/payloads"); + var serDes = stringCodec() + .then(FileSystemSerDesStage.builder(archiveBasePath).build()); + + var failure = assertThrows( + SerDesException.class, () -> new SerDesRunner(null).serialize(serDes, "expected", context())); + + assertCauseMessage(failure, "SecureDirectoryStream support"); + } + } + + @Test + void rejectsMalformedUtf8StringsAndFilePayloads() throws Exception { + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + var runner = new SerDesRunner(null); + + assertThrows(SerDesException.class, () -> runner.serialize(serDes, "lone surrogate \uD800", context())); + + var envelope = runner.serialize(serDes, "valid", context()); + var malformed = new byte[] {(byte) 0xC3, (byte) 0x28}; + var malformedFile = contentHashedPath(payloadFile(envelope), malformed); + Files.write(malformedFile, malformed); + var malformedEnvelope = (ObjectNode) MAPPER.readTree(envelope); + malformedEnvelope.put("file", malformedFile.toString()); + malformedEnvelope.put("payloadDigest", sha256(malformed)); + + assertThrows( + SerDesException.class, + () -> runner.deserialize(serDes, malformedEnvelope.toString(), TypeToken.get(String.class), context())); + } + + @Test + void hashEncodingUsesFixedLengthSegments() throws Exception { + var stage = FileSystemSerDesStage.builder(basePath) + .pathEncoding(FileSystemPathEncoding.HASH) + .build(); + var serDes = stringCodec().then(stage); + + var envelope = new SerDesRunner(null).serialize(serDes, "value", context()); + var file = Path.of(MAPPER.readTree(envelope).get("file").textValue()); + + assertEquals(64, file.getParent().getFileName().toString().length()); + assertEquals(174, file.getFileName().toString().length()); + assertTrue(file.getFileName().toString().matches("[0-9a-f]{64}-[0-9a-f]{64}-[0-9a-f-]{36}\\.payload")); + assertFalse(file.toString().contains("operation")); + } + + @Test + void includesBoundedPreviewWithoutChangingStoredPayload() throws Exception { + var previewValue = new AtomicReference(); + var previewContext = new AtomicReference(); + var stage = FileSystemSerDesStage.builder(basePath) + .previewGenerator((value, context) -> { + previewValue.set(value); + previewContext.set(context); + return Map.of("summary", "order"); + }) + .build(); + var serDes = new JacksonSerDes().then(stage); + var runner = new SerDesRunner(null); + var originalValue = Map.of("secret", "value"); + + var envelope = runner.serialize(serDes, originalValue, context()); + var json = MAPPER.readTree(envelope); + + assertEquals("order", json.get("preview").get("summary").textValue()); + assertEquals("{\"secret\":\"value\"}", previewValue.get()); + assertSame(originalValue, previewContext.get().originalValue()); + assertEquals( + "{\"secret\":\"value\"}", + Files.readString(Path.of(json.get("file").textValue()))); + + var oversizedPreviewStage = FileSystemSerDesStage.builder(basePath) + .previewGenerator((value, context) -> Map.of("summary", "x".repeat(256 * 1024))) + .build(); + var oversizedPreview = stringCodec().then(oversizedPreviewStage); + var failure = assertThrows(SerDesException.class, () -> runner.serialize(oversizedPreview, "value", context())); + assertCauseMessage(failure, "checkpoint payload limit"); + } + + @Test + void retryablePreviewFailureCanBeRetried() throws Exception { + var attempts = new AtomicInteger(); + var fileSystemStage = FileSystemSerDesStage.builder(basePath) + .previewGenerator((value, context) -> { + if (attempts.incrementAndGet() == 1) { + throw new RetryableSerDesException("preview service unavailable"); + } + return Map.of("summary", "order"); + }) + .build(); + var pipeline = stringCodec() + .then(new RetrySerDesStage( + fileSystemStage, + (failure, attempt) -> + attempt == 1 ? RetryDecision.retry(Duration.ZERO) : RetryDecision.fail())); + + var envelope = new SerDesRunner(null).serialize(pipeline, "value", context()); + + assertEquals(2, attempts.get()); + assertEquals( + "order", MAPPER.readTree(envelope).get("preview").get("summary").textValue()); + } + + @Test + void structuredPreviewConfigSelectsAndMasksJsonFields() throws Exception { + var stage = FileSystemSerDesStage.builder(basePath) + .previewConfig(PreviewConfig.builder(PreviewMode.EXCLUDE_ALL) + .include(PreviewField.anywhere("id"), PreviewField.path("customer.status")) + .mask(PreviewField.anywhere("email")) + .build()) + .build(); + var serDes = new JacksonSerDes().then(stage); + var runner = new SerDesRunner(null); + + var value = Map.of( + "id", + "order-1", + "email", + "root@example.com", + "customer", + Map.of("status", "ready", "email", "customer@example.com", "secret", "hidden")); + var envelope = runner.serialize(serDes, value, context()); + var preview = MAPPER.readTree(envelope).get("preview"); + + assertEquals("order-1", preview.get("id").textValue()); + assertEquals("***", preview.get("email").textValue()); + assertEquals("ready", preview.get("customer").get("status").textValue()); + assertEquals("***", preview.get("customer").get("email").textValue()); + assertFalse(preview.get("customer").has("secret")); + assertEquals(value, runner.deserialize(serDes, envelope, new TypeToken>() {}, context())); + } + + @Test + void structuredPreviewConfigRequiresJsonStageValue() { + var stage = FileSystemSerDesStage.builder(basePath) + .previewConfig(PreviewConfig.builder(PreviewMode.INCLUDE_ALL).build()) + .build(); + var runner = new SerDesRunner(null); + + var failure = assertThrows( + SerDesException.class, () -> runner.serialize(stringCodec().then(stage), "not-json", context())); + + assertCauseMessage(failure, "requires a JSON stage value"); + } + + @Test + void passesUnrecognizedPayloadsThroughAtEverySource() { + var stage = FileSystemSerDesStage.builder(basePath).build(); + var filesystemPipeline = new JacksonSerDes().then(stage); + var pipeline = new JacksonSerDes().then(wrappingStage()).then(stage); + var pipelineWithStageAfterFilesystem = new JacksonSerDes().then(stage).then(wrappingStage()); + var runner = new SerDesRunner(null); + + assertEquals( + Map.of("id", 42), + runner.deserialize( + filesystemPipeline, + "{\"id\":42}", + new TypeToken>() {}, + executionContext(SerDesPayloadKind.INPUT))); + assertEquals( + Map.of("id", 42), + runner.deserialize( + pipeline, + "{\"id\":42}", + new TypeToken>() {}, + operationContext(OperationType.CALLBACK, OperationSubType.CALLBACK))); + assertEquals( + "invoke-result", + runner.deserialize( + pipeline, + "\"invoke-result\"", + TypeToken.get(String.class), + operationContext(OperationType.CHAINED_INVOKE, OperationSubType.CHAINED_INVOKE))); + assertEquals( + Map.of("id", 42), + runner.deserialize( + pipelineWithStageAfterFilesystem, + "{\"id\":42}", + new TypeToken>() {}, + executionContext(SerDesPayloadKind.INPUT))); + assertEquals( + Map.of("domainMarker", 1, "data", "domain-value"), + runner.deserialize( + filesystemPipeline, + "{\"domainMarker\":1,\"data\":\"domain-value\"}", + new TypeToken>() {}, + executionContext(SerDesPayloadKind.INPUT))); + assertThrows( + SerDesException.class, + () -> runner.deserialize( + filesystemPipeline, + "{\"__durable_execution_filesystem_serdes\":1,\"data\":\"domain-value\"}", + new TypeToken>() {}, + executionContext(SerDesPayloadKind.INPUT))); + + assertEquals( + "raw-step", + runner.deserialize(filesystemPipeline, "\"raw-step\"", TypeToken.get(String.class), context())); + } + + @Test + void rejectsTrailingTokensAfterFilesystemEnvelope() { + var stage = FileSystemSerDesStage.builder(basePath).build(); + var envelope = "{\"__durable_execution_filesystem_serdes\":1," + + "\"ownerDurableExecutionArn\":\"" + + ARN + + "\",\"ownerEntityId\":\"1\",\"payloadType\":\"STRING\"," + + "\"payloadDigest\":\"" + + sha256("value".getBytes(StandardCharsets.UTF_8)) + + "\",\"data\":\"value\"}"; + + var failure = assertThrows(SerDesException.class, () -> stage.deserialize(envelope + " true", context())); + + assertCauseMessage(failure, "Invalid filesystem SerDes envelope"); + } + + @Test + void recognizesMalformedFilesystemMarkerRegardlessOfWhitespaceOrFieldOrder() { + var stage = FileSystemSerDesStage.builder(basePath).build(); + var malformedEnvelopes = List.of( + "{ \n \"__durable_execution_filesystem_serdes\" : 1", + "{\"precedingField\":true,\n \"__durable_execution_filesystem_serdes\" : 1", + "{\"\\u005f_durable_execution_filesystem_serdes\" : 1"); + + for (var envelope : malformedEnvelopes) { + var failure = assertThrows(SerDesException.class, () -> stage.deserialize(envelope, context())); + assertCauseMessage(failure, "Invalid filesystem SerDes envelope"); + } + } + + @Test + void doesNotTreatFilesystemMarkerTextInsideAStringAsAnEnvelope() { + var stage = FileSystemSerDesStage.builder(basePath).build(); + var value = "{\"message\":\"__durable_execution_filesystem_serdes\"} trailing"; + + assertEquals(value, stage.deserialize(value, null)); + } + + @Test + void rejectsUnsupportedEnvelopeVersionsAtExternalBoundaries() { + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + var futureEnvelope = "{\"__durable_execution_filesystem_serdes\":2," + + "\"ownerDurableExecutionArn\":\"" + + ARN + + "\",\"ownerEntityId\":\"1\",\"data\":\"value\"}"; + + var failure = assertThrows(SerDesException.class, () -> new SerDesRunner(null) + .deserialize( + serDes, + futureEnvelope, + TypeToken.get(String.class), + executionContext(SerDesPayloadKind.INPUT))); + + assertCauseMessage(failure, "Unsupported filesystem SerDes envelope version 2"); + } + + @Test + void rejectsUnsupportedBinaryPayloadType() { + var envelope = "{\"__durable_execution_filesystem_serdes\":1," + + "\"ownerDurableExecutionArn\":\"" + + ARN + + "\",\"ownerEntityId\":\"1\",\"payloadType\":\"BYTES\",\"data\":\"not-base64!\"}"; + + assertThrows(SerDesException.class, () -> new SerDesRunner(null) + .deserialize( + stringCodec() + .then(FileSystemSerDesStage.builder(basePath).build()), + envelope, + TypeToken.get(String.class), + context())); + } + + @Test + void rejectsOutOfRangeEnvelopeVersionsWithoutTruncation() { + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + var oversizedVersion = "{\"__durable_execution_filesystem_serdes\":4294967297," + + "\"ownerDurableExecutionArn\":\"" + + ARN + + "\",\"ownerEntityId\":\"1\",\"data\":\"value\"}"; + + var failure = assertThrows(SerDesException.class, () -> new SerDesRunner(null) + .deserialize( + serDes, + oversizedVersion, + TypeToken.get(String.class), + executionContext(SerDesPayloadKind.INPUT))); + + assertCauseMessage(failure, "Unsupported filesystem SerDes envelope version 4294967297"); + } + + @Test + void overflowFilesystemStageCanBeFollowedByAnotherStage() { + var filesystem = FileSystemSerDesStage.builder(basePath) + .storageMode(FileSystemStorageMode.OVERFLOW) + .build(); + var pipeline = stringCodec().then(filesystem).then(wrappingStage()); + var runner = new SerDesRunner(null); + + var checkpoint = runner.serialize(pipeline, "small", context()); + + assertTrue(checkpoint.startsWith("<")); + assertEquals("small", runner.deserialize(pipeline, checkpoint, TypeToken.get(String.class), context())); + } + + @Test + void fileReferencesCrossInvokeInputAndResultBoundaries() { + var serDes = + new JacksonSerDes().then(FileSystemSerDesStage.builder(basePath).build()); + var runner = new SerDesRunner(null); + var callerArn = + "arn:aws:lambda:us-east-1:123456789012:function:caller:1/durable-execution/caller-execution/caller-invocation"; + var calleeArn = + "arn:aws:lambda:us-east-1:123456789012:function:callee:1/durable-execution/callee-execution/callee-invocation"; + var callerInvokePayload = SerDesContext.forOperation( + callerArn, + "invoke-1", + "call-callee", + null, + OperationType.CHAINED_INVOKE, + OperationSubType.CHAINED_INVOKE, + SerDesPayloadKind.INVOKE_PAYLOAD, + null); + var calleeInput = + SerDesContext.forExecution(calleeArn, "callee-invocation", "callee-execution", SerDesPayloadKind.INPUT); + + var invokeEnvelope = runner.serialize(serDes, Map.of("request", "value"), callerInvokePayload); + assertEquals( + Map.of("request", "value"), + runner.deserialize(serDes, invokeEnvelope, new TypeToken>() {}, calleeInput)); + + var calleeOutput = SerDesContext.forExecution( + calleeArn, "callee-invocation", "callee-execution", SerDesPayloadKind.OUTPUT); + var callerInvokeResult = SerDesContext.forOperation( + callerArn, + "invoke-1", + "call-callee", + null, + OperationType.CHAINED_INVOKE, + OperationSubType.CHAINED_INVOKE, + SerDesPayloadKind.RESULT, + null); + var resultEnvelope = runner.serialize(serDes, Map.of("response", "value"), calleeOutput); + + assertEquals( + Map.of("response", "value"), + runner.deserialize( + serDes, resultEnvelope, new TypeToken>() {}, callerInvokeResult)); + } + + @Test + void rejectsCallsWithoutContextAndMalformedOrUnsafeEnvelopes() throws Exception { + var stage = FileSystemSerDesStage.builder(basePath).build(); + var serDes = stringCodec().then(stage); + assertThrows(SerDesException.class, () -> stage.serialize("value", null)); + assertEquals("value", stage.deserialize("value", null)); + + var runner = new SerDesRunner(null); + assertEquals("{}", runner.deserialize(serDes, "{}", TypeToken.get(String.class), context())); + assertThrows( + SerDesException.class, + () -> runner.deserialize( + serDes, "{\"__durable_execution_filesystem_serdes\":", TypeToken.get(String.class), context())); + assertThrows( + SerDesException.class, + () -> runner.deserialize( + serDes, envelopeWithFile("/outside/payload.json"), TypeToken.get(String.class), context())); + + var missingEnvelope = runner.serialize(serDes, "missing", context()); + var missingFile = payloadFile(missingEnvelope); + assertTrue(Files.deleteIfExists(missingFile)); + var missingFileFailure = assertThrows( + RetryableSerDesException.class, + () -> runner.deserialize(serDes, missingEnvelope, TypeToken.get(String.class), context())); + assertInstanceOf(RetryableSerDesException.class, missingFileFailure.getCause()); + } + + @Test + void rejectsCrossEntityAndSymbolicLinkReferences() throws Exception { + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + var envelope = new SerDesRunner(null).serialize(serDes, "payload", context()); + + var otherEntity = SerDesContext.forOperation( + ARN, "2", "other-step", null, OperationType.STEP, OperationSubType.STEP, SerDesPayloadKind.RESULT, 1); + assertThrows(SerDesException.class, () -> new SerDesRunner(null) + .deserialize(serDes, envelope, TypeToken.get(String.class), otherEntity)); + + var file = payloadFile(envelope); + var outside = Files.createTempFile(basePath.getParent(), "outside-payload-", ".json"); + Files.writeString(outside, "payload"); + Files.delete(file); + Files.createSymbolicLink(file, outside); + + assertThrows(SerDesException.class, () -> new SerDesRunner(null) + .deserialize(serDes, envelope, TypeToken.get(String.class), context())); + } + + @Test + void rejectsSymbolicLinkDirectoriesWhenWriting() throws Exception { + var outside = Files.createTempDirectory(basePath.getParent(), "outside-payloads-"); + Files.createSymbolicLink(basePath.resolve("orders"), outside); + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + + assertThrows(SerDesException.class, () -> new SerDesRunner(null).serialize(serDes, "payload", context())); + try (var files = Files.list(outside)) { + assertEquals(0, files.count()); + } + } + + @Test + void rejectsSymbolicLinkDirectoriesWhenReading() throws Exception { + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + var runner = new SerDesRunner(null); + var envelope = runner.serialize(serDes, "payload", context()); + var orders = basePath.resolve("orders"); + var outside = Files.createTempDirectory(basePath.getParent(), "outside-payloads-"); + var outsideOrders = outside.resolve("orders"); + Files.move(orders, outsideOrders); + Files.createSymbolicLink(orders, outsideOrders); + + assertThrows( + SerDesException.class, + () -> runner.deserialize(serDes, envelope, TypeToken.get(String.class), context())); + } + + @Test + void rejectsSymbolicLinkConfiguredBasePathAndAncestors() throws Exception { + var outsideRoot = Files.createTempDirectory(basePath.getParent(), "outside-root-"); + var linkedRoot = basePath.resolve("linked-root"); + Files.createSymbolicLink(linkedRoot, outsideRoot); + + var rootSerDes = + stringCodec().then(FileSystemSerDesStage.builder(linkedRoot).build()); + assertThrows(SerDesException.class, () -> new SerDesRunner(null).serialize(rootSerDes, "payload", context())); + + var outsideAncestor = Files.createTempDirectory(basePath.getParent(), "outside-ancestor-"); + var linkedAncestor = basePath.resolve("linked-ancestor"); + Files.createSymbolicLink(linkedAncestor, outsideAncestor); + var nestedSerDes = stringCodec() + .then(FileSystemSerDesStage.builder(linkedAncestor.resolve("payloads")) + .build()); + + assertThrows(SerDesException.class, () -> new SerDesRunner(null).serialize(nestedSerDes, "payload", context())); + assertFalse(Files.exists(outsideAncestor.resolve("payloads"))); + } + + @Test + void rejectsExecutionPathsOutsideConfiguredBasePath() { + var serDes = stringCodec().then(FileSystemSerDesStage.builder(basePath).build()); + var unsafeContext = SerDesContext.forOperation( + "arn:aws:lambda:us-east-1:123456789012:function:..:1/durable-execution/../..", + "1", + "step", + null, + OperationType.STEP, + OperationSubType.STEP, + SerDesPayloadKind.RESULT, + 1); + + assertThrows(SerDesException.class, () -> new SerDesRunner(null).serialize(serDes, "value", unsafeContext)); + } + + private static SerDes stringCodec() { + return new SerDes() { + @Override + public String serialize(Object value) { + return (String) value; + } + + @Override + @SuppressWarnings("unchecked") + public T deserialize(String data, TypeToken typeToken) { + if (!TypeToken.get(String.class).equals(typeToken)) { + throw new SerDesException("String codec cannot deserialize " + typeToken); + } + return (T) data; + } + }; + } + + private static void assertCauseMessage(Throwable failure, String expected) { + var current = failure; + while (current != null + && (current.getMessage() == null || !current.getMessage().contains(expected))) { + current = current.getCause(); + } + assertNotNull(current, "Expected exception chain to contain: " + expected); + } + + private static String envelopeWithFile(String file) { + try { + return MAPPER.writeValueAsString(Map.of( + ENVELOPE_MARKER, + 1, + "file", + file, + "ownerDurableExecutionArn", + ARN, + "ownerEntityId", + "1", + "payloadType", + "STRING", + "payloadDigest", + "0".repeat(64))); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + private static Path payloadFile(String envelope) { + try { + return Path.of(MAPPER.readTree(envelope).get("file").textValue()); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + private static Path contentHashedPath(Path original, byte[] data) throws Exception { + var name = original.getFileName().toString(); + var existingHash = sha256(Files.readAllBytes(original)); + var hash = sha256(data); + return original.resolveSibling(name.replace(existingHash, hash)); + } + + private static String sha256(byte[] data) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data)); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + private static SerDesContext context() { + return context(1); + } + + private static SerDesContext context(int attempt) { + return SerDesContext.forOperation( + ARN, "1", "step", null, OperationType.STEP, OperationSubType.STEP, SerDesPayloadKind.RESULT, attempt); + } + + private static SerDesContext executionContext(SerDesPayloadKind payloadKind) { + return SerDesContext.forExecution(ARN, "invocation-1", "execution-1", payloadKind); + } + + private static SerDesContext operationContext(OperationType operationType, OperationSubType operationSubType) { + return SerDesContext.forOperation( + ARN, "1", "operation", null, operationType, operationSubType, SerDesPayloadKind.RESULT, null); + } + + private static BinarySerDesStage xorBinaryStage(byte key) { + return new BinarySerDesStage() { + @Override + public byte[] serialize(byte[] value, SerDesContext context) { + return xor(value, key); + } + + @Override + public byte[] deserialize(byte[] data, SerDesContext context) { + return xor(data, key); + } + }; + } + + private static byte[] xor(byte[] value, byte key) { + var result = Arrays.copyOf(value, value.length); + for (int index = 0; index < result.length; index++) { + result[index] ^= key; + } + return result; + } + + private static SerDesStage wrappingStage() { + return new SerDesStage() { + @Override + public String serialize(String value, SerDesContext context) { + return "<" + value + ">"; + } + + @Override + public String deserialize(String data, SerDesContext context) { + if (!data.startsWith("<")) { + return data; + } + if (!data.endsWith(">")) { + throw new SerDesException("Malformed wrapping stage value"); + } + return data.substring(1, data.length() - 1); + } + }; + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/serde/filesystem/SerDesPreviewTest.java b/sdk/src/test/java/software/amazon/lambda/durable/serde/filesystem/SerDesPreviewTest.java new file mode 100644 index 000000000..2d1c53f2d --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/serde/filesystem/SerDesPreviewTest.java @@ -0,0 +1,211 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.filesystem; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.exception.SerDesException; + +class SerDesPreviewTest { + + @Test + void includeAllAppliesExcludeAndMaskRules() { + var value = Map.of( + "id", + "123", + "email", + "alice@example.com", + "ssn", + "000-00-0000", + "user", + Map.of("name", "Alice", "role", "admin")); + var config = PreviewConfig.builder(PreviewMode.INCLUDE_ALL) + .exclude(PreviewField.anywhere("role")) + .mask(PreviewField.anywhere("ssn")) + .build(); + + var preview = SerDesPreview.buildPreview(value, config); + + assertEquals("123", preview.get("id")); + assertEquals("***", preview.get("ssn")); + assertFalse(nested(preview, "user").containsKey("role")); + assertEquals("Alice", nested(preview, "user").get("name")); + } + + @Test + void excludeAllIncludesSelectedAndMaskedFields() { + var value = Map.of("id", "123", "email", "alice@example.com", "ssn", "000-00-0000"); + var config = PreviewConfig.builder(PreviewMode.EXCLUDE_ALL) + .include(PreviewField.anywhere("id")) + .mask(PreviewField.anywhere("ssn")) + .build(); + + var preview = SerDesPreview.buildPreview(value, config); + + assertEquals(Map.of("id", "123", "ssn", "***"), preview); + } + + @Test + void excludeWinsOverMask() { + var config = PreviewConfig.builder(PreviewMode.INCLUDE_ALL) + .exclude(PreviewField.anywhere("ssn")) + .mask(PreviewField.anywhere("ssn")) + .build(); + + var preview = SerDesPreview.buildPreview(Map.of("id", "123", "ssn", "secret"), config); + + assertEquals(Map.of("id", "123"), preview); + } + + @Test + void pathAndAnywhereMatchingHaveDifferentScopes() { + var value = Map.of("email", "root@example.com", "user", Map.of("email", "nested@example.com", "id", "user-1")); + var pathConfig = PreviewConfig.builder(PreviewMode.EXCLUDE_ALL) + .include(PreviewField.path("email")) + .build(); + var anywhereConfig = PreviewConfig.builder(PreviewMode.EXCLUDE_ALL) + .include(PreviewField.anywhere("email")) + .build(); + + var pathPreview = SerDesPreview.buildPreview(value, pathConfig); + var anywherePreview = SerDesPreview.buildPreview(value, anywhereConfig); + + assertEquals(Map.of("email", "root@example.com"), pathPreview); + assertEquals("root@example.com", anywherePreview.get("email")); + assertEquals("nested@example.com", nested(anywherePreview, "user").get("email")); + } + + @Test + void arraysMergeFieldsAtTheirContainingPath() { + var value = Map.of("items", List.of(Map.of("id", "first"), Map.of("email", "second@example.com"))); + var config = PreviewConfig.builder(PreviewMode.INCLUDE_ALL).build(); + + var preview = SerDesPreview.buildPreview(value, config); + + assertEquals(Map.of("id", "first", "email", "second@example.com"), nested(preview, "items")); + } + + @Test + void includeAllPreservesScalarArrayFields() { + var preview = SerDesPreview.buildPreviewFromJson( + "{\"tags\":[\"a\",\"b\"]}", + PreviewConfig.builder(PreviewMode.INCLUDE_ALL).build()); + + assertEquals(Map.of("tags", List.of("a", "b")), preview); + } + + @Test + void excludeAllPreservesExplicitlyIncludedScalarArrayFields() { + var preview = SerDesPreview.buildPreview( + Map.of("tags", List.of("a", "b"), "hidden", List.of("c")), + PreviewConfig.builder(PreviewMode.EXCLUDE_ALL) + .include(PreviewField.path("tags")) + .build()); + + assertEquals(Map.of("tags", List.of("a", "b")), preview); + } + + @Test + void customMaskStringAndByteBudgetAreApplied() { + var value = new LinkedHashMap(); + value.put("first", "one"); + value.put("second", "two"); + value.put("secret", "hidden"); + var config = PreviewConfig.builder(PreviewMode.INCLUDE_ALL) + .mask(PreviewField.anywhere("secret")) + .maskString("[REDACTED]") + .maxPreviewBytes(18) + .build(); + + var preview = SerDesPreview.buildPreview(value, config); + + assertEquals(1, preview.size()); + assertTrue(preview.containsKey("first")); + } + + @Test + void nestedPreviewUsesTheExactSerializedByteBudget() { + var value = Map.of("a", Map.of("b", "x")); + + var tooSmall = SerDesPreview.buildPreview( + value, + PreviewConfig.builder(PreviewMode.INCLUDE_ALL) + .maxPreviewBytes(14) + .build()); + var exactFit = SerDesPreview.buildPreview( + value, + PreviewConfig.builder(PreviewMode.INCLUDE_ALL) + .maxPreviewBytes(15) + .build()); + + assertNull(tooSmall); + assertEquals(value, exactFit); + } + + @Test + void returnsNullWhenNoFieldsAreVisibleOrValueIsNotAnObject() { + var config = PreviewConfig.builder(PreviewMode.EXCLUDE_ALL).build(); + + assertNull(SerDesPreview.buildPreview(Map.of("id", "123"), config)); + assertNull(SerDesPreview.buildPreview("value", config)); + assertNull(SerDesPreview.buildPreview(List.of(Map.of("id", "123")), config)); + } + + @Test + void objectPreviewUsesJacksonSerDesTimeFormats() { + var instant = Instant.parse("2026-08-26T03:30:00Z"); + var duration = Duration.ofMinutes(5); + var localDateTime = LocalDateTime.parse("2026-08-26T03:30:00"); + var config = PreviewConfig.builder(PreviewMode.INCLUDE_ALL).build(); + + var preview = SerDesPreview.buildPreview(new TemporalPayload(instant, duration, localDateTime), config); + + assertEquals("2026-08-26T03:30:00Z", preview.get("instant")); + assertEquals(0, new BigDecimal("300").compareTo((BigDecimal) preview.get("duration"))); + assertEquals("2026-08-26T03:30:00", preview.get("localDateTime")); + } + + @Test + void jsonPreviewRejectsMalformedJsonAndSkipsDottedFieldNames() { + var config = PreviewConfig.builder(PreviewMode.INCLUDE_ALL).build(); + + assertThrows(SerDesException.class, () -> SerDesPreview.buildPreviewFromJson("not-json", config)); + assertEquals( + Map.of("safe", "value"), + SerDesPreview.buildPreviewFromJson("{\"safe\":\"value\",\"not.addressable\":\"secret\"}", config)); + } + + @Test + void validatesConfiguration() { + assertThrows(NullPointerException.class, () -> PreviewConfig.builder(null)); + assertNull(SerDesPreview.buildPreview( + Map.of("id", "123"), + PreviewConfig.builder(PreviewMode.INCLUDE_ALL) + .maxPreviewBytes(0) + .build())); + assertThrows(IllegalArgumentException.class, () -> PreviewConfig.builder(PreviewMode.INCLUDE_ALL) + .maxPreviewBytes(-1)); + assertThrows(IllegalArgumentException.class, () -> new PreviewField(" ")); + assertThrows(NullPointerException.class, () -> PreviewConfig.builder(PreviewMode.INCLUDE_ALL) + .include((PreviewField) null)); + } + + @SuppressWarnings("unchecked") + private static Map nested(Map value, String field) { + return (Map) value.get(field); + } + + private record TemporalPayload(Instant instant, Duration duration, LocalDateTime localDateTime) {} +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/serde/internal/ChainedInvokePayloadFrameTest.java b/sdk/src/test/java/software/amazon/lambda/durable/serde/internal/ChainedInvokePayloadFrameTest.java new file mode 100644 index 000000000..c76747f69 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/serde/internal/ChainedInvokePayloadFrameTest.java @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.serde.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.exception.SerDesException; + +class ChainedInvokePayloadFrameTest { + + @Test + void roundTripsSerializedPayloadWithoutReencodingIt() { + var payload = "{\"file\":\"/mnt/efs/payload\"}"; + + var framed = ChainedInvokePayloadFrame.encode(payload); + + assertTrue(ChainedInvokePayloadFrame.isFramed(framed)); + assertEquals(payload, ChainedInvokePayloadFrame.decode(framed)); + } + + @Test + void nullAndExternalPayloadsAreNotFramed() { + assertNull(ChainedInvokePayloadFrame.encode(null)); + assertFalse(ChainedInvokePayloadFrame.isFramed(null)); + assertFalse(ChainedInvokePayloadFrame.isFramed("{\"external\":true}")); + } + + @Test + void rejectsUnsupportedOrMalformedReservedFrames() { + assertThrows( + SerDesException.class, + () -> ChainedInvokePayloadFrame.decode("__durable_execution_chained_invoke_payload:2:value")); + assertThrows(SerDesException.class, () -> ChainedInvokePayloadFrame.decode("external")); + } +}