Skip to content

Commit 7bfddca

Browse files
committed
fix(plugin): give each invocation its own plugin session and log correlation
Review findings on the per-invocation plugin contract. The first one meant the contract change did not actually deliver what it promised under concurrency. `durable_execution` built one `PluginExecutor` at decoration time and every invocation shared it, while the executor stored that invocation's plugin instances, invocation metadata and operations provider on itself. So the SDK created one plugin per invocation and then parked it in a shared mutable slot. Measured with two invocations held inside their user function at once, invocation A's operation hook was delivered to B's plugin and A's plugin never received its own operation or end hook; in a second scenario A's scope exit cleared the shared state while B was live and B lost `on_invocation_end` entirely. A new handler-lifetime `PluginHost` now holds nothing but the resolved factory list and hands out a fresh `PluginExecutor` per invocation, which is the shape JS and Java already had. The executor is reachable only from the frames of the invocation that owns it, and a second `run()` on one executor raises, so reuse fails loudly instead of as silent crosstalk. Its thread pool is per invocation too: sharing one would let an ending invocation shut down a pool a concurrent invocation was still submitting to. The OTel log filter had the same class of bug. It held one mutable plugin reference, so with two invocations open the one that started last won and every other invocation's records carried its trace. Reading the active span from the OTel context, as the Java plugin does, is not sufficient here: the invocation span is never attached to the context, and the SDK runs the handler body on a pool thread that does not inherit the context of the thread the plugin bound on. The filter is now stateless and resolves per record, preferring the invocation that claimed the emitting thread through a `ContextVar` and falling back to the single open invocation when exactly one is open. With several open and an unclaimed thread it leaves the record unstamped, because an unattributed record is a smaller defect than one attributed to another customer's execution. Installation is serialized so concurrent first-time callers cannot stack filters. Third, the Insight scheduler used `0` for both "no flush in flight" and "a flush in flight covering zero exports", which are different facts. An invocation that emitted no record could not tell that its flush was already running, so it requested a second one that then ran after both invocations had returned — customer exporter code past the invocation boundary, which the flush contract forbids. Absence is now `None` and coverage an integer.
1 parent f011990 commit 7bfddca

13 files changed

Lines changed: 842 additions & 152 deletions

File tree

