@@ -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+
616658def _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