Skip to content

Commit d9b1ff3

Browse files
committed
fix: round-trip serdes result on first run across ops
1 parent 6967db7 commit d9b1ff3

28 files changed

Lines changed: 2114 additions & 334 deletions

packages/aws-durable-execution-sdk-python-examples/examples-catalog.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,15 @@
4646
"path": "./src/step/step_with_retry.py"
4747
},
4848
{
49-
"name": "Step with Custom SerDes",
50-
"description": "Step with a non-identity custom SerDes; the step returns the canonical round-tripped value, consistent across first run and replay",
51-
"handler": "step_with_custom_serdes.handler",
49+
"name": "Custom SerDes Round-Trip",
50+
"description": "One function proving the first-run/replay result-equality guarantee with a non-identity SerDes across step, wait_for_condition, and run_in_child_context (normal, virtual, large-payload)",
51+
"handler": "serdes_roundtrip.handler",
5252
"integration": true,
5353
"durableConfig": {
5454
"RetentionPeriodInDays": 7,
5555
"ExecutionTimeout": 300
5656
},
57-
"path": "./src/step/step_with_custom_serdes.py"
57+
"path": "./src/serdes_roundtrip/serdes_roundtrip.py"
5858
},
5959
{
6060
"name": "Wait State",
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Custom (non-identity) SerDes round-trip across every operation.
2+
3+
Demonstrates the first-run / replay result-equality guarantee: with a
4+
non-identity ``SerDes``, each durable operation returns the *round-tripped*
5+
value - the value produced by ``serialize`` then ``deserialize`` - on the first
6+
run, which is exactly the value a replay reconstructs from the checkpoint.
7+
Returning the raw in-memory result on the first run would make the first run and
8+
replay disagree whenever the serdes is not a perfect round-trip.
9+
10+
A single deployed function exercises the guarantee across every operation that
11+
checkpoints a serialized result, so there is no need to deploy a separate
12+
example per operation:
13+
14+
* ``step``
15+
* ``wait_for_condition``
16+
* ``run_in_child_context`` - normal, virtual, and large-payload (ReplayChildren)
17+
18+
The shared ``MarkerSerDes`` strips a ``round_tripped`` marker on ``serialize``
19+
and re-adds it on ``deserialize``, so ``deserialize(serialize(x)) != x``. Every
20+
operation's result therefore carries ``round_tripped=True`` only if the SDK
21+
handed back the canonical round-tripped value rather than the raw one.
22+
"""
23+
24+
import json
25+
from typing import Any
26+
27+
from aws_durable_execution_sdk_python.config import ChildConfig, StepConfig
28+
from aws_durable_execution_sdk_python.context import DurableContext
29+
from aws_durable_execution_sdk_python.execution import durable_execution
30+
from aws_durable_execution_sdk_python.serdes import SerDes, SerDesContext
31+
from aws_durable_execution_sdk_python.waits import (
32+
WaitForConditionConfig,
33+
WaitForConditionDecision,
34+
)
35+
36+
# Results larger than this are not checkpointed in full; the child context
37+
# switches to ReplayChildren mode (a compact summary is checkpointed and the
38+
# child re-executes on replay). Kept in sync with the SDK constant.
39+
CHECKPOINT_SIZE_LIMIT_BYTES = 256 * 1024
40+
41+
42+
class MarkerSerDes(SerDes[dict[str, Any]]):
43+
"""Non-identity serdes: ``deserialize`` re-adds a marker ``serialize`` strips.
44+
45+
``deserialize(serialize(value)) == {**value, "round_tripped": True}``, which
46+
differs from ``value`` whenever ``value`` lacks the marker. That makes the
47+
round-trip observable in the value each operation returns.
48+
"""
49+
50+
def serialize(self, value: dict[str, Any], _: SerDesContext) -> str:
51+
payload = {k: v for k, v in dict(value).items() if k != "round_tripped"}
52+
return json.dumps(payload)
53+
54+
def deserialize(self, data: str, _: SerDesContext) -> dict[str, Any]:
55+
return {**json.loads(data), "round_tripped": True}
56+
57+
58+
def _large_child_summary(_result: dict[str, Any]) -> str:
59+
"""Compact summary checkpointed in place of the large child result."""
60+
return json.dumps({"type": "large-child-result"})
61+
62+
63+
def _stop_immediately(
64+
_state: dict[str, Any], _attempt: int
65+
) -> WaitForConditionDecision:
66+
"""Meet the wait_for_condition on the first check."""
67+
return WaitForConditionDecision.stop_polling()
68+
69+
70+
@durable_execution
71+
def handler(_event: Any, context: DurableContext) -> dict[str, bool]:
72+
"""Run every operation with a non-identity serdes and report the round-trip.
73+
74+
Each returned value carries ``round_tripped=True`` only because the SDK
75+
returned ``deserialize(serialize(result))`` on the first run - the same
76+
value the corresponding replay path produces.
77+
"""
78+
serdes = MarkerSerDes()
79+
80+
step_result = context.step(
81+
lambda _step_ctx: {"op": "step"},
82+
name="step",
83+
config=StepConfig(serdes=serdes),
84+
)
85+
86+
child_result = context.run_in_child_context(
87+
lambda _child_ctx: {"op": "child"},
88+
name="child",
89+
config=ChildConfig(serdes=serdes),
90+
)
91+
92+
virtual_result = context.run_in_child_context(
93+
lambda _child_ctx: {"op": "virtual-child"},
94+
name="virtual-child",
95+
config=ChildConfig(serdes=serdes, is_virtual=True),
96+
)
97+
98+
# A result larger than the checkpoint limit forces ReplayChildren mode: only
99+
# the summary is checkpointed, yet the returned value is still the full
100+
# round-tripped result.
101+
large_blob = "x" * (CHECKPOINT_SIZE_LIMIT_BYTES + 1)
102+
large_result = context.run_in_child_context(
103+
lambda _child_ctx: {"op": "large-child", "blob": large_blob},
104+
name="large-child",
105+
config=ChildConfig(serdes=serdes, summary_generator=_large_child_summary),
106+
)
107+
108+
wait_for_condition_result = context.wait_for_condition(
109+
check=lambda _state, _ctx: {"op": "wait-for-condition"},
110+
config=WaitForConditionConfig(
111+
initial_state={},
112+
wait_strategy=_stop_immediately,
113+
serdes=serdes,
114+
),
115+
name="wait-for-condition",
116+
)
117+
118+
# Only the marker booleans are returned (never the large blob) so the
119+
# handler result stays small and easy to assert on.
120+
return {
121+
"step_round_tripped": step_result.get("round_tripped", False),
122+
"child_round_tripped": child_result.get("round_tripped", False),
123+
"virtual_child_round_tripped": virtual_result.get("round_tripped", False),
124+
"large_child_round_tripped": large_result.get("round_tripped", False),
125+
"wait_for_condition_round_tripped": wait_for_condition_result.get(
126+
"round_tripped", False
127+
),
128+
}

packages/aws-durable-execution-sdk-python-examples/src/step/step_with_custom_serdes.py

Lines changed: 0 additions & 65 deletions
This file was deleted.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Tests for the combined custom-serdes round-trip example."""
2+
3+
import pytest
4+
from aws_durable_execution_sdk_python.execution import InvocationStatus
5+
6+
from src.serdes_roundtrip import serdes_roundtrip
7+
from test.conftest import deserialize_operation_payload
8+
9+
10+
@pytest.mark.example
11+
@pytest.mark.durable_execution(
12+
handler=serdes_roundtrip.handler,
13+
lambda_function_name="Custom SerDes Round-Trip",
14+
)
15+
def test_serdes_roundtrip_all_operations(durable_runner):
16+
"""Every operation returns the canonical, round-tripped value on first run.
17+
18+
With a non-identity serdes, each operation (step, wait_for_condition, and
19+
every run_in_child_context variant - normal, virtual, large-payload) must
20+
return ``deserialize(serialize(result))`` on the first run, so the returned
21+
value matches what a replay reconstructs. The handler reports a boolean per
22+
operation that is True only when the marker added by ``deserialize`` is
23+
present.
24+
"""
25+
with durable_runner:
26+
result = durable_runner.run(input="test", timeout=15)
27+
28+
assert result.status is InvocationStatus.SUCCEEDED
29+
30+
result_data = deserialize_operation_payload(result.result)
31+
assert result_data == {
32+
"step_round_tripped": True,
33+
"child_round_tripped": True,
34+
"virtual_child_round_tripped": True,
35+
"large_child_round_tripped": True,
36+
"wait_for_condition_round_tripped": True,
37+
}

packages/aws-durable-execution-sdk-python-examples/test/step/test_step_with_custom_serdes.py

Lines changed: 0 additions & 44 deletions
This file was deleted.

packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
InvocationError,
3030
InvokeError,
3131
PluginLoadError,
32+
RetryableSerDesError,
33+
SerDesError,
3234
StepError,
3335
ValidationError,
3436
WaitForConditionError,
@@ -57,6 +59,8 @@
5759
"InvokeError",
5860
"ParallelBranch",
5961
"PluginLoadError",
62+
"RetryableSerDesError",
63+
"SerDesError",
6064
"StepContext",
6165
"StepError",
6266
"ValidationError",

0 commit comments

Comments
 (0)