Skip to content

feat: add filesystem payload offloader and retries - #681

Open
zhongkechen wants to merge 1 commit into
issue-463-payload-offloaderfrom
issue-463-payload-offloader-filesystem
Open

feat: add filesystem payload offloader and retries#681
zhongkechen wants to merge 1 commit into
issue-463-payload-offloaderfrom
issue-463-payload-offloader-filesystem

Conversation

@zhongkechen

@zhongkechen zhongkechen commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Stack

Layer PR Scope
Architecture #678 ADR-006 and design decision
1 #649 Core API, envelopes, runtime, operations
2 #681 (this PR) Filesystem implementation and retries
3 #682 Testing utilities and integration coverage
4 #683 Examples, E2E infrastructure, and implementation docs

Scope

  • add FileSystemPayloadOffloader in the core SDK
  • publish immutable unique files with CREATE_NEW
  • enforce SecureDirectoryStream, no-follow access, direct-child confinement, ownership binding, and SHA-256 verification
  • support ALWAYS/OVERFLOW, URI/hash path encodings, envelope-size limits, and structured/custom previews
  • add RetryPayloadOffloader for explicitly retryable storage failures
  • add focused filesystem, preview, integrity, provider, symlink, retry, and interruption tests

Intentionally excluded:

  • testing-runner/history integration
  • cloud/local integration scenarios
  • examples, E2E infrastructure, and documentation

Validation

  • full eight-module Maven reactor on Java 17
  • git diff --check
  • mvn spotless:check

Related to #463.

Comment on lines +75 to +80
Thread.currentThread().interrupt();
var interrupted = new RetryablePayloadOffloadException(
String.format("Interrupted while waiting to retry payload %s after attempt %d", action, attempt),
e);
interrupted.addSuppressed(failure);
throw interrupted;

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 · Finding arf_v1_5vmint2jktj7b53xftxnhr7dib

[P1] An interrupted backoff is converted to RetryablePayloadOffloadException. DurableExecutor treats this subtype as RETRYING, so cancellation can trigger a fresh Lambda invocation and repeat storage work. The filesystem classifier similarly marks actual I/O interruptions retryable. Restore the interrupt flag but throw a non-retryable PayloadOffloadException, and classify ClosedByInterruptException or an interrupt-signaled InterruptedIOException as permanent interruption.

