Skip to content

fix: round-trip serdes result on first run across ops - #550

Draft
ayushiahjolia wants to merge 1 commit into
mainfrom
serdes-roundtrip-first-run
Draft

fix: round-trip serdes result on first run across ops#550
ayushiahjolia wants to merge 1 commit into
mainfrom
serdes-roundtrip-first-run

Conversation

@ayushiahjolia

@ayushiahjolia ayushiahjolia commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Issue #, if available:
#406
#544

Description of changes:
When a step, child context, or wait_for_condition finishes, we now serialize its result and deserialize it back before saving the SUCCEED checkpoint. The value the function returns on the first run is the deserialized one - exactly what it would return on replay.

Before this, the first run returned the raw in-memory result, but replay returned the value rebuilt from the checkpoint. With a custom serdes that changes the value in transit, those two could differ. Running the round-trip up front makes the first run and replay always agree, and guarantees a SUCCEEDED result is always reconstructable.

Serdes failures are now clearly split:

  • Permanent failures raise SerDesError. The operation fails (no step retry) and user code can catch it.
  • Transient failures raise RetryableSerDesError, which fails the invocation so the backend retries the whole thing (no step retry).

Behavior changes to note

  • For wait_for_condition, the wait strategy now sees the round-tripped value, matching the Java and JS SDKs.
  • A result that wasn't serializable before (for virtual or large-payload child contexts) will now raise SerDesError instead of silently passing through.
  • Custom serdes authors should expect deserialize to be called right after serialize on the first run. For large payloads this means one extra round-trip.
  • Python matches Java's round-trip-before-checkpoint; JS defers deserialize until after SUCCEED for step/child.
  • Serdes failures are now a catchable SerDesError (permanent, no retry) or RetryableSerDesError (transient, backend retry)

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@ayushiahjolia
ayushiahjolia force-pushed the serdes-roundtrip-first-run branch from 6a28b7a to e1293c7 Compare July 17, 2026 23:00
@ayushiahjolia ayushiahjolia changed the title fix: round-trip serdes result on first run across operations fix: round-trip serdes result on first run across ops Jul 17, 2026
@ayushiahjolia
ayushiahjolia marked this pull request as ready for review July 17, 2026 23:09
@ayushiahjolia
ayushiahjolia force-pushed the serdes-roundtrip-first-run branch from 315a90c to cd7a3c4 Compare July 21, 2026 17:37
@ayushiahjolia
ayushiahjolia marked this pull request as draft July 24, 2026 18:57
@ayushiahjolia
ayushiahjolia force-pushed the serdes-roundtrip-first-run branch 5 times, most recently from b4f4ddf to 3d1eef0 Compare July 27, 2026 21:08
@ayushiahjolia
ayushiahjolia marked this pull request as ready for review July 27, 2026 21:17
@ayushiahjolia
ayushiahjolia requested a review from yaythomas July 27, 2026 23:05
@zhongkechen
zhongkechen had a problem deploying to ai-pr-review-runtime July 27, 2026 23:06 — with GitHub Actions Failure
@zhongkechen
zhongkechen had a problem deploying to ai-pr-review-runtime July 27, 2026 23:06 — with GitHub Actions Failure
@ayushiahjolia
ayushiahjolia force-pushed the serdes-roundtrip-first-run branch from 249c4e7 to 6a57868 Compare July 28, 2026 16:56
@ayushiahjolia
ayushiahjolia had a problem deploying to ai-pr-review-runtime July 28, 2026 16:56 — with GitHub Actions Failure
@ayushiahjolia
ayushiahjolia temporarily deployed to ai-pr-review-runtime July 28, 2026 16:56 — with GitHub Actions Inactive
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@ayushiahjolia
ayushiahjolia force-pushed the serdes-roundtrip-first-run branch from 7db4a57 to 782f6db Compare July 28, 2026 21:53
@ayushiahjolia
ayushiahjolia temporarily deployed to ai-pr-review-runtime July 28, 2026 21:53 — with GitHub Actions Inactive
@github-actions

This comment has been minimized.

@ayushiahjolia
ayushiahjolia force-pushed the serdes-roundtrip-first-run branch from d05e7f4 to 6afedae Compare July 29, 2026 21:56
@ayushiahjolia
ayushiahjolia requested a review from yaythomas July 29, 2026 21:57
@ayushiahjolia
ayushiahjolia marked this pull request as ready for review July 29, 2026 22:21
@ayushiahjolia
ayushiahjolia had a problem deploying to ai-pr-review-runtime July 29, 2026 22:26 — with GitHub Actions Failure
@yaythomas

