Skip to content

Commit be30636

Browse files
committed
fix: don't surface suspend as a FAILED outcome
A user function that suspends (for example a child context that waits) was reported to instrumentation plugins as a FAILED outcome, so the OTEL plugin recorded the suspend as a span error and X-Ray surfaced a fault on the child-context span. The execution itself behaved correctly; only the instrumentation labeling was wrong. Drop the plugin notification on suspend so that on_user_function_end no longer fires for the suspending invocation. The OTEL plugin's existing on_invocation_end sweep already ends all remaining operation spans cleanly (no status, no fault). Also drop durable.attempt.number and durable.attempt.outcome from CONTEXT spans. These per-attempt fields are meaningful for STEP (each retry is an attempt) but not for CONTEXT, and dropping them aligns the emitted attributes across SDKs. STEP spans are unchanged. No public plugin API change: UserFunctionOutcome retains the SUCCEEDED and FAILED values plugins already used. Closes #491
1 parent 893268f commit be30636

6 files changed

Lines changed: 126 additions & 23 deletions

File tree

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -453,11 +453,8 @@ def on_user_function_end(self, info: UserFunctionEndInfo) -> None:
453453
else "Unknown error"
454454
)
455455
)
456-
elif info.outcome is UserFunctionOutcome.SUCCEEDED:
457-
span.set_status(StatusCode.OK)
458456
else:
459-
# PENDING
460-
span.set_status(StatusCode.UNSET, "PENDING")
457+
span.set_status(StatusCode.OK)
461458

462459
end_timestamp = info.end_time
463460
if end_timestamp is not None and end_timestamp == info.start_time:
@@ -493,9 +490,13 @@ def _extract_attributes(self, info: Any) -> dict[str, str]:
493490
attributes["durable.operation.type"] = info.operation_type.value
494491
if hasattr(info, "name") and info.name is not None:
495492
attributes["durable.operation.name"] = info.name
496-
if hasattr(info, "attempt") and info.attempt is not None:
497-
attributes["durable.attempt.number"] = info.attempt
498-
if hasattr(info, "outcome") and info.outcome is not None:
499-
attributes["durable.attempt.outcome"] = info.outcome.value
493+
# Per-attempt fields are meaningful for STEP (each attempt is retried)
494+
# but not for CONTEXT (a context is entered once per invocation, not
495+
# retried). Omit them on CONTEXT spans for cross-SDK consistency.
496+
if getattr(info, "operation_type", None) is not OperationType.CONTEXT:
497+
if hasattr(info, "attempt") and info.attempt is not None:
498+
attributes["durable.attempt.number"] = info.attempt
499+
if hasattr(info, "outcome") and info.outcome is not None:
500+
attributes["durable.attempt.outcome"] = info.outcome.value
500501

501502
return attributes

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
@@ -269,6 +269,51 @@ def test_user_function_callbacks_emit_attempt_span_attributes():
269269
)
270270

271271

272+
def test_context_span_omits_attempt_attributes():
273+
"""CONTEXT operations do not carry per-attempt attributes.
274+
275+
durable.attempt.number and durable.attempt.outcome are meaningful for
276+
STEP operations (each retry is an attempt) but not for CONTEXT, so the
277+
plugin omits them on CONTEXT spans for cross-SDK consistency.
278+
"""
279+
plugin, exporter = _create_plugin()
280+
plugin.on_invocation_start(_invocation_start_info())
281+
operation_id = "ctx-1"
282+
283+
plugin.on_user_function_start(
284+
UserFunctionStartInfo(
285+
operation_id=operation_id,
286+
operation_type=OperationType.CONTEXT,
287+
sub_type=None,
288+
name="book-trip",
289+
parent_id=None,
290+
start_time=START_TIME,
291+
is_replay_children=False,
292+
attempt=1,
293+
)
294+
)
295+
plugin.on_user_function_end(
296+
UserFunctionEndInfo(
297+
operation_id=operation_id,
298+
operation_type=OperationType.CONTEXT,
299+
sub_type=None,
300+
name="book-trip",
301+
parent_id=None,
302+
start_time=START_TIME,
303+
is_replay_children=False,
304+
attempt=1,
305+
outcome=UserFunctionOutcome.SUCCEEDED,
306+
end_time=END_TIME,
307+
error=None,
308+
)
309+
)
310+
311+
span = exporter.get_finished_spans()[0]
312+
assert span.attributes["durable.operation.type"] == OperationType.CONTEXT.value
313+
assert "durable.attempt.number" not in span.attributes
314+
assert "durable.attempt.outcome" not in span.attributes
315+
316+
272317
def test_span_registry_helpers_can_be_called_from_multiple_threads():
273318
"""Verify active span registry helpers are safe under concurrent access."""
274319
plugin, _ = _create_plugin()

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import contextlib
24
import datetime
35
import functools
@@ -7,7 +9,6 @@
79
from enum import Enum
810
from typing import Any, Callable, MutableMapping
911

