Skip to content

feat: add payload offloader support (Approach B) - #649

Closed
zhongkechen wants to merge 1 commit into
mainfrom
issue-463-payload-offloader
Closed

feat: add payload offloader support (Approach B)#649
zhongkechen wants to merge 1 commit into
mainfrom
issue-463-payload-offloader

Conversation

@zhongkechen

Copy link
Copy Markdown
Contributor

Summary

  • implement Approach B from ADR-005 with a dedicated core PayloadOffloader API, versioned checkpoint envelopes, operation-level overrides, invocation-scoped caching, and a separate blocking-I/O executor
  • apply the payload pipeline to root input/output, durable operation results and state, invoke payloads, and serialized exceptions while preserving legacy raw checkpoint compatibility
  • add the optional aws-durable-execution-sdk-java-extra-filesystem-offloader module with ALWAYS/OVERFLOW modes, URI/HASH paths, attempt-aware files, atomic writes, and preview metadata
  • update local test helpers and documentation for offloaded payload inspection and filesystem durability requirements

This is the Approach B alternative to #648, which implements Approach A.

Testing

  • mvn spotless:apply
  • full reactor mvn test with the Mockito Java agent required by this JDK environment
  • full reactor mvn clean install with the same Mockito Java agent
  • focused payload codec, filesystem offloader, local runner, and integration tests

Closes #463

@zhongkechen
zhongkechen requested a review from a team August 24, 2026 20:14
@zhongkechen
zhongkechen temporarily deployed to ai-pr-review-runtime August 24, 2026 20:14 — with GitHub Actions Inactive
@zhongkechen
zhongkechen temporarily deployed to ai-pr-review-runtime August 24, 2026 20:14 — with GitHub Actions Inactive
@zhongkechen
zhongkechen temporarily deployed to ai-pr-review-runtime August 24, 2026 20:14 — with GitHub Actions Inactive

