Skip to content

Commit ac786f6

Browse files
committed
fix(plugin): send one end notification per invocation
The success dispatch of on_invocation_end sat inside the try that reports a handler failure, so an end hook that raised was caught as though the handler had failed and the hook ran a second time with a RETRY outcome. Later plugins were told the wrong thing about an invocation that succeeded, and an exporter exported twice. A hook can raise: _dispatch_plugin re-raises the three exceptions that instruct the calling thread to stop, and from_dict can reject an output the handler built. The output is parsed inside the try and the success hook is dispatched after it, so each invocation produces exactly one end notification. Registration also rejects more factory-class shapes. A signature bind accepts create_plugin(self, info=None) and create_plugin(self, *args), because the probe binds to self and what remains is satisfied, so plugins=[MyFactory] still passed and then failed on every invocation where the error is swallowed. For a class the kind of the member now decides: a classmethod carries __self__, a staticmethod is identified through its descriptor, and an attribute holding a callable object takes no implicit first argument. Anything else read off a class takes self and is rejected. inspect.getattr_static walks the MRO without running a descriptor, so this still runs no factory code.
1 parent fa6190d commit ac786f6

4 files changed

Lines changed: 167 additions & 21 deletions

File tree

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,13 +1094,18 @@ def decorator(
10941094
@functools.wraps(func)
10951095
def wrapper(event: Any, context: LambdaContext):
10961096
with self.invocation() as plugin_executor:
1097+
# The end hook is dispatched exactly once per invocation, so
1098+
# the success dispatch sits outside the try. Inside it, an
1099+
# end hook that raised would be caught as though the handler
1100+
# had failed, and the hook would run a second time with a
1101+
# RETRY outcome -- telling later plugins the wrong thing about
1102+
# an invocation that succeeded, and letting an exporter export
1103+
# twice. A hook can raise: _dispatch_plugin re-raises the three
1104+
# exceptions that instruct the calling thread to stop, and
1105+
# from_dict below can reject an output the handler built.
10971106
try:
10981107
output = func(event, context, plugin_executor)
1099-
1100-
plugin_executor.on_invocation_end(
1101-
output=DurableExecutionInvocationOutput.from_dict(output),
1102-
)
1103-
return output
1108+
completed = DurableExecutionInvocationOutput.from_dict(output)
11041109
except BaseException as e:
11051110
# Every exit fires the end hook, not only the ones that
11061111
# derive from Exception. A handler that surfaces an
@@ -1126,6 +1131,8 @@ def wrapper(event: Any, context: LambdaContext):
11261131
),
11271132
)
11281133
raise
1134+
plugin_executor.on_invocation_end(output=completed)
1135+
return output
11291136

11301137
return wrapper
11311138

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

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,12 @@ def _is_plugin_factory(value: object) -> bool:
7373
plain function whose first parameter is ``self``, which is callable. The
7474
per-invocation call supplies only the info, Python binds it to ``self``, and
7575
the resulting :exc:`TypeError` is contained like any other factory failure:
76-
telemetry is silently absent for the lifetime of the function. Binding one
77-
positional argument to the signature rejects that at registration instead.
78-
The bind is a signature operation, so no factory code runs.
76+
telemetry is silently absent for the lifetime of the function. Two checks
77+
reject that at registration instead. For a class, the *kind* of the member
78+
decides, because a signature bind cannot tell ``create_plugin(self, info)``
79+
from ``create_plugin(info)``: see :func:`_is_unbound_instance_method`. For
80+
everything else, binding one positional argument to the signature rejects a
81+
member that cannot receive the info. Neither check runs factory code.
7982
8083
A callable with no introspectable signature -- a C-implemented callable, for
8184
example -- is accepted on the member alone. ``inspect.signature`` raises for
@@ -94,9 +97,45 @@ def _is_plugin_factory(value: object) -> bool:
9497
create_plugin = getattr(value, "create_plugin", None)
9598
if not callable(create_plugin):
9699
return False
100+
if isinstance(value, type) and _is_unbound_instance_method(value, create_plugin):
101+
return False
97102
return _accepts_one_positional_argument(create_plugin)
98103

99104