packages/aws-durable-execution-sdk-python-insight/src/aws_durable_execution_sdk_python_insight/_export_scheduler.py

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,17 @@ def __init__(self, exporters: list[InsightExporter]) -> None:
101101
self._flushes_completed = 0
102102
self._flush_requested = False
103103
# Export counter coverage of the flush the worker is running right now, or
104-
# 0 when no flush is in flight. Published when the worker commits to a
105-
# flush, so a waiter woken while that flush runs -- before its coverage
106-
# reaches _flushed_through -- can tell it is already covered instead of
107-
# requesting a second flush that would run after its drain returned.
108-
self._flush_in_flight = 0
104+
# None when no flush is in flight. Presence and coverage are separate
105+
# facts: a flush that covers zero exports is an ordinary flush -- it is
106+
# what an invocation that emitted no record asks for -- and a single
107+
# integer cannot say both "no flush is running" and "a flush covering
108+
# nothing is running". Encoding the first as 0 made those two states
109+
# identical, so a waiter needing zero coverage could not tell that its
110+
# flush was already running and requested a second one that then ran
111+
# after its invocation had returned. Published when the worker commits to
112+
# a flush, so a waiter woken while that flush runs -- before its coverage
113+
# reaches _flushed_through -- can tell it is already covered.
114+
self._flush_in_flight: int | None = None
109115
# Value of the global schedule counter (_seq) when a flush was requested.
110116
# The worker defers the flush until no record scheduled at or before that
111117
# point is still pending. That is deliberately wider than the requester's
@@ -202,15 +208,15 @@ def drain(self, execution: _ExportState) -> None:
202208
# made has already been consumed -- runs an extra flush after
203209
# this drain, and the invocation, returned.
204210
#
205-
# `_flush_in_flight` uses 0 as its "no flush is running"
206-
# sentinel, so the naive `self._flush_in_flight >= need`
207-
# reads as "already covered" when `need` is 0 -- precisely
208-
# when nothing is running at all. `need` is 0 for a drain
209-
# whose invocation emitted no record, so that form would let
210-
# such a drain skip its request and park until some other
211-
# execution happened to flush. Require a marker that is
212-
# actually set AND that reaches `need`.
213-
covered = 0 < self._flush_in_flight >= need
211+
# `_flush_in_flight` is None exactly while no flush is
212+
# running, so a flush that covers zero exports is still a
213+
# flush in flight. That case is the common one, not a corner:
214+
# `need` is 0 for a drain whose invocation emitted no record,
215+
# and the flush it asks for covers 0 exports when nothing has
216+
# ever been exported. Two such drains at once both see the
217+
# other's flush and neither asks for a second.
218+
in_flight = self._flush_in_flight
219+
covered = in_flight is not None and in_flight >= need
214220
if not self._flush_requested and not covered:
215221
self._flush_requested = True
216222
self._flush_barrier = max(self._flush_barrier, self._seq)
@@ -260,7 +266,7 @@ def _ensure_worker_locked(self) -> tuple[_Dropped | None, Exception | None]:
260266
self._pending = {}
261267
self._flush_requested = False
262268
self._flush_barrier = 0
263-
self._flush_in_flight = 0
269+
self._flush_in_flight = None
264270
# Release every waiter; the permanent disable latch means no record
265271
# will ever be exported.
266272
self._condition.notify_all()
@@ -304,7 +310,10 @@ def _run_loop(self) -> None:
304310
flush_covers = self._export_count
305311
# Publish what this flush will cover before releasing the
306312
# lock, so a waiter that wakes while it runs can see that
307-
# this flush releases it and skip asking for another.
313+
# this flush releases it and skip asking for another. A
314+
# coverage of 0 is published like any other: it means this
315+
# flush covers every export so far, of which there are
316+
# none, and it is still a flush in flight.
308317
self._flush_in_flight = flush_covers
309318
break
310319
if self._pending:
@@ -353,7 +362,7 @@ def _run_loop(self) -> None:
353362
# Retire the marker whatever happened: a stale one would park
354363
# every later waiter that trusted this flush to cover it. Only
355364
# a flush that ran to completion publishes its coverage.
356-
self._flush_in_flight = 0
365+
self._flush_in_flight = None
357366
if flushed:
358367
self._flushes_completed += 1
359368
if flush_covers > self._flushed_through:

packages/aws-durable-execution-sdk-python-insight/tests/test_export_scheduler.py

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,92 @@ def drain() -> None:
430430
assert capture.calls == [("flush", None)]
431431

432432