10-
from aws_durable_execution_sdk_python.exceptions import SuspendExecution
1112
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
1213
from aws_durable_execution_sdk_python.lambda_service import (
1314
DurableExecutionInvocationOutput,
@@ -51,16 +52,12 @@ class OperationEndInfo(OperationInfo):
5152
class UserFunctionOutcome(Enum):
5253
SUCCEEDED = "SUCCEEDED"
5354
FAILED = "FAILED"
54-
PENDING = "PENDING"
5555

5656
@classmethod
57-
def from_error(cls, error: ErrorObject | None) -> "UserFunctionOutcome":
57+
def from_error(cls, error: ErrorObject | None) -> UserFunctionOutcome:
5858
if error is None:
5959
return cls(cls.SUCCEEDED)
60-
elif error.type == SuspendExecution.__name__:
61-
return cls(cls.PENDING)
62-
else:
63-
return cls(cls.FAILED)
60+
return cls(cls.FAILED)
6461

6562

6663
@dataclass(frozen=True)
@@ -86,7 +83,7 @@ class UserFunctionEndInfo(OperationInfo):
8683
@classmethod
8784
def from_start_info(
8885
cls, start_info: UserFunctionStartInfo, error: ErrorObject | None
89-
) -> "UserFunctionEndInfo":
86+
) -> UserFunctionEndInfo:
9087
return UserFunctionEndInfo(
9188
operation_id=start_info.operation_id,
9289
operation_type=start_info.operation_type,

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -952,13 +952,7 @@ def wrapper(*args, **kwargs):
952952
result = user_function(*args, **kwargs)
953953
self._plugin_executor.on_user_function_end(start_info, None)
954954
return result
955-
except SuspendExecution as e:
956-
self._plugin_executor.on_user_function_end(
957-
start_info,
958-
ErrorObject(
959-
type=type(e).__name__, message=None, data=None, stack_trace=None
960-
),
961-
)
955+
except SuspendExecution:
962956
raise
963957
except Exception as e:
964958
self._plugin_executor.on_user_function_end(

packages/aws-durable-execution-sdk-python/tests/plugin_test.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,5 +783,29 @@ def on_operation_attempt_end(self, info):
783783
# endregion Helper Classes
784784

785785

786+
# region Suspend Outcome Tests
787+
class TestUserFunctionOutcomeValues(unittest.TestCase):
788+
def test_outcome_values(self):
789+
self.assertEqual(
790+
{o.value for o in UserFunctionOutcome},
791+
{"SUCCEEDED", "FAILED"},
792+
)
793+
794+
795+
class TestUserFunctionOutcomeFromError(unittest.TestCase):
796+
def test_none_error_is_succeeded(self):
797+
self.assertEqual(
798+
UserFunctionOutcome.from_error(None), UserFunctionOutcome.SUCCEEDED
799+
)
800+
801+
def test_error_is_failed(self):
802+
self.assertEqual(
803+
UserFunctionOutcome.from_error(ERROR), UserFunctionOutcome.FAILED
804+
)
805+
806+
807+
# endregion Suspend Outcome Tests
808+
809+
786810
if __name__ == "__main__":
787811
unittest.main()

packages/aws-durable-execution-sdk-python/tests/state_test.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
DurableApiErrorCategory,
2020
GetExecutionStateError,
2121
OrphanedChildException,
22+
TimedSuspendExecution,
2223
)
2324
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
2425
from aws_durable_execution_sdk_python.lambda_service import (
@@ -41,6 +42,7 @@
4142
from aws_durable_execution_sdk_python.plugin import (
4243
DurableInstrumentationPlugin,
4344
PluginExecutor,
45+
UserFunctionEndInfo,
4446
)
4547
from aws_durable_execution_sdk_python.state import (
4648
CheckpointBatcherConfig,
@@ -4199,6 +4201,46 @@ def on_operation_end(self, info):
41994201
executor.shutdown(wait=True)
42004202

42014203

4204+
def test_wrap_user_function_suspend_does_not_fire_end_hook():
4205+
"""A user function that suspends does not fire the end hook.
4206+
4207+
Regression: a timed suspend (TimedSuspendExecution) raised inside a wrapped
4208+
user function (e.g. a child context that waits) must not be surfaced to
4209+
plugins as a FAILED outcome. The suspend is normal durable control flow,
4210+
and the plugin observes it by absence (no end hook fires), with the
4211+
instrumentation plugin's own per-invocation span sweep closing any open
4212+
spans cleanly at invocation end.
4213+
"""
4214+
captured: list[UserFunctionEndInfo] = []
4215+
4216+
class _CapturingPlugin(DurableInstrumentationPlugin):
4217+
def on_user_function_end(self, info: UserFunctionEndInfo) -> None:
4218+
captured.append(info)
4219+
4220+
plugin_executor = PluginExecutor(plugins=[_CapturingPlugin()])
4221+
with plugin_executor.run():
4222+
state = ExecutionState(
4223+
durable_execution_arn="test_arn",
4224+
initial_checkpoint_token="token123", # noqa: S106
4225+
operations={},
4226+
service_client=create_autospec(spec=LambdaClient),
4227+
plugin_executor=plugin_executor,
4228+
)
4229+
4230+
def suspends(_: object) -> None:
4231+
raise TimedSuspendExecution.from_delay("waiting", 5)
4232+
4233+
op_id = OperationIdentifier(
4234+
operation_id="op-1", sub_type=OperationSubType.STEP, name="step"
4235+
)
4236+
wrapped = state.wrap_user_function(suspends, op_id, attempt=1)
4237+
4238+
with pytest.raises(TimedSuspendExecution):
4239+
wrapped(None)
4240+
4241+
assert captured == []
4242+
4243+
42024244
def test_plugin_executor_not_called_for_pending_operations():
42034245
"""Test that plugin_executor.on_operation_update fires on_user_function_end for PENDING operations."""
42044246
mock_client = create_autospec(spec=LambdaClient)

0 commit comments

Comments
 (0)