Skip to content

Commit 2e84c0d

Browse files
committed
fix(plugin): split exception groups, drain before the build
A BaseExceptionGroup is neither case the containment boundary tested. A group carrying a KeyboardInterrupt is not an instance of one, so the tuple handler naming the three did not match it and the broad handler contained the interrupt inside it. Plugin code produces such a group without asking: an asyncio.TaskGroup whose task is interrupted raises one. Both boundaries now partition what plugin code raised. The control leaves are returned to the caller and re-raised; what remains is logged as a contained plugin failure. A group of ordinary failures is therefore contained whole, and a mixed group is logged and then propagates only its control part. The end-hook dispatch catches BaseException rather than naming the three, because everything reaching it has already been partitioned. Insight registers its invocation-end drain on entering the hook rather than after building the record. _emit runs customer code, and a content transform raising a BaseException escapes _apply_data_content, which contains only Exception. That failure left the hook with the drain unrequested, so records this execution had already scheduled stayed in a buffering exporter when the environment froze.
1 parent 84159aa commit 2e84c0d

4 files changed

Lines changed: 200 additions & 32 deletions

File tree

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,16 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
389389
return
390390
emit_mode = self._shared._emit_mode
391391
with self._hook_frame():
392+
# The drain is registered before anything can fail, not after the
393+
# record is built. `_emit` runs customer code -- the content and
394+
# result transforms, and `__del__` on an object a displaced record
395+
# carried -- and a failure there leaves this hook by way of the SDK's
396+
# containment. Registering afterwards meant such a failure skipped
397+
# the drain, so records this execution had already scheduled stayed
398+
# in a buffering exporter when the environment froze. Registering
399+
# here costs nothing when the hook succeeds: the frame runs the drain
400+
# once, on the way out, either way.
401+
self._request_drain()
392402
with self._lock:
393403
if not self._closed:
394404
# Close the gate before emitting so a concurrent late hook for
@@ -447,12 +457,13 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
447457
# after this call is done, without waiting on records scheduled after the
448458
# call by other executions.
449459
#
450-
# Asked for rather than performed here, so it runs when the outermost
451-
# hook frame on this thread unwinds and every `_lock` hold is released.
452-
# See `_hook_frame`: an invocation end that customer code re-entered
453-
# from inside another hook's build would otherwise wait for the export
454-
# worker while holding the lock that worker may need.
455-
self._request_drain()
460+
# Asked for rather than performed, so it runs when the outermost hook
461+
# frame on this thread unwinds and every `_lock` hold is released. See
462+
# `_hook_frame`: an invocation end that customer code re-entered from
463+
# inside another hook's build would otherwise wait for the export
464+
# worker while holding the lock that worker may need. The request
465+
# itself is made at the top of this hook, so a failure in the build
466+
# below cannot skip it.
456467

457468
# -- emission -------------------------------------------------------------
458469

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

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@
1919

2020
from __future__ import annotations
2121

22+
import asyncio
2223
import datetime
2324
import itertools
2425
import threading
2526
import time
2627
from typing import Any
2728

29+
import pytest
30+
2831
from aws_durable_execution_sdk_python.lambda_service import (
2932
ErrorObject,
3033
OperationStatus,
@@ -680,6 +683,7 @@ class ConcurrentCaptureExporter:
680683

681684
def __init__(self) -> None:
682685
self.records: list[dict[str, Any]] = []
686+
self.flushes = 0
683687
self._lock = threading.Lock()
684688

685689
def render(self, record: dict[str, Any]) -> Any:
@@ -690,7 +694,8 @@ def export(self, record: dict[str, Any]) -> None:
690694
self.records.append(record)
691695

692696
def flush(self) -> None:
693-
pass
697+
with self._lock:
698+
self.flushes += 1
694699

695700
def snapshot(self) -> list[dict[str, Any]]:
696701
with self._lock:
@@ -1188,6 +1193,53 @@ def hook() -> None:
11881193
)
11891194

11901195

