Skip to content

Commit 9b88343

Browse files
committed
fix(plugin): fire the end hook on every exit
The invocation wrapper fired on_invocation_end on the success path and on except Exception, so an exit by BaseException skipped it. A handler that surfaces an asyncio.CancelledError -- user code awaiting a cancelled task -- leaves that way, and so does a KeyboardInterrupt or SystemExit. The invocation then ended without the one hook a plugin has to finish on: Insight never drained, so the records it held for that execution were dropped, and OTel never ended the spans it had opened. Teardown still ran, so nothing failed visibly. Every exit now fires the hook and re-raises the exception unchanged. Plugin-code containment widens with it. The factory and hook boundaries caught Exception, so a factory or hook raising CancelledError failed an execution it was only observing and stopped the remaining plugins from running. Both now contain every BaseException except KeyboardInterrupt, SystemExit and GeneratorExit, which are instructions to the calling thread rather than reports of a plugin defect. CancelledError is not among them: nothing cancels the invocation thread or the single-worker plugin pool, so one arriving from plugin code came from the plugin's own asyncio use. ErrorObject.from_exception is annotated BaseException, which is what its own helper already accepted.
1 parent 5d874f1 commit 9b88343

3 files changed

Lines changed: 179 additions & 5 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ def from_dict(cls, data: MutableMapping[str, Any]) -> ErrorObject:
235235
)
236236

237237
@classmethod
238-
def from_exception(cls, exception: Exception) -> ErrorObject:
238+
def from_exception(cls, exception: BaseException) -> ErrorObject:
239239
# SerDesError and subclasses pin to the base discriminator so replay
240240
# always reconstructs them as SerDesError.
241241
if isinstance(exception, SerDesError):

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

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,21 @@ def _factory_name(factory: object) -> str:
530530
return getattr(factory, "__qualname__", None) or type(factory).__name__
531531

532532

