Skip to content

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

Open
ayushiahjolia wants to merge 2 commits into
mainfrom
serdes-roundtrip-first-run
Open

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

Conversation

@ayushiahjolia

Copy link
Copy Markdown
Contributor

Issue #, if available:
#406
#544

Description of changes:
Extends the step operation's first-run behavior (#543) to the remaining operations: on the first run, an operation now returns the serdes round-tripped value deserialize(serialize(result)) - instead of the raw in-memory result. This is the same value each operation reconstructs
on replay, so results stay identical across the first run and every replay when a non-identity custom SerDes is configured.

Covered operations:

  • wait_for_condition: returns the round-tripped state on success.
  • run_in_child_context: round-trips in every mode - normal, virtual, and large-payload (ReplayChildren). Large payloads still checkpoint only a compact summary; the full result is round-tripped transiently for the return value. This also flows through map/parallel, whose items and BatchResult go through the child handler.
  • invoke and callback need no change (invoke always deserializes from the checkpoint; callback returns only an id).

Testing:

  • Covered by unit test, integration test and cloud examples.

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
# returned as-is.
if serialized_state is None:
return None # type: ignore[return-value]
return deserialize(

@zhongkechen zhongkechen Jul 20, 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.

I think deserialize should happen before we checkpoint SUCCEEDED. If deserialize throws, we checkpointed SUCCEEDED and then checkpoint another FAILED for the same operation. Same issue in child operation.

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.

+1

just thinking through the various points this applies:

  1. wait_for_condition:
serialize -> deserialize -> wait_strategy(deserialized_state, attempt) -> checkpoint SUCCEED -> return deserialized_state.

This fixes the double-checkpoint and also matches JS, where the wait strategy already sees the round-tripped state.

Note the wait_for_condition reordering fixes a second, separate divergence for free: today Python passes the raw state to wait_strategy while JS passes the round-tripped state, so with a non-identity serdes the same strategy can decide differently across SDKs. Deserializing before the wait_strategy call closes that gap too.

  1. child:

deserialize before the SUCCEED checkpoint, in all three modes. Note this intentionally diverges from JS ordering (JS checkpoints first). JS is only safe there because safeDeserialize terminates the invocation instead of throwing, so it never writes a FAIL after a SUCCEED.

Deserialize-before-checkpoint is prob the correct Python equivalent, and matches how serialize failures already behave (FAIL, no SUCCEED)?

  1. step
    same pattern exists there from fix(step): Return round-tripped result on first run #543 (deserialize after SUCCEED, inside the try that routes to the retry handler)

# returned as-is.
if serialized_state is None:
return None # type: ignore[return-value]
return deserialize(

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.

+1

just thinking through the various points this applies:

  1. wait_for_condition:
serialize -> deserialize -> wait_strategy(deserialized_state, attempt) -> checkpoint SUCCEED -> return deserialized_state.

This fixes the double-checkpoint and also matches JS, where the wait strategy already sees the round-tripped state.

Note the wait_for_condition reordering fixes a second, separate divergence for free: today Python passes the raw state to wait_strategy while JS passes the round-tripped state, so with a non-identity serdes the same strategy can decide differently across SDKs. Deserializing before the wait_strategy call closes that gap too.

  1. child:

deserialize before the SUCCEED checkpoint, in all three modes. Note this intentionally diverges from JS ordering (JS checkpoints first). JS is only safe there because safeDeserialize terminates the invocation instead of throwing, so it never writes a FAIL after a SUCCEED.

Deserialize-before-checkpoint is prob the correct Python equivalent, and matches how serialize failures already behave (FAIL, no SUCCEED)?

  1. step
    same pattern exists there from fix(step): Return round-tripped result on first run #543 (deserialize after SUCCEED, inside the try that routes to the retry handler)

def create_wait_for_condition_retry(
cls,
identifier: OperationIdentifier,
payload: str,

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.

shouldn't this also be str | None like the others are now?

# Ready to execute (checkpoint exists or was just created)
return CheckResult.create_is_ready_to_execute(checkpointed_result)

def _deserialize_payload(self, serialized: str | None) -> T:

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.

nice! keeping all three child return sites identical :-)

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