1196+
def test_an_end_transform_that_fails_still_drains_what_was_scheduled():
1197+
# `_emit` runs customer code between the snapshot and the hand-off: the content
1198+
# transforms, a result override, and __del__ on an object a displaced record
1199+
# carried. A failure there leaves the hook through the SDK's containment, and
1200+
# the drain used to be requested after the build, so that failure skipped it --
1201+
# leaving records this execution had already scheduled in a buffering exporter
1202+
# when the environment froze. The drain is now requested on entry.
1203+
exporter = ConcurrentCaptureExporter()
1204+
calls: list[str] = []
1205+
1206+
def failing_on_the_second_call(value: Any) -> Any:
1207+
calls.append("input")
1208+
if len(calls) > 1:
1209+
# CancelledError rather than an ordinary exception on purpose:
1210+
# _apply_data_content contains Exception so that a failing redactor
1211+
# cannot leak the raw value, and a BaseException is what escapes the
1212+
# build and leaves the hook.
1213+
raise asyncio.CancelledError("transform cancelled")
1214+
return value
1215+
1216+
factory = workflow_insight(
1217+
WorkflowInsightConfig(
1218+
exporters=[exporter],
1219+
emit_mode="on-change",
1220+
content=ContentConfig(input=failing_on_the_second_call),
1221+
)
1222+
)
1223+
start = _start(operations={})
1224+
plugin = factory.create_plugin(start)
1225+
1226+
# The first emit succeeds and schedules a RUNNING record.
1227+
plugin.on_invocation_start(start)
1228+
1229+
# The terminal emit fails inside the transform, so this hook raises. The SDK
1230+
# contains that; here it is raised directly, which is the same code path.
1231+
with pytest.raises(asyncio.CancelledError):
1232+
plugin.on_invocation_end(_end(operations=_ops(_step("s"))))
1233+
1234+
statuses = [record["status"] for record in exporter.snapshot()]
1235+
assert statuses == ["RUNNING"], (
1236+
"the record scheduled before the failing transform must have been drained "
1237+
f"to the exporters, not left pending: {statuses}"
1238+
)
1239+
assert exporter.flushes >= 1, "the drain must have flushed"
1240+
assert _wait_until(lambda: not factory._scheduler._worker_alive())
1241+
1242+
11911243
def _free_for_another_thread(lock: Any) -> bool:
11921244
"""Report whether a lock is unheld, as seen from a thread that never took it.
11931245

packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/plugin.py

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,36 @@ def _factory_name(factory: object) -> str:
545545
_PLUGIN_THREAD_CONTROL_EXCEPTIONS = (KeyboardInterrupt, SystemExit, GeneratorExit)
546546

547547

548+
def _contain_plugin_failure(
549+
error: BaseException, message: str, *message_args: object
550+
) -> BaseException | None:
551+
"""Log what plugin code raised, and return the part that must not be contained.
552+
553+
Returns ``None`` when the whole failure was contained, or the part that
554+
instructs the calling thread to stop, which the caller re-raises.
555+
556+
A :class:`BaseExceptionGroup` is split rather than tested, because it is
557+
neither of the two cases a plain ``isinstance`` chain covers: a group carrying
558+
a :class:`KeyboardInterrupt` is not an instance of one, so a tuple handler
559+
naming the three does not match it and a broad handler would contain the
560+
interrupt inside it. Plugin code produces such a group without asking for it
561+
-- an ``asyncio.TaskGroup`` whose task is interrupted raises one -- so the
562+
group is partitioned: the control leaves are returned to be re-raised, and
563+
what remains is logged like any other contained plugin failure.
564+
"""
565+
control: BaseException | None
566+
contained: BaseException | None
567+
if isinstance(error, BaseExceptionGroup):
568+
control, contained = error.split(_PLUGIN_THREAD_CONTROL_EXCEPTIONS)
569+
elif isinstance(error, _PLUGIN_THREAD_CONTROL_EXCEPTIONS):
570+
control, contained = error, None
571+
else:
572+
control, contained = None, error
573+
if contained is not None:
574+
logger.error(message, *message_args, exc_info=contained)
575+
return control
576+
577+
548578
class PluginExecutor:
549579
"""One invocation's plugin instances, metadata and dispatch.
550580
@@ -632,24 +662,24 @@ def _create_plugins(self, info: InvocationStartInfo) -> None:
632662
while the handler is being initialized, so the silent case is not
633663
reachable through ``durable_execution()``.
634664
635-
Containment covers every ``BaseException`` except the three that instruct
665+
Containment covers every ``BaseException`` except the parts that instruct
636666
the calling thread to stop; see
637-
:data:`_PLUGIN_THREAD_CONTROL_EXCEPTIONS`. Narrowing it to ``Exception``
638-
left the contract conditional on a factory never raising outside that
639-
hierarchy, and a factory that awaits a cancelled task raises
667+
:data:`_PLUGIN_THREAD_CONTROL_EXCEPTIONS` and
668+
:func:`_contain_plugin_failure`. Narrowing it to ``Exception`` left the
669+
contract conditional on a factory never raising outside that hierarchy,
670+
and a factory that awaits a cancelled task raises
640671
``asyncio.CancelledError``, which is outside it.
641672
"""
642673
plugins: list[DurableInstrumentationPlugin] = []
643674
for factory in self._plugin_factories:
644675
try:
645676
plugin = factory.create_plugin(info)
646-
except _PLUGIN_THREAD_CONTROL_EXCEPTIONS:
647-
raise
648-
except BaseException: # noqa: BLE001 - a factory must not fail the execution
649-
# log and ignore the exception
650-
logger.exception(
651-
"Plugin factory %s exception ignored", _factory_name(factory)
677+
except BaseException as error: # noqa: BLE001 - a factory must not fail the execution
678+
control = _contain_plugin_failure(
679+
error, "Plugin factory %s exception ignored", _factory_name(factory)
652680
)
681+
if control is not None:
682+
raise control from None
653683
continue
654684
if plugin is None:
655685
logger.error(
@@ -678,11 +708,11 @@ def _create_plugins(self, info: InvocationStartInfo) -> None:
678708
def _dispatch_plugin(plugin: DurableInstrumentationPlugin, info) -> None:
679709
"""Invoke the appropriate plugin callback. Runs inside the thread pool.
680710
681-
Contains every ``BaseException`` except the three that instruct the
682-
calling thread to stop, the same rule the factory boundary uses. The
683-
thread here is the executor's own single worker, which nothing outside
684-
this class cancels or interrupts, so an exception outside the ``Exception``
685-
hierarchy arriving here was raised by the plugin.
711+
Contains every ``BaseException`` except the parts that instruct the calling
712+
thread to stop, the same rule the factory boundary uses. The thread here
713+
is the executor's own single worker, which nothing outside this class
714+
cancels or interrupts, so an exception outside the ``Exception`` hierarchy
715+
arriving here was raised by the plugin.
686716
"""
687717
try:
688718
match info:
@@ -702,11 +732,12 @@ def _dispatch_plugin(plugin: DurableInstrumentationPlugin, info) -> None:
702732
plugin.on_user_function_end(info)
703733
case _:
704734
raise RuntimeError(f"Unknown info type: {type(info)}")
705-
except _PLUGIN_THREAD_CONTROL_EXCEPTIONS:
706-
raise
707-
except BaseException: # noqa: BLE001 - a hook must not fail the execution
708-
# log and ignore the exception
709-
logger.exception("Plugin %s exception ignored", plugin.__class__.__name__)
735+
except BaseException as error: # noqa: BLE001 - a hook must not fail the execution
736+
control = _contain_plugin_failure(
737+
error, "Plugin %s exception ignored", plugin.__class__.__name__
738+
)
739+
if control is not None:
740+
raise control from None
710741

711742
def execute_plugins(self, info, sync):
712743
"""Dispatch one hook to this invocation's plugins.
@@ -725,14 +756,20 @@ def execute_plugins(self, info, sync):
725756
allocated what its end hook releases.
726757
727758
The invocation-end hook is the one hook that finishes dispatching even
728-
when a plugin raises one of those three. Every plugin it reaches has
729-
already started, so cutting the loop short costs a plugin its only chance
730-
to finish: Insight would not drain, and OTel would leave spans unended.
731-
The first such exception is held and re-raised once every plugin has been
759+
when a plugin raises one of those. Every plugin it reaches has already
760+
started, so cutting the loop short costs a plugin its only chance to
761+
finish: Insight would not drain, and OTel would leave spans unended. The
762+
first such exception is held and re-raised once every plugin has been
732763
called, so the thread still stops and nothing is swallowed. No other hook
733764
defers: stopping a start-hook loop early leaves later plugins with nothing
734765
to clean up, because the pairing rule above then withholds their end hook
735766
too.
767+
768+
Anything :meth:`_dispatch_plugin` raises is already a thread-control
769+
failure -- it contains everything else -- so the end path catches
770+
``BaseException`` rather than naming the three again. Naming them would
771+
miss a :class:`BaseExceptionGroup` carrying one, which is what
772+
:func:`_contain_plugin_failure` hands back.
736773
"""
737774
if not self._executor:
738775
return
@@ -752,7 +789,7 @@ def execute_plugins(self, info, sync):
752789
continue
753790
try:
754791
self._dispatch_plugin(plugin, info)
755-
except _PLUGIN_THREAD_CONTROL_EXCEPTIONS as control:
792+
except BaseException as control: # noqa: BLE001 - held and re-raised below
756793
if deferred_control is None:
757794
deferred_control = control
758795
if deferred_control is not None:

packages/aws-durable-execution-sdk-python/tests/plugin_test.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,64 @@ def create_plugin(self, info: InvocationStartInfo) -> _TrackingPlugin:
647647
self.assertEqual(built[0].calls, ["invocation_start:req-1"])
648648
self.assertEqual(built[1].calls, ["invocation_start:req-2"])
649649

650+
def test_a_factory_raising_a_group_with_a_control_exception_propagates(self):
651+
"""A group carrying a control exception is not contained.
652+
653+
``BaseExceptionGroup`` is neither of the two cases an ``isinstance`` chain
654+
covers: a group holding a ``KeyboardInterrupt`` is not an instance of one,
655+
so naming the three in a handler does not match it and a broad handler
656+
would swallow the interrupt inside it. Plugin code produces such a group
657+
without asking for one -- an ``asyncio.TaskGroup`` whose task is
658+
interrupted raises it.
659+
"""
660+
group = BaseExceptionGroup(
661+
"plugin group", [ValueError("contained"), KeyboardInterrupt()]
662+
)
663+
executor = PluginExecutor(plugins=[_GroupRaisingFactory(group)])
664+
665+
with (
666+
self.assertLogs(
667+
"aws_durable_execution_sdk_python.plugin", level=logging.ERROR
668+
) as logs,
669+
executor.run(),
670+
self.assertRaises(BaseExceptionGroup) as raised,
671+
):
672+
executor.on_invocation_start(
673+
execution_arn="arn:exec",
674+
lambda_context=LAMBDA_CTX,
675+
execution_start_time=START_TS,
676+
is_first_invocation=True,
677+
)
678+
679+
# Only the control leaf propagates; the rest was logged as a contained
680+
# plugin failure.
681+
self.assertEqual(
682+
[type(leaf) for leaf in raised.exception.exceptions], [KeyboardInterrupt]
683+
)
684+
self.assertIn("contained", "\n".join(logs.output))
685+
686+
def test_a_factory_raising_a_group_without_a_control_exception_is_contained(self):
687+
"""A group of ordinary failures is contained like any other."""
688+
surviving = _TrackingPlugin()
689+
group = BaseExceptionGroup("plugin group", [ValueError("boom")])
690+
executor = PluginExecutor(
691+
plugins=[_GroupRaisingFactory(group), plugin_factory(surviving)],
692+
)
693+
694+
with self.assertLogs(
695+
"aws_durable_execution_sdk_python.plugin", level=logging.ERROR
696+
) as logs:
697+
with executor.run():
698+
executor.on_invocation_start(
699+
execution_arn="arn:exec",
700+
lambda_context=LAMBDA_CTX,
701+
execution_start_time=START_TS,
702+
is_first_invocation=True,
703+
)
704+
705+
self.assertIn("boom", "\n".join(logs.output))
706+
self.assertEqual(surviving.calls, ["invocation_start:req-1"])
707+
650708
def test_an_end_hook_that_stops_the_thread_still_finishes_the_dispatch(self):
651709
"""Every started plugin receives the end hook, then the thread stops.
652710
@@ -2380,6 +2438,16 @@ def create_plugin(self, info: InvocationStartInfo) -> DurableInstrumentationPlug
23802438
raise self._control
23812439

23822440

2441+
class _GroupRaisingFactory:
2442+
"""Factory that raises a ``BaseExceptionGroup``, as an ``asyncio.TaskGroup`` does."""
2443+
2444+
def __init__(self, group: BaseExceptionGroup) -> None:
2445+
self._group = group
2446+
2447+
def create_plugin(self, info: InvocationStartInfo) -> DurableInstrumentationPlugin:
2448+
raise self._group
2449+
2450+
23832451
class _CancellingPlugin(DurableInstrumentationPlugin):
23842452
"""Plugin whose hook raises outside the ``Exception`` hierarchy."""
23852453

0 commit comments

Comments
 (0)