/** Serializes and optionally offloads a value. */
public String serialize(Object value, SerDes serDes, PayloadOffloader offloader, PayloadOffloadContext context) {
var serialized = serDes.serialize(value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review

[P1] JacksonSerDes.serialize(null) returns null, so an active filesystem offloader rejects valid null results; a permissive offloader would still fail when null is inserted into ConcurrentHashMap. This breaks nullable root/step/child results and the internal Void step used by waitForCallback. Bypass offloading and caching for null serialized values and add null-result replay coverage.

Suggested change
var serialized = serDes.serialize(value);
var serialized = serDes.serialize(value);
if (serialized == null) {
return null;
}

.tenantId(invokeConfig.tenantId())
.build())
.payload(payloadSerDes.serialize(this.payload));
.payload(serializePayload(this.payload, payloadSerDes, SerDesPayloadKind.INVOKE_PAYLOAD, null));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review

[P1] OperationUpdate.payload is sent directly to the invoked Lambda. With any active offloader, including OVERFLOW returning inline data, this replaces the target's JSON input with an @aws-durable-payload envelope. Standard Lambda targets cannot decode it, and durable targets require matching storage configuration. Keep chained-invoke wire payloads serialized normally, or make this an explicit target-aware opt-in with corresponding decode support and integration tests.

Comment thread pom.xml

<modules>
<module>sdk</module>
<module>extra-filesystem-offloader</module>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review

[P1] Adding the module to the reactor does not publish it: .github/scripts/maven_publish.sh deploys only sdk, sdk-testing, and otel-plugin. Consequently the documented filesystem artifact will never reach Maven Central. Add extra-filesystem-offloader to the deploy script and release artifact upload/verification workflow.

Comment on lines +168 to +170
return executionManager
.getPayloadCodec()
.serialize(value, serDes, payloadOffloader, payloadContext(payloadKind, attempt));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review

[P2] Offloading occurs before callers decide whether a payload will be checkpointed. Flat/virtual map branches, branches finishing after their parent, and completed parallel replays therefore write unreferenced files; replay can also overwrite a stable filesystem reference despite skipping the update. Perform offloading only for updates that will actually be persisted, while using plain SerDes normalization for virtual/skipped/replayed results. Add flat-map and completed-parallel replay tests using an offload call counter.

Comment on lines +58 to +60
if (storageMode == PayloadOffloadMode.OVERFLOW
&& serializedPayload.getBytes(StandardCharsets.UTF_8).length <= OVERFLOW_THRESHOLD_BYTES) {
return OffloadedPayload.inline(serializedPayload);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review

[P2] OVERFLOW checks the serialized text size before that text is JSON-escaped inside the SDK envelope. Payloads containing many quotes or backslashes can be below 255 KB here but exceed 256 KB after envelope encoding, causing checkpoint rejection instead of offloading. Base the decision on the final UTF-8 envelope size, or conservatively account for JSON escaping, and test highly escapable payloads near the threshold.

@github-actions

Copy link
Copy Markdown
Contributor

Codex AI review

Five confirmed issues block reliable payload offloading, including compatibility and publication failures.

Reviewed commit 625b1d3c4d3f01dcc1a9dc3dc451817f5c0832ff. Workflow run

Comment on lines +56 to +60
var serialized = serDes.serialize(value);
var effectiveOffloader = effectiveOffloader(offloader);
if (effectiveOffloader == null) {
return serialized;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude AI review

Null payloads throw when an offloader is configured. serDes.serialize(value) returns null for a null value (see JacksonSerDes.serialize), and this then calls effectiveOffloader.offload(null, context). FileSystemPayloadOffloader.offload starts with Objects.requireNonNull(serializedPayload, ...), and OffloadedPayload.inline(null) also rejects null — so the offload task fails and gets wrapped in a PayloadOffloadException.

Impact: any operation whose result serializes to null (a step/child/parallel/map branch returning null, waitForCondition state, or the handler's root output) previously checkpointed a null payload and replayed as null. With a global (or per-operation) offloader configured, these now throw at checkpoint time — a regression for a common case, and it is untested. resolve(null, ...)/deserialize already handle a null checkpoint, so the pipeline is only broken on the write side.

Fix: skip offloading when the serialized value is null so the legacy null behavior is preserved.

Suggested change
var serialized = serDes.serialize(value);
var effectiveOffloader = effectiveOffloader(offloader);
if (effectiveOffloader == null) {
return serialized;
}
var serialized = serDes.serialize(value);
var effectiveOffloader = effectiveOffloader(offloader);
if (serialized == null || effectiveOffloader == null) {
return serialized;
}

*/
public final class FileSystemPayloadOffloader implements PayloadOffloader {
private static final int CHECKPOINT_SIZE_LIMIT_BYTES = 256 * 1024;
private static final int OVERFLOW_THRESHOLD_BYTES = CHECKPOINT_SIZE_LIMIT_BYTES - 1024;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude AI review

OVERFLOW headroom is too small once enveloping/escaping is accounted for. offload decides inline-vs-reference from the raw serialized byte length against OVERFLOW_THRESHOLD_BYTES (256 KB − 1 KB). But when it returns OffloadedPayload.inline(...), PayloadCodec.serialize still wraps it as "@aws-durable-payload:v1:" + JSON({"mode":"INLINE","data":<serialized>, ...}). The serialized text is embedded as a JSON string, so every "/\ is escaped — for JSON payloads this can add well over 1 KB (worst case ~2x). A payload just under the 255 KB threshold can therefore produce a checkpoint value exceeding the 256 KB limit that OVERFLOW exists to stay under, causing the checkpoint to be rejected — the exact failure the mode is meant to prevent.

Fix: size the threshold against the enveloped form (e.g. offload if the enveloped/escaped size, or a conservative estimate such as raw*2 + fixed wrapper overhead, exceeds the limit) rather than subtracting a flat 1 KB from the raw length. Add a boundary test around the threshold to lock the behavior in.

@github-actions

Copy link
Copy Markdown
Contributor

Claude AI review

Payload Offloader (Approach B) — review

The change is well-structured: a versioned @aws-durable-payload:v1: envelope keeps legacy raw checkpoints readable, offloading runs on a dedicated blocking-I/O executor, per-invocation caching prevents duplicate loads, attempt-aware storage keys keep retries isolated, and the disabled sentinel plus per-operation overrides are threaded consistently through every config/operation. Replay semantics (reference stored in the envelope, resolved via cache-or-load) look correct, and the attempt - 1 used when resuming waitForCondition state matches the attempt the state was written under.

Two confirmed defects, both surfacing only when an offloader is configured (the intended production setup):

  1. Null payloads throwPayloadCodec.serialize forwards a null serialized value into offloader.offload(...), and FileSystemPayloadOffloader / OffloadedPayload.inline reject null. Any step/child/parallel/map result, waitForCondition state, or root output that serializes to null now fails with PayloadOffloadException where it previously checkpointed cleanly. See sdk/src/main/java/software/amazon/lambda/durable/execution/PayloadCodec.java:56.
  2. OVERFLOW threshold ignores envelope/escaping overhead — inline overflow payloads are still enveloped and JSON-escaped, so a payload just under the raw threshold can produce a checkpoint over the 256 KB limit the mode exists to respect. See extra-filesystem-offloader/.../FileSystemPayloadOffloader.java:33.

Residual test risk: No test covers a null operation result / root output under a configured offloader (the integration tests only use non-null values), nor the near-threshold OVERFLOW boundary. Separately worth noting (not flagged inline, as it appears intentional and is documented as requiring a shared mount): offloading invoke payloads/results is on by default under a global offloader, which requires the invoked function to run a compatible SDK + offloader with access to the same storage, or it will fail to parse the envelope.

Reviewed commit 625b1d3c4d3f01dcc1a9dc3dc451817f5c0832ff. Workflow run

@zhongkechen
zhongkechen deleted the issue-463-payload-offloader branch August 25, 2026 05:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add FileSystem serdes

1 participant