533+
# Raised out of plugin code, these three are not reports of a plugin defect but
534+
# instructions to the thread that is running: stop. Containing one would drop the
535+
# instruction and return a thread that was told to unwind to the work after the
536+
# plugin. They are re-raised; every other BaseException is contained.
537+
#
538+
# asyncio.CancelledError is deliberately NOT here. It derives from BaseException
539+
# and it does mean "stop" for the task that was cancelled, but the task here is
540+
# the SDK's, not the plugin's: nothing cancels the invocation thread or the
541+
# single-worker plugin pool. A CancelledError arriving from plugin code therefore
542+
# came from the plugin's own asyncio use -- an awaited task it let be cancelled --
543+
# which is a plugin defect and belongs on the contained side, or the plugin's
544+
# failure would fail an execution it was only observing.
545+
_PLUGIN_THREAD_CONTROL_EXCEPTIONS = (KeyboardInterrupt, SystemExit, GeneratorExit)
546+
547+
533548
class PluginExecutor:
534549
"""One invocation's plugin instances, metadata and dispatch.
535550
@@ -611,12 +626,21 @@ def _create_plugins(self, info: InvocationStartInfo) -> None:
611626
:func:`plugin_discovery.load_configured_plugins` rejects such an entry
612627
while the handler is being initialized, so the silent case is not
613628
reachable through ``durable_execution()``.
629+
630+
Containment covers every ``BaseException`` except the three that instruct
631+
the calling thread to stop; see
632+
:data:`_PLUGIN_THREAD_CONTROL_EXCEPTIONS`. Narrowing it to ``Exception``
633+
left the contract conditional on a factory never raising outside that
634+
hierarchy, and a factory that awaits a cancelled task raises
635+
``asyncio.CancelledError``, which is outside it.
614636
"""
615637
plugins: list[DurableInstrumentationPlugin] = []
616638
for factory in self._plugin_factories:
617639
try:
618640
plugin = factory.create_plugin(info)
619-
except Exception:
641+
except _PLUGIN_THREAD_CONTROL_EXCEPTIONS:
642+
raise
643+
except BaseException: # noqa: BLE001 - a factory must not fail the execution
620644
# log and ignore the exception
621645
logger.exception(
622646
"Plugin factory %s exception ignored", _factory_name(factory)
@@ -647,7 +671,14 @@ def _create_plugins(self, info: InvocationStartInfo) -> None:
647671

648672
@staticmethod
649673
def _dispatch_plugin(plugin: DurableInstrumentationPlugin, info) -> None:
650-
"""Invoke the appropriate plugin callback. Runs inside the thread pool."""
674+
"""Invoke the appropriate plugin callback. Runs inside the thread pool.
675+
676+
Contains every ``BaseException`` except the three that instruct the
677+
calling thread to stop, the same rule the factory boundary uses. The
678+
thread here is the executor's own single worker, which nothing outside
679+
this class cancels or interrupts, so an exception outside the ``Exception``
680+
hierarchy arriving here was raised by the plugin.
681+
"""
651682
try:
652683
match info:
653684
case InvocationStartInfo():
@@ -666,7 +697,9 @@ def _dispatch_plugin(plugin: DurableInstrumentationPlugin, info) -> None:
666697
plugin.on_user_function_end(info)
667698
case _:
668699
raise RuntimeError(f"Unknown info type: {type(info)}")
669-
except Exception:
700+
except _PLUGIN_THREAD_CONTROL_EXCEPTIONS:
701+
raise
702+
except BaseException: # noqa: BLE001 - a hook must not fail the execution
670703
# log and ignore the exception
671704
logger.exception("Plugin %s exception ignored", plugin.__class__.__name__)
672705

@@ -1068,7 +1101,25 @@ def wrapper(event: Any, context: LambdaContext):
10681101
output=DurableExecutionInvocationOutput.from_dict(output),
10691102
)
10701103
return output
1071-
except Exception as e:
1104+
except BaseException as e:
1105+
# Every exit fires the end hook, not only the ones that
1106+
# derive from Exception. A handler that surfaces an
1107+
# asyncio.CancelledError -- user code that awaited a
1108+
# cancelled task, most simply -- leaves the invocation by
1109+
# a BaseException, and an invocation that ends without
1110+
# its end hook costs the plugins the only point at which
1111+
# they can finish: Insight never drains, so the records it
1112+
# holds for this execution are dropped, and OTel never
1113+
# ends the spans it opened, so they are never exported.
1114+
# The teardown below still runs either way, which is why
1115+
# the gap was silent rather than a leak.
1116+
#
1117+
# KeyboardInterrupt and SystemExit reach here too, and
1118+
# they also fire the hook. The hook is what a plugin needs
1119+
# to flush, and a process being torn down is when flushing
1120+
# matters; the cost is the same bounded work any
1121+
# invocation end does. The exception itself is re-raised
1122+
# unchanged, so what the caller sees is untouched.
10721123
plugin_executor.on_invocation_end(
10731124
output=DurableExecutionInvocationOutput.create_retry(
10741125
ErrorObject.from_exception(e)

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

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import contextlib
23
import datetime
34
import logging
@@ -646,6 +647,41 @@ def create_plugin(self, info: InvocationStartInfo) -> _TrackingPlugin:
646647
self.assertEqual(built[0].calls, ["invocation_start:req-1"])
647648
self.assertEqual(built[1].calls, ["invocation_start:req-2"])
648649

650+
def test_a_body_raising_cancellation_still_fires_the_end_hook(self):
651+
"""Every exit fires the end hook, not only the ones deriving from Exception.
652+
653+
A handler that surfaces an ``asyncio.CancelledError`` left the invocation
654+
without its end hook, so Insight never drained the records it held for the
655+
execution and OTel never ended the spans it had opened. Nothing failed
656+
visibly, which is why the gap was silent.
657+
"""
658+
for raised in (
659+
asyncio.CancelledError("cancelled"),
660+
KeyboardInterrupt(),
661+
SystemExit(),
662+
):
663+
with self.subTest(raised=type(raised).__name__):
664+
plugin = _TrackingPlugin()
665+
host = PluginHost(plugins=[plugin_factory(plugin)])
666+
667+
@host.handle_durable_output
668+
def handler(event, context, plugin_executor):
669+
plugin_executor.on_invocation_start(
670+
execution_arn="arn:exec",
671+
lambda_context=LAMBDA_CTX,
672+
execution_start_time=START_TS,
673+
is_first_invocation=True,
674+
)
675+
raise raised
676+
677+
with self.assertRaises(type(raised)):
678+
handler({}, LAMBDA_CTX)
679+
680+
self.assertEqual(
681+
plugin.calls,
682+
["invocation_start:req-1", "invocation_end:req-1"],
683+
)
684+
649685
def test_host_hands_out_a_new_executor_per_invocation(self):
650686
"""The host itself holds no per-invocation state to overwrite."""
651687
host = PluginHost(plugins=[plugin_factory(_TrackingPlugin())])
@@ -891,6 +927,69 @@ def test_every_failing_factory_leaves_the_executor_usable(self):
891927
),
892928
)
893929