Comment on lines +87 to +90
var inlinePayload = OffloadedPayload.inline(
serializedPayload, context.durableExecutionArn(), context.entityId(), payloadDigest)
.bindProducer(context, payloadDigest);
if (fitsCheckpoint(inlinePayload)) {

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 · Finding arf_v1_6olmknqlyp3zhgezq3cu4y2oso

[P1] OVERFLOW sizing materializes the complete escaped envelope and then another UTF-8 byte array through PayloadCodec.envelopeSizeBytes. For the multi-megabyte payloads this offloader is intended to handle, these extra full-size copies can exhaust Lambda memory before the code chooses filesystem storage. Short-circuit when a bounded UTF-8 count already exceeds the limit, and perform exact envelope encoding only for near-boundary payloads.

Comment on lines +197 to +200
var expectedPrefix = payloadOwnerPrefix(payload.ownerDurableExecutionArn(), payload.ownerEntityId())
+ "-"
+ payload.payloadDigest()
+ "-";

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 · Finding arf_v1_2cuyghirtpc6zjugxs55p2ygo2

[P2] Loading recomputes the expected filename using the current pathEncoding. An in-flight execution that wrote a URI-form reference will therefore fail replay after configuration changes to HASH, despite the persisted reference, owner, and digest remaining valid. Make encoding a write-only choice by accepting both versioned filename formats during load, or persist the encoding with the reference, and add a cross-configuration replay test.

import software.amazon.lambda.durable.offload.PayloadStorageMode;
import software.amazon.lambda.durable.offload.SerDesPayloadKind;

class FileSystemPayloadOffloaderTest {

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 · Finding arf_v1_nlr5gumo4w2iw45unbkquvbkep

[P2] The new public offloader is tested only through direct method calls. That does not exercise DurableConfig, PayloadCodec, executor routing, checkpoint persistence, or replay, and project rules require integration coverage for public API changes. Add a LocalDurableTestRunner test that configures this offloader and verifies checkpoint/suspend/replay loading, including an OVERFLOW payload and retryable storage failure.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Codex AI review

Found four actionable issues affecting interruption handling, large-payload reliability, replay compatibility, and required integration coverage.

Reviewed commit 578208f0b55d4110cb89acd77a1e5923488e80d6. Workflow run

@zhongkechen
zhongkechen requested a review from a team September 3, 2026 18:32
@zhongkechen
zhongkechen force-pushed the issue-463-payload-offloader-filesystem branch from 578208f to d3f3e32 Compare September 3, 2026 18:32
@zhongkechen
zhongkechen deployed to ai-pr-review-runtime September 3, 2026 18:50 — with GitHub Actions Active
Comment on lines +64 to +82
private void waitForRetry(String action, RetryablePayloadOffloadException failure, int attempt, Duration delay) {
if (delay == null || delay.isNegative()) {
throw new PayloadOffloadException(String.format(
"Retry strategy returned an invalid delay for payload %s attempt %d", action, attempt));
}
if (delay.isZero()) {
return;
}
try {
sleeper.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
var interrupted = new RetryablePayloadOffloadException(
String.format("Interrupted while waiting to retry payload %s after attempt %d", action, attempt),
e);
interrupted.addSuppressed(failure);
throw interrupted;
}
}

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 · Finding arf_v1_b5hgr2vtoilx2bumojiyfm4bsc

waitForRetry blocks the calling thread with TimeUnit.SECONDS.sleep/TimeUnit.NANOSECONDS.sleep for the entire retry delay returned by the caller-supplied RetryStrategy, with no upper bound and no awareness of the Lambda invocation's remaining time budget. PayloadCodec.runOffloadTask can invoke offloader calls inline on the current thread (its Javadoc says the executor may be null "to execute inline"), so a realistic backoff (seconds to minutes) burns billed compute time and can push the invocation past its timeout — turning a transient, retryable storage failure into a hard failure. This is inconsistent with every other retry path in the SDK: StepOperation.handleStepFailure always converts a retry delay into a checkpointed RETRY + poll-for-READY cycle instead of a blocking sleep, and AGENTS.md explicitly says not to use Thread.sleep()/blocking timers to model waits. Recommend either capping the cumulative retry delay this executor will block for (failing fast past the cap) or prominently documenting on RetryPayloadOffloader/PayloadOffloadRetryExecutor that supplied RetryStrategy delays must be small/bounded because they block the calling thread and count as billed compute time.

Comment on lines +43 to +55
@Test
void overflowModeKeepsSmallPayloadInlineAndOffloadsLargePayload() {
var offloader = FileSystemPayloadOffloader.builder(temporaryDirectory)
.storageMode(PayloadOffloadMode.OVERFLOW)
.build();

var inline = offloader.offload("small", context());
var reference = offloader.offload("x".repeat(256 * 1024), context());

assertEquals(PayloadStorageMode.INLINE, inline.mode());
assertEquals("small", inline.data());
assertEquals(PayloadStorageMode.REFERENCE, reference.mode());
}

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 · Finding arf_v1_xm7cj2gaddq2zlhqpxsmwmpbvb

This test only checks inline.mode()/inline.data() but never calls offloader.load(inline, context()). FileSystemPayloadOffloader's OVERFLOW branch builds the inline payload via OffloadedPayload.inline(data, arn, entityId, digest), which always sets requiresLoad=true; consequently PayloadCodec.resolve() will call this offloader's load() on every subsequent read of that payload (e.g. on replay), exercising the untested PayloadStorageMode.INLINE branch of load() (integrity-metadata check, owner validation, digest verification). A regression in that branch (e.g. in requireIntegrityMetadata, validateOwner, or verifyDigest for inline data) would go undetected. Add assertEquals("small", offloader.load(inline, context())) to close the gap.

Comment on lines +335 to +345
private java.nio.channels.SeekableByteChannel openPayloadForRead(
SecureDirectoryStream<Path> directory, Path fileName, PayloadOffloadContext context) throws IOException {
var attributes = readAttributes(directory, fileName);
if (attributes.isSymbolicLink() || !attributes.isRegularFile()) {
throw new PayloadOffloadException(
"Filesystem payload must be a regular file for entity '" + context.entityId() + "'");
}
return directory.newByteChannel(fileName, Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS));
}

private static java.nio.file.attribute.BasicFileAttributes readAttributes(

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 · Finding arf_v1_lpu6fjilm7ok6xrupvoce2d45m

openPayloadForRead returns java.nio.channels.SeekableByteChannel and readAttributes returns java.nio.file.attribute.BasicFileAttributes, both using fully qualified names inline instead of imports. AGENTS.md's Java style rules explicitly say "ALWAYS use proper imports, NEVER use fully qualified class names in code." Add import java.nio.channels.SeekableByteChannel; and import java.nio.file.attribute.BasicFileAttributes; at the top of the file and use the simple type names in both signatures.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Claude AI review

Reviewed the new filesystem payload offloader (FileSystemPayloadOffloader, PayloadPreview/PreviewConfig/PreviewField) and the retry decorator (RetryPayloadOffloader, PayloadOffloadRetryExecutor), plus their unit tests.

Overall the filesystem implementation is careful: SecureDirectoryStream-based traversal with NOFOLLOW_LINKS at every hop defends against symlink/TOCTOU attacks, CREATE_NEW gives immutable, collision-free publishing, path/prefix/digest/owner checks on load() reject tampered or cross-entity references (mirroring PayloadCodec.validateOwner), and IOException classification into retryable vs. permanent failures is consistent with the accompanying tests (stale handle/ENOSPC/EROFS/etc.). The PayloadPreview field-matching/masking/budget logic was traced against all its tests and is internally consistent (array containers correctly omitted, inherited-include/exclude/mask precedence, path escaping).

Three actionable issues remain, ordered by impact:

  1. Blocking retry backoff (PayloadOffloadRetryExecutor/RetryPayloadOffloader) sleeps on the calling thread for the full RetryStrategy delay with no cap, unlike every other retry path in this SDK (StepOperation always converts a retry delay into a checkpointed suspend+poll). This can silently burn billed Lambda time and risks hitting the function timeout, and conflicts with AGENTS.md's explicit "don't use Thread.sleep() to model waits" rule.
  2. Test gap: the OVERFLOW-mode inline payload (requiresLoad=true by construction) will be routed back through FileSystemPayloadOffloader.load()'s INLINE branch in real usage, but no test exercises that round trip.
  3. Style rule violation: two method signatures use fully qualified type names (java.nio.channels.SeekableByteChannel, java.nio.file.attribute.BasicFileAttributes) instead of imports, contradicting AGENTS.md's explicit import rule.

No correctness bugs were found in the digest/ownership validation, path containment, checkpoint-size accounting (OVERFLOW/ALWAYS modes, envelope-size math), or the preview truncation/budget algorithm. Residual risk: this PR intentionally excludes testing-runner/history integration and cloud/local integration scenarios (deferred to a later PR in the stack), so end-to-end checkpoint/replay coverage of this offloader is not yet exercised anywhere in the repo.

Reviewed commit d3f3e325634d296d898c49816d30c8544b536832. Workflow run

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.

1 participant