105+
def _is_unbound_instance_method(cls: type, create_plugin: object) -> bool:
106+
"""Report whether a class's ``create_plugin`` is an instance method.
107+
108+
Read off the class, an instance method is a plain function whose first
109+
parameter is ``self``, so the per-invocation call binds the info to ``self``
110+
and the factory never sees it. The signature bind below cannot catch every
111+
such shape: ``create_plugin(self, info=None)`` and
112+
``create_plugin(self, *args)`` both bind one argument to ``self`` and leave
113+
the rest satisfied. The kind of the member decides it instead.
114+
115+
Three shapes read off a class are usable and none of them is a plain
116+
function. A ``@classmethod`` is already bound to the class, so it carries
117+
``__self__``. A ``@staticmethod`` is a plain function, but its descriptor says
118+
it takes no implicit first argument. And an attribute holding a callable
119+
object -- ``create_plugin = SomeCallable()`` -- is not a function at all and
120+
takes no implicit first argument either. Anything else read off a class takes
121+
``self`` and cannot serve.
122+
123+
:func:`inspect.getattr_static` is what distinguishes the ``@staticmethod``,
124+
because it returns the descriptor rather than what reading the attribute
125+
produces. It walks the MRO without running any descriptor, so no factory code
126+
runs here.
127+
"""
128+
if getattr(create_plugin, "__self__", None) is not None:
129+
return False
130+
if not inspect.isfunction(create_plugin):
131+
return False
132+
try:
133+
declared = inspect.getattr_static(cls, "create_plugin")
134+
except AttributeError:
135+
return False
136+
return not isinstance(declared, staticmethod)
137+
138+
100139
def _accepts_one_positional_argument(create_plugin: object) -> bool:
101140
"""Report whether one positional argument can be bound to a callable.
102141

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

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ def create_plugin(self, info: InvocationStartInfo) -> _PluginB:
4545
return _PluginB()
4646

4747

48+
class _DefaultedArgumentFactory:
49+
"""Instance method whose info parameter has a default, so one argument binds."""
50+
51+
def create_plugin(self, info: InvocationStartInfo | None = None) -> _PluginA:
52+
return _PluginA()
53+
54+
55+
class _VariadicFactory:
56+
"""Instance method taking ``*args``, so any argument count binds."""
57+
58+
def create_plugin(self, *args: object) -> _PluginA:
59+
return _PluginA()
60+
61+
4862
_plugin_a_factory = _PluginAFactory()
4963
_plugin_b_factory = _PluginBFactory()
5064

@@ -579,26 +593,49 @@ def create_plugin(cls, info: InvocationStartInfo) -> _ClassFactoryPlugin:
579593
assert plugin.info is INVOCATION_START_INFO
580594

581595

582-
def test_explicit_factory_class_with_an_instance_method_is_rejected() -> None:
583-
"""The factory class is not the factory, and callability does not reveal it.
584-
585-
``MyFactory.create_plugin`` read off the class is a plain function whose
586-
first parameter is ``self``, so it is callable and used to pass. The
587-
per-invocation call supplies only the info, Python binds it to ``self``, and
588-
the resulting ``TypeError`` is contained like any other factory failure:
589-
instrumentation is silently absent for the lifetime of the function. The
590-
signature is bound at registration so the mistake fails here instead.
596+
@pytest.mark.parametrize(
597+
"factory_class",
598+
[
599+
_PluginAFactory,
600+
_DefaultedArgumentFactory,
601+
_VariadicFactory,
602+
],
603+
ids=["plain", "defaulted", "variadic"],
604+
)
605+
def test_explicit_factory_class_with_an_instance_method_is_rejected(
606+
factory_class: type,
607+
) -> None:
608+
"""The factory class is not the factory, and no signature shape rescues it.
609+
610+
``MyFactory.create_plugin`` read off the class is a plain function whose first
611+
parameter is ``self``, so the per-invocation call binds the info to ``self``
612+
and the factory never sees it. A signature bind alone does not catch every
613+
such shape: ``create_plugin(self, info=None)`` and
614+
``create_plugin(self, *args)`` both bind one argument to ``self`` and leave
615+
the rest satisfied, so they used to pass and then fail on every invocation
616+
where the error is swallowed. The kind of the member decides instead.
591617
"""
592618
with pytest.raises(PluginLoadError) as error:
593-
load_configured_plugins([_PluginAFactory], environment={}) # type: ignore[list-item]
619+
load_configured_plugins([factory_class], environment={}) # type: ignore[list-item]
594620

