Skip to content

Commit 8874bd9

Browse files
author
Frank Chen
committed
fix(otel): address PR 696 review findings
1 parent 00efaf2 commit 8874bd9

4 files changed

Lines changed: 163 additions & 29 deletions

File tree

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

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import datetime
6+
import threading
67
from collections.abc import Mapping
78
from typing import Any
89

@@ -44,6 +45,7 @@ def __init__(
4445
self.status = Status(StatusCode.UNSET)
4546
self._earliest_start_time = start_time
4647
self._latest_end_time: datetime.datetime | None = None
48+
self._timestamp_lock = threading.Lock()
4749

4850
def get_span_context(self) -> SpanContext:
4951
return self._span_context
@@ -91,25 +93,31 @@ def note_start_time(self, timestamp: datetime.datetime | None) -> None:
9193
"""Include a descendant or operation start timestamp."""
9294
if timestamp is None:
9395
return
94-
if self._earliest_start_time is None or timestamp < self._earliest_start_time:
95-
self._earliest_start_time = timestamp
96+
with self._timestamp_lock:
97+
if (
98+
self._earliest_start_time is None
99+
or timestamp < self._earliest_start_time
100+
):
101+
self._earliest_start_time = timestamp
96102

97103
def note_end_time(self, timestamp: datetime.datetime | None) -> None:
98104
"""Include a descendant or operation end timestamp."""
99105
if timestamp is None:
100106
return
101-
if self._latest_end_time is None or timestamp > self._latest_end_time:
102-
self._latest_end_time = timestamp
107+
with self._timestamp_lock:
108+
if self._latest_end_time is None or timestamp > self._latest_end_time:
109+
self._latest_end_time = timestamp
103110

104111
def normalized_start_time(
105112
self, timestamp: datetime.datetime | None
106113
) -> datetime.datetime | None:
107114
"""Return a start that encloses all observed descendants."""
108-
if timestamp is None:
109-
return self._earliest_start_time
110-
if self._earliest_start_time is None:
111-
return timestamp
112-
return min(timestamp, self._earliest_start_time)
115+
with self._timestamp_lock:
116+
if timestamp is None:
117+
return self._earliest_start_time
118+
if self._earliest_start_time is None:
119+
return timestamp
120+
return min(timestamp, self._earliest_start_time)
113121

114122
def normalized_end_time(
115123
self,
@@ -118,12 +126,13 @@ def normalized_end_time(
118126
start_time: datetime.datetime | None = None,
119127
) -> datetime.datetime | None:
120128
"""Return an end that encloses all observed descendants."""
121-
if timestamp is None:
122-
normalized = self._latest_end_time
123-
elif self._latest_end_time is None:
124-
normalized = timestamp
125-
else:
126-
normalized = max(timestamp, self._latest_end_time)
129+
with self._timestamp_lock:
130+
if timestamp is None:
131+
normalized = self._latest_end_time
132+
elif self._latest_end_time is None:
133+
normalized = timestamp
134+
else:
135+
normalized = max(timestamp, self._latest_end_time)
127136
if (
128137
start_time is not None
129138
and normalized is not None

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,10 @@ def _register_operation_placeholder(
338338
span_context = self._operation_span_context(operation_id)
339339
if span_context is None:
340340
return None
341+
existing = self._get_span(operation_id)
342+
if isinstance(existing, DurableParentSpan):
343+
existing.note_start_time(start_time)
344+
return existing
341345
placeholder = DurableParentSpan(span_context, start_time=start_time)
342346
self._set_span(operation_id, placeholder)
343347
return placeholder
@@ -548,6 +552,9 @@ def _end_open_recording_spans(self) -> None:
548552
continue
549553
popped = self._pop_span(key)
550554
if popped is not None:
555+
popped.set_attribute(
556+
"durable.span.truncated_at_invocation_boundary", True
557+
)
551558
popped.end()
552559

553560
def _start_invocation_span(self, info: InvocationStartInfo) -> None:
@@ -642,6 +649,11 @@ def on_operation_end(self, info: OperationEndInfo) -> None:
642649
logger.debug("Durable operation ended: %s", info)
643650
if not self._tracing_enabled:
644651
return
652+
# ReplayChildren and virtual child contexts intentionally re-execute
653+
# without creating a new terminal checkpoint. Their end callback is
654+
# replay-only and must not re-export the deterministic logical span.
655+
if info.is_replayed:
656+
return
645657
# Export the span only on the first end for this operation.
646658
with self._lock:
647659
if info.operation_id in self._ended_operation_ids:
@@ -660,6 +672,11 @@ def on_operation_end(self, info: OperationEndInfo) -> None:
660672
end_time,
661673
start_time=start_time,
662674
)
675+
if start_time is None and end_time is not None:
676+
# Checkpointless child contexts report no durable start timestamp.
677+
# Use their callback end as the lower bound rather than allowing
678+
# the tracer to choose a later wall-clock start.
679+
start_time = end_time
663680
end_time = ensure_end_after_start(start_time, end_time)
664681
span = self._start_span(
665682
operation_id=info.operation_id,
@@ -700,6 +717,10 @@ def _start_span(
700717
"""
701718
key = span_key if span_key is not None else operation_id
702719
with self._lock:
720+
existing = self._operation_spans.get(key)
721+
if existing is not None and existing.is_recording():
722+
existing.set_attribute("durable.span.replaced_on_reentry", True)
723+
existing.end()
703724
links = self._build_invocation_links()
704725
span_id = (
705726
operation_id_to_span_id(self._execution_arn, operation_id)

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

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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

7474
def _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+
368387
def 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

752795
def 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+
795881
def 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

10511137
def _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"))

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ def _assert_otel_context_balanced():
6969
"""Assert each test leaves the OTel thread-local context as it found it."""
7070
before = otel_context.get_current()
7171
yield
72-
assert (
73-
otel_context.get_current() == before
74-
), "test leaked OTel context state: an attach() was not detached"
72+
assert otel_context.get_current() == before, (
73+
"test leaked OTel context state: an attach() was not detached"
74+
)
7575

7676

7777
def _provider() -> tuple[TracerProvider, InMemorySpanExporter]:
@@ -91,10 +91,12 @@ def on_start(self, span, parent_context=None) -> None:
9191
if isinstance(parent, ReadableSpan):
9292
_ = parent.attributes
9393
else:
94-
_ = parent.kind
95-
_ = parent.attributes.get("aws.trace.id")
96-
if parent.kind is SpanKind.SERVER:
97-
_ = parent.kind
94+
parent_kind = getattr(parent, "kind", None)
95+
parent_attributes = getattr(parent, "attributes", {})
96+
_ = parent_kind
97+
_ = parent_attributes.get("aws.trace.id")
98+
if getattr(parent, "kind", None) is SpanKind.SERVER:
99+
_ = getattr(parent, "kind", None)
98100

99101
def on_end(self, span: ReadableSpan) -> None:
100102
return

0 commit comments

Comments
 (0)