433+
class GatedFlushExporter(CaptureExporter):
434+
"""Holds each ``flush()`` open until the test releases it, and counts them."""
435+
436+
def __init__(self) -> None:
437+
super().__init__()
438+
self._lock = threading.Lock()
439+
self.flush_count = 0
440+
self.started: dict[int, threading.Event] = {}
441+
self.release: dict[int, threading.Event] = {}
442+
for index in (1, 2):
443+
self.started[index] = threading.Event()
444+
self.release[index] = threading.Event()
445+
446+
def flush(self) -> None:
447+
with self._lock:
448+
self.flush_count += 1
449+
index = self.flush_count
450+
if index in self.started:
451+
self.started[index].set()
452+
self.release[index].wait(10.0)
453+
super().flush()
454+
455+
456+
def test_concurrent_drains_with_nothing_to_export_share_one_flush() -> None:
457+
# Two invocations that emitted no record drain at the same time. Neither has
458+
# an export to be covered, so both need a flush that covers zero exports.
459+
# While `_flush_in_flight` used 0 for "no flush is running", the second drain
460+
# could not tell that the flush it needs was already running, and asked for
461+
# another. The completion of the first flush then released both drains, and
462+
# the second flush ran after both invocations had already returned -- customer
463+
# exporter code running past the invocation boundary, which is what the flush
464+
# contract forbids. One flush must serve both, and whichever drain a flush
465+
# belongs to must stay parked until that flush completes.
466+
exporter = GatedFlushExporter()
467+
scheduler = _ArnScheduler([exporter])
468+
returned: list[str] = []
469+
returned_lock = threading.Lock()
470+
471+
def drain(name: str, execution_arn: str) -> None:
472+
scheduler.drain(execution_arn)
473+
with returned_lock:
474+
returned.append(name)
475+
476+
threads = [
477+
threading.Thread(target=drain, args=("first", ARN_A), daemon=True),
478+
threading.Thread(target=drain, args=("second", ARN_B), daemon=True),
479+
]
480+
try:
481+
threads[0].start()
482+
assert exporter.started[1].wait(10.0), "the first drain never flushed"
483+
threads[1].start()
484+
485+
first = scheduler.executions[ARN_A]
486+
487+
def both_parked() -> bool:
488+
second = scheduler.executions.get(ARN_B)
489+
if second is None:
490+
return False
491+
with scheduler._condition:
492+
return first.waiters == 1 and second.waiters == 1
493+
494+
assert _wait_until(both_parked), "a drain raced past the flush it needs"
495+
with returned_lock:
496+
assert returned == [], "a drain returned before its flush completed"
497+
498+
exporter.release[1].set()
499+
for thread in threads:
500+
thread.join(10.0)
501+
assert not any(thread.is_alive() for thread in threads)
502+
with returned_lock:
503+
assert sorted(returned) == ["first", "second"]
504+
505+
# The redundant request, if one was made, was recorded before either
506+
# drain returned, so the worker starts that flush without further
507+
# prompting. Nothing arriving here is what proves no second flush was
508+
# requested.
509+
assert not exporter.started[2].wait(0.75), (
510+
"a second flush ran after both invocations had returned"
511+
)
512+
assert exporter.flush_count == 1
513+
finally:
514+
exporter.release[1].set()
515+
exporter.release[2].set()
516+
_wait_until(lambda: not scheduler._worker_alive())
517+
518+
433519
def test_drain_never_rides_on_a_flush_that_finished_before_it_started() -> None:
434520
# Export coverage alone would let the second drain return immediately: every
435521
# export is already covered by the first drain's flush. A drain must wait for
@@ -475,14 +561,15 @@ def fail_start(self) -> None: # noqa: ARG001
475561
for execution in scheduler.executions.values()
476562
)
477563
assert scheduler._flush_requested is False
478-
assert scheduler._flush_in_flight == 0
564+
assert scheduler._flush_in_flight is None
479565

480566

481567
def test_disabled_latch_clears_a_published_flush_in_flight_marker(monkeypatch) -> None:
482568
# `_flush_in_flight` is the coverage of the flush the worker is running right
483569
# now, published so a waiter woken during that flush can tell it is already
484-
# covered and skip requesting another. 0 means "no flush is running", so the
485-
# marker is a claim that a flush is in flight and will complete.
570+
# covered and skip requesting another. None means "no flush is running", so
571+
# any integer -- 0 included -- is a claim that a flush is in flight and will
572+
# complete.
486573
#
487574
# The _disabled latch makes that claim permanently false: no worker exists and
488575
# none will ever be started again, so the published flush can never complete.
@@ -503,7 +590,7 @@ def fail_start(self) -> None: # noqa: ARG001
503590

504591
with scheduler._condition:
505592
assert scheduler._disabled
506-
assert scheduler._flush_in_flight == 0, (
593+
assert scheduler._flush_in_flight is None, (
507594
"the _disabled latch left a flush-in-flight marker behind for a flush "
508595
"that can never run"
509596
)

packages/aws-durable-execution-sdk-python-otel/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,10 @@ Structured trace context and sampling decision returned by context extractors.
430430

431431
The logging filter (and its installer) used to stamp trace context onto log
432432
records. Installed automatically when `enrich_logger=True`; exported for manual
433-
setups.
433+
setups, where `install_log_filter(target_logger)` attaches it to a logger of your
434+
choice. The filter carries no invocation identity of its own: it resolves the
435+
invocation a record belongs to at emit time, so one filter serves every
436+
invocation the environment runs, including concurrent ones.
434437

435438
## Requirements
436439

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,11 @@
9090
canonical_trace_id,
9191
)
9292
from aws_durable_execution_sdk_python_otel.otel_plugin_config import OtelPluginConfig
93-
from aws_durable_execution_sdk_python_otel.log_filter import install_log_filter
93+
from aws_durable_execution_sdk_python_otel.log_filter import (
94+
bind_invocation,
95+
install_log_filter,
96+
unbind_invocation,
97+
)
9498
from aws_durable_execution_sdk_python_otel.provider import create_tracer_provider
9599