Copy link
Copy Markdown
Contributor

re SerDesError name clash raised by the bot reviewer...

A collision here means except StepError: misses a real step failure and except SerDesError: catches a non-serdes one

could be an idea to store the namepssced class name rather than the short/bare class name? i.e aws_durable_execution_sdk_python.exceptions.SerDesError?

collisions become difficult... To collide, a user would have to ship a module literally named aws_durable_execution_sdk_python.exceptions containing a class of the same name.

At which point they've shadowed the SDK's own import path and have gone to a fair degree of trouble.

@ayushiahjolia
ayushiahjolia force-pushed the serdes-roundtrip-first-run branch from 6afedae to eebd0ac Compare July 29, 2026 22:50
@ayushiahjolia
ayushiahjolia temporarily deployed to ai-pr-review-runtime July 29, 2026 23:21 — with GitHub Actions Inactive
@ayushiahjolia
ayushiahjolia temporarily deployed to ai-pr-review-runtime July 29, 2026 23:21 — with GitHub Actions Inactive
@github-actions

This comment has been minimized.

# Namespaced wire token used as the checkpoint ErrorType discriminator.
# Matching on this rather than the bare class name prevents a user
# exception coincidentally named "SerDesError" from being misidentified.
WIRE_ERROR_TYPE: str = "aws_durable_execution_sdk_python.exceptions.SerDesError"

@yaythomas yaythomas Jul 29, 2026

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.

f"{__module__}.{__qualname__}"?

Also, so only this one type gets a namespaced token and eight others get bare class names? So the ErrorType field now carries two conventions.

You could argue this is the only discriminant the SDK branches on to choose a raised type, so this bare-names for the others are fine and this one is special?

If you want to go the other way for consistency, a relatively light way of enforcing this for the others is something like this in ErrorObject.from_exception?

cls_ = type(exception)
module: str = "" if cls_.__module__ == "builtins" else f"{cls_.__module__}."
wire_type: str = f"{module}{cls_.__qualname__}"

type=wire_type"

This would give you ValueError rather than builtins.ValueError, and everything else full.y qualified.

this would also mean to change
_DURABLE_OPERATION_ERROR_REGISTRY to derived from the classes rather than typed by hand.

And raise_as_operation_error's comparison to use the same derived expression for SerDesError.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We can do this to make it consistent, but should be a separate change.
And this change means we are accepting that python will differ in convention with other SDKs.

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.

Re "differ in convention", my worry is that this change is introducing two different conventions in the same PR in the same SDK. And when looking at convention for other SDKs, doesn't Java's ErrorType values use fully qualified (Class.forName(errorType))? JS does use bare names.

And the other part I was asking about is whether it's feasiable to derive the value for WIRE_ERROR_TYPE instead of hardcoding the literal?

Which parts of these are you proposing to do as a separate change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes it's feasible to derive the value of WIRE_ERROR_TYPE from module name, I'll include that change. I am proposing to do the same change in other Error types in separate PR (still in v2).

@github-actions

Copy link
Copy Markdown

Claude AI review

Review: fix: round-trip serdes result on first run across ops

I reviewed the complete SHA-anchored diff plus the surrounding source (step.py, child.py, wait_for_condition.py, concurrency/executor.py, serdes.py, exceptions.py, lambda_service.py, execution.py, map.py, examples).

Verified correct

  • Round-trip before SUCCEED checkpoint in step.py, child.py, wait_for_condition.py: a permanent SerDesError in the deserialize half writes FAIL and never SUCCEED (no double-checkpoint), and the first-run return value is identical to the replay path. None-payload short-circuits mirror replay.
  • Large-payload / ReplayChildren: returned value uses the full serialized_result; only the summary is checkpointed. First run and replay both return deserialize(serialize(raw)) — confirmed equal.
  • Virtual / FLAT branches now round-trip results (intentional, documented change; previously-unserializable results now raise SerDesError); replay re-executes and round-trips identically.
  • Error taxonomy: SerDesError (permanent, catchable DurableExecutionsError) vs RetryableSerDesError (transient, retryable InvocationError) correctly routed. serialize/deserialize propagate the retryable variant and wrap everything else as SerDesError. ErrorObject/raise_as_operation_error surface SerDesError as itself via WIRE_ERROR_TYPE (not bare class name) on first run and replay.
  • Executor: retryable branch errors escape the batch (backend retry), take priority over suspension, and re-raise on the FLAT-replay path; permanent SerDesError becomes a per-operation failed item with the correct wire type. New InvocationError import is used, not duplicated.
  • lambda_service str | None widening consistent across all create_*_succeed/_retry/_start sites; handles serdes returning None.
  • Examples: deleted step_with_custom_serdes fully removed (source, test, catalog); consolidated serdes_roundtrip example is consistent with the namespace-package layout.