595621
assert "plugins[0]" in str(error.value)
596622
assert "a factory instance rather than the factory class" in str(error.value)
597623

598624

599-
def test_discovery_rejects_a_factory_class_at_the_entry_point() -> None:
600-
"""The entry-point path applies the same signature check."""
601-
entry_point = _FakeEntryPoint("a", _PluginAFactory)
625+
@pytest.mark.parametrize(
626+
"factory_class",
627+
[
628+
_PluginAFactory,
629+
_DefaultedArgumentFactory,
630+
_VariadicFactory,
631+
],
632+
ids=["plain", "defaulted", "variadic"],
633+
)
634+
def test_discovery_rejects_a_factory_class_at_the_entry_point(
635+
factory_class: type,
636+
) -> None:
637+
"""The entry-point path applies the same rule."""
638+
entry_point = _FakeEntryPoint("a", factory_class)
602639

603640
with (
604641
patch(
@@ -616,6 +653,28 @@ def test_discovery_rejects_a_factory_class_at_the_entry_point() -> None:
616653
assert "not the factory class" in str(error.value)
617654

618655

656+
def test_explicit_class_holding_a_callable_create_plugin_is_accepted() -> None:
657+
"""A class attribute holding a callable takes no implicit first argument.
658+
659+
Reading it off the class produces the callable itself, so the info reaches it.
660+
"""
661+
662+
class _CallableMember:
663+
def __call__(self, info: InvocationStartInfo) -> _PluginA:
664+
return _PluginA()
665+
666+
class _MemberFactory:
667+
create_plugin = _CallableMember()
668+
669+
result = load_configured_plugins([_MemberFactory], environment={}) # type: ignore[list-item]
670+
671+
assert result == [_MemberFactory]
672+
assert isinstance(
673+
_MemberFactory.create_plugin(INVOCATION_START_INFO),
674+
_PluginA,
675+
)
676+
677+
619678
def test_explicit_class_declaring_a_static_create_plugin_is_accepted() -> None:
620679
"""A ``@staticmethod`` presents the signature the SDK calls, so it binds."""
621680

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,36 @@ 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_an_end_hook_that_stops_the_thread_is_not_reported_twice(self):
651+
"""Exactly one end notification per invocation, whatever the hook does.
652+
653+
``_dispatch_plugin`` re-raises the three exceptions that instruct the
654+
calling thread to stop, so a hook can raise. With the success dispatch
655+
inside the try, that raise was caught as a handler failure and the hook
656+
ran a second time with a RETRY outcome -- the wrong outcome for an
657+
invocation that succeeded, and a second export for an exporter.
658+
"""
659+
plugin = _ControlOnEndPlugin()
660+
host = PluginHost(plugins=[plugin_factory(plugin)])
661+
662+
@host.handle_durable_output
663+
def handler(event, context, plugin_executor):
664+
plugin_executor.on_invocation_start(
665+
execution_arn="arn:exec",
666+
lambda_context=LAMBDA_CTX,
667+
execution_start_time=START_TS,
668+
is_first_invocation=True,
669+
)
670+
return {
671+
"Status": ServiceInvocationStatus.SUCCEEDED.value,
672+
"Result": None,
673+
}
674+
675+
with self.assertRaises(KeyboardInterrupt):
676+
handler({}, LAMBDA_CTX)
677+
678+
self.assertEqual(plugin.end_statuses, [InvocationStatus.SUCCEEDED])
679+
650680
def test_a_body_raising_cancellation_still_fires_the_end_hook(self):
651681
"""Every exit fires the end hook, not only the ones deriving from Exception.
652682
@@ -2294,6 +2324,17 @@ def on_operation_start(self, info):
22942324
raise asyncio.CancelledError("hook cancelled")
22952325

22962326

2327+
class _ControlOnEndPlugin(DurableInstrumentationPlugin):
2328+
"""Plugin whose end hook records the outcome and then stops the thread."""
2329+
2330+
def __init__(self) -> None:
2331+
self.end_statuses: list[InvocationStatus] = []
2332+
2333+
def on_invocation_end(self, info: InvocationEndInfo) -> None:
2334+
self.end_statuses.append(info.status)
2335+
raise KeyboardInterrupt
2336+
2337+
22972338
class _FailingPlugin(DurableInstrumentationPlugin):
22982339
"""Plugin that raises on every hook call."""
22992340

0 commit comments

Comments
 (0)