930+
def test_a_factory_raising_cancellation_is_contained(self):
931+
"""Containment is not limited to ``Exception``.
932+
933+
``asyncio.CancelledError`` derives from ``BaseException``, so a factory
934+
that awaits a cancelled task used to abort the invocation it was only
935+
instrumenting and stop the remaining factories from running.
936+
"""
937+
surviving = _TrackingPlugin()
938+
939+
executor = PluginExecutor(
940+
plugins=[_CancellingFactory(), plugin_factory(surviving)],
941+
)
942+
943+
with self.assertLogs(
944+
"aws_durable_execution_sdk_python.plugin", level=logging.ERROR
945+
) as logs:
946+
with executor.run():
947+
executor.on_invocation_start(
948+
execution_arn="arn:exec",
949+
lambda_context=LAMBDA_CTX,
950+
execution_start_time=START_TS,
951+
is_first_invocation=True,
952+
)
953+
954+
self.assertIn("factory cancelled", "\n".join(logs.output))
955+
self.assertEqual(surviving.calls, ["invocation_start:req-1"])
956+
957+
def test_a_factory_raising_thread_control_still_propagates(self):
958+
"""The three that tell the thread to stop are not contained.
959+
960+
Containing one would drop the instruction and hand the thread back to the
961+
work that follows the plugin.
962+
"""
963+
for control in (KeyboardInterrupt, SystemExit, GeneratorExit):
964+
with self.subTest(control=control.__name__):
965+
executor = PluginExecutor(
966+
plugins=[_ThreadControlFactory(control)],
967+
)
968+
969+
with executor.run(), self.assertRaises(control):
970+
executor.on_invocation_start(
971+
execution_arn="arn:exec",
972+
lambda_context=LAMBDA_CTX,
973+
execution_start_time=START_TS,
974+
is_first_invocation=True,
975+
)
976+
977+
def test_a_hook_raising_cancellation_is_contained(self):
978+
"""The hook boundary uses the same rule as the factory boundary."""
979+
tracking = _TrackingPlugin()
980+
executor = PluginExecutor(
981+
plugins=[plugin_factory(_CancellingPlugin()), plugin_factory(tracking)]
982+
)
983+
984+
with self.assertLogs(
985+
"aws_durable_execution_sdk_python.plugin", level=logging.ERROR
986+
) as logs:
987+
with _invocation(executor, tracking):
988+
executor.execute_plugins(OPERATION_START_INFO, sync=True)
989+
990+
self.assertIn("hook cancelled", "\n".join(logs.output))
991+
self.assertIn("operation_start:op-2", tracking.calls)
992+
894993

895994
class TestPluginExecutor(unittest.TestCase):
896995
def test_no_thread_pool_when_plugins_is_none(self):
@@ -2171,6 +2270,30 @@ def create_plugin(self, info: InvocationStartInfo) -> DurableInstrumentationPlug
21712270
raise RuntimeError("factory boom")
21722271

21732272

2273+
class _CancellingFactory:
2274+
"""Factory whose ``create_plugin`` raises outside the ``Exception`` hierarchy."""
2275+
2276+
def create_plugin(self, info: InvocationStartInfo) -> DurableInstrumentationPlugin:
2277+
raise asyncio.CancelledError("factory cancelled")
2278+
2279+
2280+
class _ThreadControlFactory:
2281+
"""Factory that raises one of the three exceptions that must propagate."""
2282+
2283+
def __init__(self, control: type[BaseException]) -> None:
2284+
self._control = control
2285+
2286+
def create_plugin(self, info: InvocationStartInfo) -> DurableInstrumentationPlugin:
2287+
raise self._control
2288+
2289+
2290+
class _CancellingPlugin(DurableInstrumentationPlugin):
2291+
"""Plugin whose hook raises outside the ``Exception`` hierarchy."""
2292+
2293+
def on_operation_start(self, info):
2294+
raise asyncio.CancelledError("hook cancelled")
2295+
2296+
21742297
class _FailingPlugin(DurableInstrumentationPlugin):
21752298
"""Plugin that raises on every hook call."""
21762299

0 commit comments

Comments
 (0)