@@ -66,9 +66,9 @@ def _assert_otel_context_balanced():
6666 """
6767 before = otel_context .get_current ()
6868 yield
69- assert (
70- otel_context . get_current () == before
71- ), "test leaked OTel context state: an attach() was not detached"
69+ assert otel_context . get_current () == before , (
70+ "test leaked OTel context state: an attach () was not detached"
71+ )
7272
7373
7474def _create_plugin (
@@ -365,6 +365,25 @@ def test_deferred_operation_encloses_attempt_timestamps():
365365 assert operation .end_time >= attempt .end_time
366366
367367
368+ def test_deferred_parent_timestamps_are_thread_safe ():
369+ plugin , _ = _create_plugin ()
370+ plugin .on_invocation_start (_invocation_start_info ())
371+
372+ parent = plugin ._workflow_span
373+ assert isinstance (parent , DurableParentSpan )
374+
375+ start_times = [START_TIME + timedelta (seconds = offset ) for offset in (3 , 1 , 2 , 4 )]
376+ end_times = [END_TIME + timedelta (seconds = offset ) for offset in (2 , 4 , 1 , 3 )]
377+ with ThreadPoolExecutor (max_workers = 4 ) as executor :
378+ list (executor .map (parent .note_start_time , start_times ))
379+ list (executor .map (parent .note_end_time , end_times ))
380+
381+ assert parent .normalized_start_time (None ) == START_TIME
382+ assert parent .normalized_end_time (END_TIME ) == END_TIME + timedelta (seconds = 4 )
383+
384+ plugin .on_invocation_end (_invocation_end_info (status = InvocationStatus .PENDING ))
385+
386+
368387def test_operation_parented_under_workflow_and_linked_to_invocation ():
369388 plugin , exporter = _create_plugin ()
370389 plugin .on_invocation_start (_invocation_start_info ())
@@ -748,6 +767,30 @@ def test_suspend_then_resume_operation_exports_one_deterministic_span():
748767 EXECUTION_ARN , operation_id
749768 )
750769
770+ # ReplayChildren/virtual child completion callbacks are replay-only and
771+ # must not re-export the terminal deterministic span in a later invocation.
772+ plugin .on_invocation_start (_invocation_start_info ())
773+ plugin .on_operation_end (
774+ OperationEndInfo (
775+ operation_id = operation_id ,
776+ operation_type = OperationType .WAIT ,
777+ sub_type = OperationSubType .WAIT ,
778+ name = "long-wait" ,
779+ parent_id = None ,
780+ start_time = START_TIME ,
781+ is_replayed = True ,
782+ status = OperationStatus .SUCCEEDED ,
783+ end_time = END_TIME ,
784+ error = None ,
785+ )
786+ )
787+ plugin .on_invocation_end (_invocation_end_info (status = InvocationStatus .PENDING ))
788+
789+ operation_spans = [
790+ s for s in exporter .get_finished_spans () if s .name == "long-wait"
791+ ]
792+ assert len (operation_spans ) == 1
793+
751794
752795def test_suspended_child_context_exports_one_span_on_replay ():
753796 """A child context that suspends then replays exports a single span."""
@@ -792,6 +835,49 @@ def test_suspended_child_context_exports_one_span_on_replay():
792835 )
793836
794837
838+ def test_checkpointless_context_end_uses_a_non_negative_duration ():
839+ plugin , exporter = _create_plugin ()
840+ plugin .on_invocation_start (_invocation_start_info ())
841+
842+ plugin .on_operation_end (
843+ OperationEndInfo (
844+ operation_id = "virtual-context" ,
845+ operation_type = OperationType .CONTEXT ,
846+ sub_type = OperationSubType .RUN_IN_CHILD_CONTEXT ,
847+ name = "virtual-context" ,
848+ parent_id = None ,
849+ start_time = None ,
850+ is_replayed = False ,
851+ status = OperationStatus .SUCCEEDED ,
852+ end_time = END_TIME ,
853+ error = None ,
854+ )
855+ )
856+ plugin .on_invocation_end (_invocation_end_info ())
857+
858+ span = next (
859+ span for span in exporter .get_finished_spans () if span .name == "virtual-context"
860+ )
861+ assert span .start_time == int (END_TIME .timestamp () * 1_000_000_000 )
862+ assert span .end_time > span .start_time
863+
864+
865+ def test_incomplete_attempt_is_marked_when_invocation_ends ():
866+ plugin , exporter = _create_plugin ()
867+ plugin .on_invocation_start (_invocation_start_info ())
868+ plugin .on_user_function_start (_step_start_info ("step-suspends" ))
869+ plugin .on_user_function_end (_step_incomplete_info ("step-suspends" ))
870+
871+ plugin .on_invocation_end (_invocation_end_info (status = InvocationStatus .PENDING ))
872+
873+ attempt = next (
874+ span
875+ for span in exporter .get_finished_spans ()
876+ if span .name == "step-suspends attempt 1"
877+ )
878+ assert attempt .attributes ["durable.span.truncated_at_invocation_boundary" ] is True
879+
880+
795881def test_duplicate_operation_end_exports_span_once ():
796882 """A repeated on_operation_end for one operation exports a single span."""
797883 plugin , exporter = _create_plugin ()
@@ -1049,15 +1135,17 @@ def _context_incomplete_info(
10491135
10501136
10511137def _context_start_info (
1052- operation_id : str , parent_id : str | None = None
1138+ operation_id : str ,
1139+ parent_id : str | None = None ,
1140+ start_time : datetime = START_TIME ,
10531141) -> UserFunctionStartInfo :
10541142 return UserFunctionStartInfo (
10551143 operation_id = operation_id ,
10561144 operation_type = OperationType .CONTEXT ,
10571145 sub_type = OperationSubType .RUN_IN_CHILD_CONTEXT ,
10581146 name = operation_id ,
10591147 parent_id = parent_id ,
1060- start_time = START_TIME ,
1148+ start_time = start_time ,
10611149 is_replayed = False ,
10621150 status = OperationStatus .STARTED ,
10631151 is_replay_children = False ,
@@ -1303,8 +1391,15 @@ def test_reentered_child_context_does_not_leave_abandoned_span_current():
13031391 assert suspended_span is not None
13041392
13051393 # Timed in-process resume re-enters the same operation.
1306- plugin .on_user_function_start (_context_start_info (context_id ))
1394+ plugin .on_user_function_start (
1395+ _context_start_info (
1396+ context_id ,
1397+ start_time = START_TIME + timedelta (seconds = 1 ),
1398+ )
1399+ )
13071400 assert len ([key for key in plugin ._context_tokens if key == context_id ]) == 1
1401+ assert plugin ._get_span (context_id ) is suspended_span
1402+ assert suspended_span .normalized_start_time (None ) == START_TIME
13081403
13091404 plugin .on_user_function_end (_context_end_info (context_id ))
13101405
@@ -1325,7 +1420,12 @@ def test_reentered_step_attempt_releases_the_previous_scope():
13251420 before_context = otel_context .get_current ()
13261421
13271422 plugin .on_user_function_start (_step_start_info ("step-1" ))
1423+ first_attempt = plugin ._get_span ("step-1:attempt:1" )
1424+ assert first_attempt is not None
1425+
13281426 plugin .on_user_function_start (_step_start_info ("step-1" ))
1427+ assert not first_attempt .is_recording ()
1428+
13291429 plugin .on_user_function_end (_step_end_info ("step-1" ))
13301430
13311431 assert otel_context .get_current () == before_context
@@ -1423,7 +1523,9 @@ def test_nested_suspension_unwinds_scopes_in_reverse_order():
14231523 )
14241524 resumed_inner = plugin ._get_span ("ctx-inner" )
14251525 assert resumed_outer is not None
1426- assert resumed_outer is not suspended_outer
1526+ # Re-entry reuses the deferred placeholder so timestamps from the
1527+ # suspended run remain available when the context eventually completes.
1528+ assert resumed_outer is suspended_outer
14271529 assert trace .get_current_span () is resumed_inner
14281530
14291531 plugin .on_user_function_end (_context_end_info ("ctx-inner" , parent_id = "ctx-outer" ))
0 commit comments