No actionable code findings.

Residual test risk

The map/parallel parent-level BatchResult round-trip (a changed behavior — these ops now return a round-tripped BatchResult) is covered only by mock-based unit tests (tests/operation/map_test.py:1224, parallel_test.py:1245). Unlike step/run_in_child_context/wait_for_condition, there is no end-to-end first-run-vs-replay equality test with a real non-identity serdes for map/parallel, and the serdes_roundtrip example omits them. A custom MapConfig/ParallelConfig serdes must now round-trip (not just serialize) a BatchResult; that path lacks integration coverage. Coverage gap, not a defect.

Reviewed commit eebd0ac2320c65cca6a5ce981aa80683fb760640. Workflow run

@ayushiahjolia
ayushiahjolia force-pushed the serdes-roundtrip-first-run branch from eebd0ac to 6060f46 Compare July 30, 2026 20:24
@ayushiahjolia
ayushiahjolia had a problem deploying to ai-pr-review-runtime July 30, 2026 20:24 — with GitHub Actions Failure
@ayushiahjolia
ayushiahjolia temporarily deployed to ai-pr-review-runtime July 30, 2026 20:24 — with GitHub Actions Inactive
@github-actions

This comment has been minimized.

Comment on lines +273 to +279
except RetryableSerDesError:
# Transient serdes failure: fail the invocation for backend retry,
# bypassing the step retry strategy. This narrow catch relies on the
# serdes wrappers raising only RetryableSerDesError or SerDesError;
# any other retryable InvocationError would fall through to the step
# retry strategy below.
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Codex AI review

P1 Re-raising only bypasses the retry strategy for the current invocation. With AT_MOST_ONCE_PER_RETRY, the durable START already exists; the backend retry sees STARTED, treats it as StepInterruptedError, and invokes the step retry strategy. An exhausted or filtered strategy therefore makes this transient SerDes failure terminal. Handle this case explicitly for at-most-once steps, either through retry_handler or a dedicated checkpoint state that can resume serialization, and add a replay test.

Comment on lines +174 to +179
# First poll (or a retry with no stored state): round-trip
# initial_state through the serdes so the check sees the same
# shape it gets on later polls, which come from the checkpoint.
current_state = self._deserialize(
self._serialize(self.config.initial_state)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Codex AI review

P2 Round-tripping initial_state changes the input to the first check and requires it to be serializable, although no poll state has yet been checkpointed. Existing custom SerDes users may intentionally transform the initial value in the check or support only returned states; those workflows now fail or compute different results. Replay before the first completed poll already reconstructs initial_state from user code, so preserve the configured value and round-trip only check results.

Suggested change
# First poll (or a retry with no stored state): round-trip
# initial_state through the serdes so the check sees the same
# shape it gets on later polls, which come from the checkpoint.
current_state = self._deserialize(
self._serialize(self.config.initial_state)
)
# No poll state has been checkpointed yet, so replay reconstructs
# the configured initial state directly.
current_state = self.config.initial_state

Comment on lines +466 to +468
except RetryableSerDesError:
# Transient failure: propagate so it retries the invocation.
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Codex AI review

P2 Only retryable errors are propagated unchanged. If a custom SerDes deliberately raises SerDesError with data or stack_trace, the generic handler replaces it with a fresh instance and discards that metadata before ErrorObject.from_exception can checkpoint it. Propagate SerDesError unchanged, or copy its fields, in both serialize and deserialize; add coverage for an explicitly raised permanent error.

@github-actions

Copy link
Copy Markdown

Codex AI review

The round-trip logic has retry and compatibility regressions, plus metadata loss for explicit permanent SerDes errors.

Reviewed commit a3b48c2235ec37c5f3d81a36ae5f9285aafdd783. 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.

3 participants