Skip to content

Commit 2b119dd

Browse files
wangyb-AAlex Wang
andauthored
feat(plugin): report incomplete user function outcomes (#661)
* feat(plugin): report incomplete user function outcomes --------- Co-authored-by: Alex Wang <wangyb@amazon.com>
1 parent edc9cb4 commit 2b119dd

9 files changed

Lines changed: 645 additions & 160 deletions

File tree

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,25 @@ def _pop_span(self, key: str) -> Span | None:
167167
return self._operation_spans.pop(key, None)
168168

169169
@staticmethod
170-
def _attempt_key(info: UserFunctionStartInfo | UserFunctionEndInfo) -> str:
170+
def _attempt_key(
171+
info: UserFunctionStartInfo | UserFunctionEndInfo,
172+
) -> str:
171173
return f"{info.operation_id}:attempt:{info.attempt or 1}"
172174

175+
@classmethod
176+
def _user_function_key(
177+
cls,
178+
info: UserFunctionStartInfo | UserFunctionEndInfo,
179+
) -> str:
180+
"""Return the registry key a user function's span and scope are stored under.
181+
182+
STEP user functions are attempts, so each attempt gets its own key; a
183+
CONTEXT is entered once per invocation and uses the operation id.
184+
"""
185+
if info.operation_type is OperationType.STEP:
186+
return cls._attempt_key(info)
187+
return info.operation_id
188+
173189
# ------------------------------------------------------------------
174190
# Context scope helpers
175191
# ------------------------------------------------------------------
@@ -511,12 +527,12 @@ def on_user_function_start(self, info: UserFunctionStartInfo) -> None:
511527
raise RuntimeError(
512528
"on_user_function_start only supports CONTEXT and STEP operations"
513529
)
530+
key = self._user_function_key(info)
514531
if info.operation_type is OperationType.STEP:
515532
parent = self._get_span(info.operation_id) or self._resolve_parent(
516533
info.parent_id
517534
)
518535
name = f"{info.name or info.operation_id} attempt {info.attempt or 1}"
519-
key = self._attempt_key(info)
520536
span = self._start_span(
521537
operation_id=info.operation_id,
522538
name=name,
@@ -528,7 +544,6 @@ def on_user_function_start(self, info: UserFunctionStartInfo) -> None:
528544
)
529545
else: # CONTEXT
530546
parent = self._resolve_parent(info.parent_id)
531-
key = info.operation_id
532547
span = self._start_span(
533548
operation_id=info.operation_id,
534549
name=info.name or info.operation_id,
@@ -548,17 +563,16 @@ def on_user_function_end(self, info: UserFunctionEndInfo) -> None:
548563
raise RuntimeError(
549564
"on_user_function_end only supports CONTEXT and STEP operations"
550565
)
551-
key = (
552-
self._attempt_key(info)
553-
if info.operation_type is OperationType.STEP
554-
else info.operation_id
555-
)
566+
key = self._user_function_key(info)
556567
span = self._get_span(key)
557568
if span is None:
558569
raise RuntimeError(
559570
"on_user_function_end without matching on_user_function_start"
560571
)
561-
if info.operation_type is OperationType.STEP:
572+
if (
573+
info.operation_type is OperationType.STEP
574+
and info.outcome is not UserFunctionOutcome.INCOMPLETE
575+
):
562576
span.set_attributes(self._operation_attributes(info))
563577
if info.outcome is UserFunctionOutcome.FAILED:
564578
span.set_status(

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

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,26 @@ def _get_span(self, operation_id: str | None) -> Span | None:
168168
return self._operation_spans.get(operation_id)
169169

170170
@staticmethod
171-
def _attempt_span_key(info: UserFunctionStartInfo | UserFunctionEndInfo) -> str:
171+
def _attempt_span_key(
172+
info: UserFunctionStartInfo | UserFunctionEndInfo,
173+
) -> str:
172174
"""Return the registry key for a STEP attempt span."""
173175
return f"{info.operation_id}:attempt:{info.attempt or 1}"
174176

177+
@classmethod
178+
def _user_function_span_key(
179+
cls,
180+
info: UserFunctionStartInfo | UserFunctionEndInfo,
181+
) -> str:
182+
"""Return the registry key a user function's span and scope are stored under.
183+
184+
STEP user functions are attempts, so each attempt gets its own key; a
185+
CONTEXT is entered once per invocation and uses the operation id.
186+
"""
187+
if info.operation_type is OperationType.STEP:
188+
return cls._attempt_span_key(info)
189+
return info.operation_id
190+
175191
# ------------------------------------------------------------------
176192
# Context scope helpers
177193
# ------------------------------------------------------------------
@@ -613,11 +629,7 @@ def on_user_function_start(self, info: UserFunctionStartInfo) -> None:
613629
span_name = info.name or info.operation_id
614630
if info.operation_type is OperationType.STEP:
615631
span_name = f"{span_name} attempt {info.attempt or 1}"
616-
span_key = (
617-
self._attempt_span_key(info)
618-
if info.operation_type is OperationType.STEP
619-
else info.operation_id
620-
)
632+
span_key = self._user_function_span_key(info)
621633
span = self._start_span(
622634
operation_id=info.operation_id,
623635
name=span_name,
@@ -648,19 +660,17 @@ def on_user_function_end(self, info: UserFunctionEndInfo) -> None:
648660
raise RuntimeError(
649661
"on_user_function_end should only be called for CONTEXT and STEP operations"
650662
)
651-
# key = f"{info.operation_id}-{int(info.start_time.timestamp())}"
652-
span_key = (
653-
self._attempt_span_key(info)
654-
if info.operation_type is OperationType.STEP
655-
else info.operation_id
656-
)
663+
span_key = self._user_function_span_key(info)
657664
span = self._get_span(span_key)
658665
if not span:
659666
raise RuntimeError(
660667
"on_user_function_end called without matching on_user_function_start"
661668
)
662669

663-
if info.operation_type is OperationType.STEP:
670+
if (
671+
info.operation_type is OperationType.STEP
672+
and info.outcome is not UserFunctionOutcome.INCOMPLETE
673+
):
664674
span.set_attributes(self._extract_attributes(info))
665675
if info.outcome is UserFunctionOutcome.FAILED:
666676
span.set_status(

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

Lines changed: 97 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,48 @@ def _step_end_info(
613613
)
614614

615615

616+
def _step_incomplete_info(
617+
operation_id: str,
618+
parent_id: str | None = None,
619+
attempt: int = 1,
620+
) -> UserFunctionEndInfo:
621+
return UserFunctionEndInfo(
622+
operation_id=operation_id,
623+
operation_type=OperationType.STEP,
624+
sub_type=OperationSubType.STEP,
625+
name=operation_id,
626+
parent_id=parent_id,
627+
start_time=START_TIME,
628+
is_replayed=False,
629+
status=OperationStatus.STARTED,
630+
is_replay_children=False,
631+
attempt=attempt,
632+
outcome=UserFunctionOutcome.INCOMPLETE,
633+
end_time=END_TIME,
634+
error=None,
635+
)
636+
637+
638+
def _context_incomplete_info(
639+
operation_id: str, parent_id: str | None = None
640+
) -> UserFunctionEndInfo:
641+
return UserFunctionEndInfo(
642+
operation_id=operation_id,
643+
operation_type=OperationType.CONTEXT,
644+
sub_type=OperationSubType.RUN_IN_CHILD_CONTEXT,
645+
name=operation_id,
646+
parent_id=parent_id,
647+
start_time=START_TIME,
648+
is_replayed=False,
649+
status=OperationStatus.STARTED,
650+
is_replay_children=False,
651+
attempt=1,
652+
outcome=UserFunctionOutcome.INCOMPLETE,
653+
end_time=END_TIME,
654+
error=None,
655+
)
656+
657+
616658
def _context_start_info(
617659
operation_id: str, parent_id: str | None = None
618660
) -> UserFunctionStartInfo:
@@ -882,75 +924,85 @@ def test_reentered_step_attempt_releases_the_previous_scope():
882924
assert plugin._context_tokens == {}
883925

884926

885-
def test_reentry_on_another_thread_leaves_the_originating_worker_dirty():
886-
"""Pin what re-entry can and cannot clean up across threads.
927+
def test_suspension_releases_the_scope_on_the_originating_worker():
928+
"""Verify the suspending worker releases its own scope.
887929
888-
A resumed branch can land on a different pool thread than the one that
889-
suspended. Re-entry drops the foreign token instead of resetting it, because
890-
a context token can only be reset on its own thread, and it unwinds cleanly
891-
on the thread that re-entered. The worker that suspended keeps the abandoned
892-
span current: releasing it needs a hook invoked on that thread when the user
893-
function fails to complete, which the SDK does not provide. The worker is
894-
kept alive here so this limitation is asserted rather than hidden by pool
895-
shutdown; the assertion flips once such a hook exists.
930+
A suspended user function reports no outcome, so the SDK fires
931+
on_user_function_end with INCOMPLETE on the thread that ran it -- the only thread that
932+
can reset its context token. The worker is kept alive and probed to prove it
933+
is left clean even though the resume lands on a different thread.
896934
"""
897-
plugin, _ = _create_plugin()
935+
plugin, exporter = _create_plugin()
898936
plugin.on_invocation_start(_invocation_start_info())
899937
before_context = otel_context.get_current()
900938
span_key = "step-1:attempt:1"
901939

902940
with ThreadPoolExecutor(max_workers=1) as worker:
903-
# The suspending run happens on the worker and never reports an end.
904-
worker.submit(
905-
plugin.on_user_function_start, _step_start_info("step-1")
906-
).result()
907-
abandoned_span = plugin._get_span(span_key)
908-
assert abandoned_span is not None
909-
foreign_thread_ident, _foreign_token = plugin._context_tokens[span_key]
910-
assert foreign_thread_ident != threading.get_ident()
911941

912-
# The timed resume lands on this thread instead.
942+
def suspend_on_worker() -> tuple[int, bool]:
943+
plugin.on_user_function_start(_step_start_info("step-1"))
944+
attached_span_id = trace.get_current_span().get_span_context().span_id
945+
plugin.on_user_function_end(_step_incomplete_info("step-1"))
946+
return (
947+
attached_span_id,
948+
trace.get_current_span().get_span_context().is_valid,
949+
)
950+
951+
attached_span_id, span_still_current = worker.submit(suspend_on_worker).result()
952+
suspended_span = plugin._get_span(span_key)
953+
954+
# The scope was released on the worker, and its span is left open.
955+
assert attached_span_id != 0
956+
assert span_still_current is False
957+
assert span_key not in plugin._context_tokens
958+
assert suspended_span is not None
959+
assert not exporter.get_finished_spans()
960+
961+
# The timed resume lands on this thread, with nothing stale to unwind.
913962
plugin.on_user_function_start(_step_start_info("step-1"))
914963
assert plugin._context_tokens[span_key][0] == threading.get_ident()
915964
plugin.on_user_function_end(_step_end_info("step-1"))
916-
917-
# This thread unwound to where it started.
918965
assert otel_context.get_current() == before_context
919966

920-
# The originating worker is still carrying the abandoned span.
921-
worker_span_id = worker.submit(
922-
lambda: trace.get_current_span().get_span_context().span_id
967+
# The originating worker is still clean.
968+
worker_span_valid = worker.submit(
969+
lambda: trace.get_current_span().get_span_context().is_valid
923970
).result()
924-
assert worker_span_id == abandoned_span.get_span_context().span_id
971+
assert worker_span_valid is False
925972

926973
plugin.on_invocation_end(_invocation_end_info())
927974

928975

929-
def test_nested_reentry_restores_the_abandoned_outer_scope():
930-
"""Pin nested re-entry: correct ids, but the abandoned outer span object.
976+
def test_nested_suspension_unwinds_scopes_in_reverse_order():
977+
"""Verify nested suspends release inner-first and resume without stale scopes.
931978
932-
When an outer child context and an inner one both suspend, re-entry releases
933-
each scope in the order the operations are replayed, which is not the reverse
934-
of the order they were attached. Ending the inner operation therefore
935-
restores the scope captured for the abandoned outer span rather than the
936-
resumed one. Deterministic CONTEXT span ids make the two indistinguishable
937-
downstream -- same trace id and span id, so parenting and log correlation are
938-
unaffected -- but the current span object is one that is never exported, so
939-
anything an instrumentation library records on it is lost. Reverse-order
940-
unwinding needs the SDK to report the suspension; this test documents the
941-
current behaviour and flips when that lands.
979+
The INCOMPLETE end callback fires as the exception propagates outward, so the inner
980+
context's scope is released before its enclosing one. On resume, ending the
981+
inner operation restores the resumed outer scope rather than the one captured
982+
for the suspended run.
942983
"""
943-
plugin, _ = _create_plugin()
984+
plugin, exporter = _create_plugin()
944985
plugin.on_invocation_start(_invocation_start_info())
945986
before_context = otel_context.get_current()
946987

947-
# Both contexts suspend, so neither reports an end.
948988
plugin.on_user_function_start(_context_start_info("ctx-outer"))
949-
abandoned_outer = plugin._get_span("ctx-outer")
989+
suspended_outer = plugin._get_span("ctx-outer")
950990
plugin.on_user_function_start(
951991
_context_start_info("ctx-inner", parent_id="ctx-outer")
952992
)
953-
assert abandoned_outer is not None
993+
assert suspended_outer is not None
994+
995+
# Both contexts suspend: the inner one unwinds first.
996+
plugin.on_user_function_end(
997+
_context_incomplete_info("ctx-inner", parent_id="ctx-outer")
998+
)
999+
assert trace.get_current_span() is suspended_outer
1000+
1001+
plugin.on_user_function_end(_context_incomplete_info("ctx-outer"))
1002+
assert otel_context.get_current() == before_context
1003+
assert set(plugin._context_tokens) == {"__invocation_context__"}
1004+
# Neither span is ended: both operations are still in flight.
1005+
assert not exporter.get_finished_spans()
9541006

9551007
# The timed in-process resume replays both contexts, outer first.
9561008
plugin.on_user_function_start(_context_start_info("ctx-outer"))
@@ -960,27 +1012,14 @@ def test_nested_reentry_restores_the_abandoned_outer_scope():
9601012
)
9611013
resumed_inner = plugin._get_span("ctx-inner")
9621014
assert resumed_outer is not None
963-
assert resumed_inner is not None
964-
assert resumed_outer is not abandoned_outer
965-
966-
# Resumed inner code runs under the resumed inner span.
1015+
assert resumed_outer is not suspended_outer
9671016
assert trace.get_current_span() is resumed_inner
9681017

9691018
plugin.on_user_function_end(_context_end_info("ctx-inner", parent_id="ctx-outer"))
9701019

971-
# The restored scope carries the abandoned outer span, whose ids match the
972-
# resumed one because CONTEXT span ids are derived from the operation id.
973-
assert trace.get_current_span() is abandoned_outer
974-
assert (
975-
abandoned_outer.get_span_context().span_id
976-
== resumed_outer.get_span_context().span_id
977-
)
978-
assert (
979-
abandoned_outer.get_span_context().trace_id
980-
== resumed_outer.get_span_context().trace_id
981-
)
1020+
# The resumed outer scope is restored, not the one from the suspended run.
1021+
assert trace.get_current_span() is resumed_outer
9821022

983-
# Leaving the outer context still unwinds to where the invocation started.
9841023
plugin.on_user_function_end(_context_end_info("ctx-outer"))
9851024
assert otel_context.get_current() == before_context
9861025
assert set(plugin._context_tokens) == {"__invocation_context__"}

0 commit comments

Comments
 (0)