96100

@@ -177,9 +181,12 @@ def __init__(self, config: OtelPluginConfig | None = None) -> None:
177181
self._tracing_enabled = False
178182

179183
if self._config.enrich_logger:
180-
# Install (or, on a warm environment, rebind) the root-logger filter
181-
# so every log record is stamped with this invocation's span context.
182-
install_log_filter(self)
184+
# Install the root-logger filter so every log record is stamped with
185+
# the active span context. On a warm environment the handler already
186+
# carries the filter a previous invocation installed and it is reused
187+
# as is: the filter holds no invocation identity, and this plugin
188+
# claims the invocation in on_invocation_start instead.
189+
install_log_filter()
183190

184191
def _bind_sdk_tracer(self) -> bool:
185192
"""Bind to an SDK tracer, retrying a deferred global provider."""
@@ -426,6 +433,12 @@ def _with_sampling(self, parent_context: Context) -> Context:
426433
# ------------------------------------------------------------------
427434
def on_invocation_start(self, info: InvocationStartInfo) -> None:
428435
logger.debug("Durable invocation started: %s", info)
436+
# Claim log correlation for this invocation before anything can fail
437+
# below: the claim is what keeps a concurrent invocation's records off
438+
# this invocation's trace, and it is registered even when tracing turns
439+
# out to be disabled so that the filter can still tell how many
440+
# invocations are open.
441+
bind_invocation(self)
429442
if info.execution_start_time is None:
430443
logger.warning(
431444
"ExecutionOtelPlugin requires InvocationStartInfo.execution_start_time "
@@ -660,12 +673,17 @@ def _release_invocation_scope(self) -> None:
660673
plugin, so any scope this plugin attached and did not release must be
661674
detached here or it would stay current on a warm environment's thread
662675
after the invocation returns.
676+
* The log filter's record of open invocations is process-global, so this
677+
invocation must be removed from it. Until it is, a record emitted on an
678+
unclaimed thread could still be correlated to this finished
679+
invocation's spans.
663680
* ``_tracing_enabled`` is cleared so a hook that arrives after the
664681
invocation end -- one dispatched off the checkpointing path, for
665682
instance -- cannot start a span after the invocation span was ended and
666683
the provider flushed.
667684
"""
668685
self._detach_remaining_contexts()
686+
unbind_invocation(self)
669687
self._tracing_enabled = False
670688

671689
# ------------------------------------------------------------------
@@ -675,6 +693,14 @@ def on_operation_start(self, info: OperationStartInfo) -> None:
675693
logger.debug("Durable operation started: %s", info)
676694
if not self._tracing_enabled:
677695
return
696+
# Runs on the thread that drives the durable operation, which is the
697+
# thread running the handler body and not the thread the
698+
# invocation-start hook claimed. Claim it too, so records emitted from
699+
# top-level handler code are correlated to this invocation even while
700+
# another invocation is open in the same process. Claimed after the
701+
# tracing-enabled gate, so a hook arriving after the invocation ended
702+
# cannot re-register a finished invocation.
703+
bind_invocation(self)
678704
if info.operation_type is OperationType.CONTEXT:
679705
with self._lock:
680706
self._checkpointed_context_ids.add(info.operation_id)
@@ -807,6 +833,9 @@ def on_user_function_start(self, info: UserFunctionStartInfo) -> None:
807833
logger.debug("Durable user function started: %s", info)
808834
if not self._tracing_enabled:
809835
return
836+
# Runs on the thread executing user code -- a parallel branch runs on its
837+
# own thread -- so claim that thread for this invocation as well.
838+
bind_invocation(self)
810839
if info.operation_type not in (OperationType.CONTEXT, OperationType.STEP):
811840
raise RuntimeError(
812841
"on_user_function_start only supports CONTEXT and STEP operations"

0 commit comments

Comments
 (0)