Skip to content

Commit 0375c28

Browse files
authored
fix: support updated operation IDs in replay (#504)
1 parent 59ec217 commit 0375c28

15 files changed

Lines changed: 376 additions & 6 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,17 @@
644644
},
645645
"path": "./src/plugin/execution_with_plugin.py"
646646
},
647+
{
648+
"name": "Plugin Wait",
649+
"description": "Test plugin hook emission for a wait completed during suspend",
650+
"handler": "execution_with_wait_plugin.handler",
651+
"integration": true,
652+
"durableConfig": {
653+
"RetentionPeriodInDays": 7,
654+
"ExecutionTimeout": 300
655+
},
656+
"path": "./src/plugin/execution_with_wait_plugin.py"
657+
},
647658
{
648659
"name": "Otel Plugin",
649660
"description": "Test Otel plugin",
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Demonstrates plugin hooks for a wait that completes while suspended."""
2+
3+
from typing import Any, ClassVar
4+
5+
from aws_durable_execution_sdk_python.config import Duration
6+
from aws_durable_execution_sdk_python.context import DurableContext
7+
from aws_durable_execution_sdk_python.execution import durable_execution
8+
from aws_durable_execution_sdk_python.plugin import (
9+
DurableInstrumentationPlugin,
10+
InvocationStartInfo,
11+
OperationEndInfo,
12+
)
13+
14+
15+
class RecordingWaitPlugin(DurableInstrumentationPlugin):
16+
operation_end_infos: ClassVar[list[dict[str, Any]]] = []
17+
18+
@classmethod
19+
def reset(cls) -> None:
20+
cls.operation_end_infos.clear()
21+
22+
@classmethod
23+
def get_wait_end_infos(cls) -> list[dict[str, Any]]:
24+
return [
25+
info
26+
for info in cls.operation_end_infos
27+
if info["operation_type"] == "WAIT" and info["name"] == "plugin-wait"
28+
]
29+
30+
def on_invocation_start(self, _info: InvocationStartInfo) -> None:
31+
self.reset()
32+
33+
def on_operation_end(self, info: OperationEndInfo) -> None:
34+
self.operation_end_infos.append(
35+
{
36+
"operation_type": info.operation_type.value,
37+
"name": info.name,
38+
"status": info.status.value,
39+
"is_replayed": info.is_replayed,
40+
"has_end_time": info.end_time is not None,
41+
}
42+
)
43+
44+
45+
@durable_execution(plugins=[RecordingWaitPlugin()])
46+
def handler(_event: Any, context: DurableContext) -> dict[str, Any]:
47+
context.wait(Duration.from_seconds(1), name="plugin-wait")
48+
return {
49+
"message": "Plugin wait completed",
50+
"wait_end_infos": RecordingWaitPlugin.get_wait_end_infos(),
51+
}

packages/aws-durable-execution-sdk-python-examples/template.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,24 @@
10501050
}
10511051
}
10521052
},
1053+
"ExecutionWithWaitPlugin": {
1054+
"Type": "AWS::Serverless::Function",
1055+
"Properties": {
1056+
"CodeUri": "build/",
1057+
"Handler": "execution_with_wait_plugin.handler",
1058+
"Description": "Test plugin hook emission for a wait completed during suspend",
1059+
"Role": {
1060+
"Fn::GetAtt": [
1061+
"DurableFunctionRole",
1062+
"Arn"
1063+
]
1064+
},
1065+
"DurableConfig": {
1066+
"RetentionPeriodInDays": 7,
1067+
"ExecutionTimeout": 300
1068+
}
1069+
}
1070+
},
10531071
"ExecutionWithOtel": {
10541072
"Type": "AWS::Serverless::Function",
10551073
"Properties": {

packages/aws-durable-execution-sdk-python-examples/test/plugin/test_plugin.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
import pytest
44
from aws_durable_execution_sdk_python.execution import InvocationStatus
5+
from aws_durable_execution_sdk_python.lambda_service import (
6+
OperationStatus,
7+
OperationType,
8+
)
59

6-
from src.plugin import execution_with_plugin
10+
from src.plugin import execution_with_plugin, execution_with_wait_plugin
711
from test.conftest import deserialize_operation_payload
812

913

@@ -22,3 +26,32 @@ def test_plugin(durable_runner):
2226

2327
step_result = result.get_step("add-result-to-2")
2428
assert deserialize_operation_payload(step_result.result) == 12
29+
30+
31+
@pytest.mark.example
32+
@pytest.mark.durable_execution(
33+
handler=execution_with_wait_plugin.handler,
34+
lambda_function_name="Plugin Wait",
35+
)
36+
def test_plugin_on_operation_end_called_for_wait_completed_during_suspend(
37+
durable_runner, monkeypatch
38+
):
39+
monkeypatch.setenv("DURABLE_EXECUTION_TIME_SCALE", "0.01")
40+
41+
with durable_runner:
42+
result = durable_runner.run(input=None, timeout=30)
43+
44+
assert result.status is InvocationStatus.SUCCEEDED
45+
result_data = deserialize_operation_payload(result.result)
46+
assert result_data["message"] == "Plugin wait completed"
47+
48+
wait_op = result.get_wait("plugin-wait")
49+
assert wait_op.status is OperationStatus.SUCCEEDED
50+
51+
wait_end_infos = result_data["wait_end_infos"]
52+
53+
assert len(wait_end_infos) == 1
54+
assert wait_end_infos[0]["operation_type"] == OperationType.WAIT.value
55+
assert wait_end_infos[0]["status"] == OperationStatus.SUCCEEDED.value
56+
assert wait_end_infos[0]["is_replayed"] is False
57+
assert wait_end_infos[0]["has_end_time"] is True

packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/plugin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ def on_operation_start(self, info: OperationStartInfo) -> None:
367367
attributes=attributes,
368368
start_time=info.start_time,
369369
parent_span=parent_span,
370+
existed=info.is_replayed,
370371
)
371372

372373
def on_operation_end(self, info: OperationEndInfo) -> None:

packages/aws-durable-execution-sdk-python-otel/tests/test_plugin.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,51 @@ def test_operation_end_without_start_emits_continuation_span_with_link():
247247
)
248248

249249

250+
def test_replayed_operation_start_emits_continuation_span_with_link():
251+
"""Replayed operation spans should not reuse the original deterministic span ID."""
252+
plugin, exporter = _create_plugin()
253+
plugin.on_invocation_start(_invocation_start_info())
254+
operation_id = "wait-replayed"
255+
random_span_id = int("abcdef1234567890", 16)
256+
plugin._id_generator._fallback_id_generator.generate_span_id = lambda: (
257+
random_span_id
258+
)
259+
260+
plugin.on_operation_start(
261+
OperationStartInfo(
262+
operation_id=operation_id,
263+
operation_type=OperationType.WAIT,
264+
sub_type=OperationSubType.WAIT,
265+
name="replayed-wait",
266+
parent_id=None,
267+
start_time=START_TIME,
268+
is_replayed=True,
269+
status=OperationStatus.SUCCEEDED,
270+
)
271+
)
272+
plugin.on_operation_end(
273+
OperationEndInfo(
274+
operation_id=operation_id,
275+
operation_type=OperationType.WAIT,
276+
sub_type=OperationSubType.WAIT,
277+
name="replayed-wait",
278+
parent_id=None,
279+
start_time=START_TIME,
280+
is_replayed=True,
281+
status=OperationStatus.SUCCEEDED,
282+
end_time=END_TIME,
283+
error=None,
284+
)
285+
)
286+
287+
span = exporter.get_finished_spans()[0]
288+
assert span.name == "replayed-wait"
289+
assert span.context.span_id == random_span_id
290+
assert span.links[0].context.span_id == operation_id_to_span_id(
291+
EXECUTION_ARN, operation_id
292+
)
293+
294+
250295
def test_step_operation_span_parents_attempt_span():
251296
"""STEP operations have a logical span with attempt spans beneath it."""
252297
plugin, exporter = _create_plugin()

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/execution.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def __init__(
6060
self.operations: list[Operation] = operations
6161
self.updates: list[OperationUpdate] = []
6262
self.invocation_completions: list[InvocationCompletedDetails] = []
63+
self.updated_operation_ids: list[str] = []
6364
self.used_tokens: set[str] = set()
6465
# TODO: this will need to persist/rehydrate depending on inmemory vs sqllite store
6566
self._token_sequence: int = 0
@@ -108,6 +109,7 @@ def to_json_dict(self) -> dict[str, Any]:
108109
"InvocationCompletions": [
109110
completion.to_json_dict() for completion in self.invocation_completions
110111
],
112+
"UpdatedOperationIds": self.updated_operation_ids,
111113
"UsedTokens": list(self.used_tokens),
112114
"TokenSequence": self._token_sequence,
113115
"IsComplete": self.is_complete,
@@ -142,6 +144,7 @@ def from_json_dict(cls, data: dict[str, Any]) -> Execution:
142144
InvocationCompletedDetails.from_json_dict(item)
143145
for item in data.get("InvocationCompletions", [])
144146
]
147+
execution.updated_operation_ids = list(data.get("UpdatedOperationIds", []))
145148
execution.used_tokens = set(data["UsedTokens"])
146149
execution._token_sequence = data["TokenSequence"] # noqa: SLF001
147150
execution.is_complete = data["IsComplete"]
@@ -239,6 +242,12 @@ def record_invocation_completion(
239242
request_id=request_id,
240243
)
241244
)
245+
self.updated_operation_ids = []
246+
247+
def _record_updated_operation(self, operation_id: str) -> None:
248+
"""Remember an operation changed outside the last invocation."""
249+
if operation_id not in self.updated_operation_ids:
250+
self.updated_operation_ids.append(operation_id)
242251

243252
def complete_success(self, result: str | None) -> None:
244253
"""Complete execution successfully (DecisionType.COMPLETE_WORKFLOW_EXECUTION)."""
@@ -319,6 +328,7 @@ def complete_wait(self, operation_id: str) -> Operation:
319328
status=OperationStatus.SUCCEEDED,
320329
end_timestamp=datetime.now(UTC),
321330
)
331+
self._record_updated_operation(operation_id)
322332
return self.operations[index]
323333

324334
def complete_retry(self, operation_id: str) -> Operation:
@@ -352,6 +362,7 @@ def complete_retry(self, operation_id: str) -> Operation:
352362

353363
# Assign
354364
self.operations[index] = updated_operation
365+
self._record_updated_operation(operation_id)
355366
return updated_operation
356367

357368
def complete_callback_success(
@@ -378,6 +389,7 @@ def complete_callback_success(
378389
end_timestamp=datetime.now(UTC),
379390
callback_details=updated_callback_details,
380391
)
392+
self._record_updated_operation(operation.operation_id)
381393
return self.operations[index]
382394

383395
def complete_callback_failure(
@@ -404,6 +416,7 @@ def complete_callback_failure(
404416
end_timestamp=datetime.now(UTC),
405417
callback_details=updated_callback_details,
406418
)
419+
self._record_updated_operation(operation.operation_id)
407420
return self.operations[index]
408421

409422
def complete_callback_timeout(
@@ -430,6 +443,7 @@ def complete_callback_timeout(
430443
end_timestamp=datetime.now(UTC),
431444
callback_details=updated_callback_details,
432445
)
446+
self._record_updated_operation(operation.operation_id)
433447
return self.operations[index]
434448

435449
def _end_execution(self, status: OperationStatus) -> None:

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/invoker.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ def create_invocation_input(
121121
operations=execution.operations,
122122
next_marker="",
123123
),
124+
updated_operation_ids=list(execution.updated_operation_ids),
124125
service_client=self.service_client,
125126
)
126127

@@ -215,6 +216,7 @@ def create_invocation_input(
215216
operations=execution.operations,
216217
next_marker="",
217218
),
219+
updated_operation_ids=list(execution.updated_operation_ids),
218220
)
219221

220222
def invoke(

packages/aws-durable-execution-sdk-python-testing/tests/execution_test.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def test_execution_init():
4343
assert execution.start_input == start_input
4444
assert execution.operations == operations
4545
assert execution.updates == []
46+
assert execution.updated_operation_ids == []
4647
assert execution.used_tokens == set()
4748
assert execution.token_sequence == 0
4849
assert execution.is_complete is False
@@ -164,6 +165,54 @@ def test_get_new_checkpoint_token():
164165
assert token1 != token2
165166

166167

168+
def test_complete_wait_records_updated_operation_id():
169+
"""Wait completion happens outside the invocation and is reported on the next input."""
170+
start_input = StartDurableExecutionInput(
171+
account_id="123456789012",
172+
function_name="test-function",
173+
function_qualifier="$LATEST",
174+
execution_name="test-execution",
175+
execution_timeout_seconds=300,
176+
execution_retention_period_days=7,
177+
invocation_id="invocation-id",
178+
)
179+
execution = Execution(
180+
"test-arn",
181+
start_input,
182+
[
183+
Operation(
184+
operation_id="wait-1",
185+
operation_type=OperationType.WAIT,
186+
status=OperationStatus.STARTED,
187+
)
188+
],
189+
)
190+
191+
execution.complete_wait("wait-1")
192+
193+
assert execution.updated_operation_ids == ["wait-1"]
194+
195+
196+
def test_record_invocation_completion_clears_updated_operation_ids():
197+
"""Updated IDs are scoped to the next completed invocation."""
198+
start_input = StartDurableExecutionInput(
199+
account_id="123456789012",
200+
function_name="test-function",
201+
function_qualifier="$LATEST",
202+
execution_name="test-execution",
203+
execution_timeout_seconds=300,
204+
execution_retention_period_days=7,
205+
invocation_id="invocation-id",
206+
)
207+
execution = Execution("test-arn", start_input, [])
208+
execution.updated_operation_ids = ["wait-1"]
209+
210+
now = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
211+
execution.record_invocation_completion(now, now, "request-1")
212+
213+
assert execution.updated_operation_ids == []
214+
215+
167216
def test_get_navigable_operations():
168217
"""Test get_navigable_operations method."""
169218
start_input = StartDurableExecutionInput(

packages/aws-durable-execution-sdk-python-testing/tests/invoker_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,15 @@ def test_in_process_invoker_create_invocation_input():
7474
invocation_id="test-invocation-id",
7575
)
7676
execution = Execution.new(input_data)
77+
execution.updated_operation_ids = ["wait-1"]
7778

7879
invocation_input = invoker.create_invocation_input(execution)
7980

8081
assert isinstance(invocation_input, DurableExecutionInvocationInputWithClient)
8182
assert invocation_input.durable_execution_arn == execution.durable_execution_arn
8283
assert invocation_input.checkpoint_token is not None
8384
assert isinstance(invocation_input.initial_execution_state, InitialExecutionState)
85+
assert invocation_input.updated_operation_ids == ["wait-1"]
8486
assert invocation_input.service_client is service_client
8587

8688

0 commit comments

Comments
 (0)