Skip to content

Commit 717ef06

Browse files
committed
refactor(plugin): make the factory an object with create_plugin
A reviewer asked for the registration type to be an object with a method rather than a bare callable, and the reason is future extensibility rather than taste. A callable type alias has no member to add anything to. So adding a process-level hook later -- a flush when the execution environment shuts down, for example -- would have to change the registration type from callable to object, which is a second breaking change on the same public surface. An object with one method can gain an optional second member additively, so doing this now costs one break instead of two. `DurableInstrumentationPluginFactory` is therefore a Protocol declaring `create_plugin(info)`, not `Callable[[InvocationStartInfo], ...]`. The type's name and the returned type's name are both unchanged, so no identifier changes meaning. The method is `create_plugin` rather than `new_invocation` because what it returns is still called a plugin, so the verb and the noun agree. This is the shape the Java SDK already had, as `DurableExecutionPluginFactory.createPlugin(InvocationInfo)`, so the three SDKs now describe one contract. The protocol is deliberately not `@runtime_checkable`, for three reasons. An `isinstance` check against a runtime-checkable protocol tests only that the member name exists, so it accepts an object whose `create_plugin` is a string. The SDK also has to name what an invalid entry actually was, which a boolean cannot supply. And such a check requires every declared member, which would make the optional second member above non-additive for any caller who wrote one. `_is_plugin_factory` tests for a callable `create_plugin` instead, structurally, so a factory need not import the protocol. `info` is positional-only on the protocol method. A protocol parameter that is positional-or-keyword is part of the structural contract, so a factory naming it `invocation` would otherwise be a type error. One consequence reverses guidance added earlier in this branch. A plugin class used to be a valid factory, because calling a class constructs an instance, and the docs said so. A class carries no `create_plugin`, so `plugins=[MyPlugin]` now fails at handler initialization, and the test that pinned the old behaviour is replaced by one pinning the new. A class that declares `create_plugin` itself, as a classmethod, is still valid, because the requirement is the member and not the kind of object. The reversal is an improvement for a reason the earlier round raised separately: `CONTRIBUTING.md:246` asks for light constructors, and class-as-factory encouraged `__init__(self, info)` to do setup work. A factory object puts that work in the factory's own constructor, where it can fail before any invocation depends on it. Migrated with it: both bundled plugins' factory classes, whose `__call__` becomes `create_plugin`; 23 conformance plugin handlers, each gaining a small factory class beside its plugin; two examples; one testing-package e2e test that previously aborted collection of that whole suite; and the prose in four READMEs. No handler's observable behaviour changed, and that was measured rather than assumed. The pre-migration handlers were extracted against the pre- migration SDK, every handler was driven through the local runner in both trees, and the emitted records were compared after dropping the execution ARN and wall-clock fields: 23 handlers, 89 records, 23 identical streams, 0 differing. Registration order is preserved in both handlers that register two plugins.
1 parent f8b4f31 commit 717ef06

46 files changed

Lines changed: 985 additions & 301 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,8 @@ write access; the runner identity needs list, read, and cleanup access.
182182
1. Find or add the requirement in the conformance repository under
183183
`test-requirements/<suite>/<id>.yaml`. New requirement IDs must be registered
184184
there first.
185-
2. Add `src/otel_<n>_<name>.py` exporting `handler`. Select the plugin with
186-
`common.otel_plugin_factory()` and guard the input with
185+
2. Add `src/otel_<n>_<name>.py` exporting `handler`. Select the plugin factory
186+
with `common.otel_plugin_factory()` and guard the input with
187187
`common.require_scenario()`.
188188
Use the SDK's real API; never hand-roll behavior to force an expected result.
189189
3. Register the function in `template.yaml` (or `template-long-running.yaml`)

packages/aws-durable-execution-sdk-python-conformance-tests-otel/src/common.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ def otel_plugin_factory() -> DurableInstrumentationPluginFactory:
2323
"""Select the telemetry view configured for this deployed function.
2424
2525
Returns a factory, which is what ``durable_execution(plugins=[...])`` takes:
26-
the SDK calls it once per invocation to build that invocation's plugin. The
27-
view is still resolved once, when the handler module is imported.
26+
the SDK calls its ``create_plugin`` once per invocation to build that
27+
invocation's plugin. The view is still resolved once, when the handler module
28+
is imported.
2829
"""
2930

3031
if os.environ.get("OTEL_PLUGIN_MODE") == "execution":

packages/aws-durable-execution-sdk-python-conformance-tests/handlers/plugin/plugin_attempt_hooks_retry.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ def on_user_function_end(self, info: UserFunctionEndInfo) -> None:
7979
)
8080

8181

82+
class AttemptPluginFactory:
83+
"""Builds one :class:`AttemptPlugin` for each invocation.
84+
85+
``durable_execution(plugins=[...])`` takes factory objects whose
86+
``create_plugin`` the SDK calls once per invocation. A bare callable is
87+
rejected while the handler is being initialized, so registering one would
88+
stop this handler from importing. This factory exists only to construct the
89+
plugin.
90+
"""
91+
92+
def create_plugin(self, info: InvocationStartInfo) -> AttemptPlugin:
93+
"""Return this invocation's plugin. ``info`` is unused."""
94+
return AttemptPlugin()
95+
96+
8297
@durable_step
8398
def unreliable_operation(step_context: StepContext) -> str:
8499
# Fail on the first attempt, succeed on the second, using the SDK's built-in
@@ -89,7 +104,7 @@ def unreliable_operation(step_context: StepContext) -> str:
89104
return "Operation succeeded"
90105

91106

92-
@durable_execution(plugins=[lambda _info: AttemptPlugin()])
107+
@durable_execution(plugins=[AttemptPluginFactory()])
93108
def handler(_event: Any, context: DurableContext) -> str:
94109
retry_config = RetryStrategyConfig(
95110
max_attempts=3,

packages/aws-durable-execution-sdk-python-conformance-tests/handlers/plugin/plugin_attempt_info_shape.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,29 @@ def on_user_function_end(self, info: UserFunctionEndInfo) -> None:
7979
_emit(record, self._execution_arn)
8080

8181

82+
class AttemptInfoShapePluginFactory:
83+
"""Builds one :class:`AttemptInfoShapePlugin` for each invocation.
84+
85+
``durable_execution(plugins=[...])`` takes factory objects whose
86+
``create_plugin`` the SDK calls once per invocation. A bare callable is
87+
rejected while the handler is being initialized, so registering one would
88+
stop this handler from importing. This factory exists only to construct the
89+
plugin.
90+
"""
91+
92+
def create_plugin(self, info: InvocationStartInfo) -> AttemptInfoShapePlugin:
93+
"""Return this invocation's plugin. ``info`` is unused."""
94+
return AttemptInfoShapePlugin()
95+
96+
8297
@durable_step
8398
def flaky(step_context: StepContext) -> str:
8499
if step_context.attempt < 2:
85100
raise RuntimeError(f"Attempt {step_context.attempt} failed")
86101
return "ok"
87102

88103

89-
@durable_execution(plugins=[lambda _info: AttemptInfoShapePlugin()])
104+
@durable_execution(plugins=[AttemptInfoShapePluginFactory()])
90105
def handler(_event: Any, context: DurableContext) -> str:
91106
retry_config = RetryStrategyConfig(
92107
max_attempts=3,

packages/aws-durable-execution-sdk-python-conformance-tests/handlers/plugin/plugin_context_info_shape.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ def on_user_function_start(self, info: UserFunctionStartInfo) -> None:
7979
_emit(record, self._execution_arn)
8080

8181

82+
class ContextInfoShapePluginFactory:
83+
"""Builds one :class:`ContextInfoShapePlugin` for each invocation.
84+
85+
``durable_execution(plugins=[...])`` takes factory objects whose
86+
``create_plugin`` the SDK calls once per invocation. A bare callable is
87+
rejected while the handler is being initialized, so registering one would
88+
stop this handler from importing. This factory exists only to construct the
89+
plugin.
90+
"""
91+
92+
def create_plugin(self, info: InvocationStartInfo) -> ContextInfoShapePlugin:
93+
"""Return this invocation's plugin. ``info`` is unused."""
94+
return ContextInfoShapePlugin()
95+
96+
8297
@durable_step
8398
def inner(_step_context: StepContext) -> str:
8499
return "x"
@@ -94,7 +109,7 @@ def branch_b(_context: DurableContext) -> str:
94109
return "b-done"
95110

96111

97-
@durable_execution(plugins=[lambda _info: ContextInfoShapePlugin()])
112+
@durable_execution(plugins=[ContextInfoShapePluginFactory()])
98113
def handler(_event: Any, context: DurableContext) -> list[str]:
99114
result: BatchResult[str] = context.parallel(
100115
[

packages/aws-durable-execution-sdk-python-conformance-tests/handlers/plugin/plugin_error_isolation.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,27 @@ def on_user_function_end(self, info: UserFunctionEndInfo) -> None:
8282
raise RuntimeError("faulty attempt-end")
8383

8484

85+
class FaultyPluginFactory:
86+
"""Builds one :class:`FaultyPlugin` for each invocation.
87+
88+
``durable_execution(plugins=[...])`` takes factory objects whose
89+
``create_plugin`` the SDK calls once per invocation. A bare callable is
90+
rejected while the handler is being initialized, so registering one would
91+
stop this handler from importing. This factory exists only to construct the
92+
plugin.
93+
"""
94+
95+
def create_plugin(self, info: InvocationStartInfo) -> FaultyPlugin:
96+
"""Return this invocation's plugin. ``info`` is unused."""
97+
return FaultyPlugin()
98+
99+
85100
@durable_step
86101
def greet(_step_context: StepContext, name: str) -> str:
87102
return f"Hello, {name}!"
88103

89104

90-
@durable_execution(plugins=[lambda _info: FaultyPlugin()])
105+
@durable_execution(plugins=[FaultyPluginFactory()])
91106
def handler(event: Any, context: DurableContext) -> str:
92107
result: str = context.step(greet(event))
93108
return result

packages/aws-durable-execution-sdk-python-conformance-tests/handlers/plugin/plugin_external_update_on_invoke.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,22 @@ def on_operation_end(self, info: OperationEndInfo) -> None:
6565
)
6666

6767

68-
@durable_execution(plugins=[lambda _info: ExternalUpdatePlugin()])
68+
class ExternalUpdatePluginFactory:
69+
"""Builds one :class:`ExternalUpdatePlugin` for each invocation.
70+
71+
``durable_execution(plugins=[...])`` takes factory objects whose
72+
``create_plugin`` the SDK calls once per invocation. A bare callable is
73+
rejected while the handler is being initialized, so registering one would
74+
stop this handler from importing. This factory exists only to construct the
75+
plugin.
76+
"""
77+
78+
def create_plugin(self, info: InvocationStartInfo) -> ExternalUpdatePlugin:
79+
"""Return this invocation's plugin. ``info`` is unused."""
80+
return ExternalUpdatePlugin()
81+
82+
83+
@durable_execution(plugins=[ExternalUpdatePluginFactory()])
6984
def handler(_event: Any, context: DurableContext) -> str:
7085
context.wait(Duration.from_seconds(2))
7186
return "Wait completed"

packages/aws-durable-execution-sdk-python-conformance-tests/handlers/plugin/plugin_faulty_and_healthy.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""10-17: Faulty plugin does not affect a healthy plugin.
22
3-
Two plugins are registered together, in order: a faulty plugin whose every
4-
exercised hook logs a line and then raises, and a healthy plugin that logs
5-
normally. The exercised hooks span the full lifecycle: invocation-start,
6-
operation-start, attempt-start, attempt-end, operation-end, and invocation-end.
3+
Two plugins are registered together, through their factories, in order: a faulty
4+
plugin whose every exercised hook logs a line and then raises, and a healthy
5+
plugin that logs normally. The exercised hooks span the full lifecycle:
6+
invocation-start, operation-start, attempt-start, attempt-end, operation-end, and
7+
invocation-end.
78
In the Python SDK the per-attempt hooks are the real ``on_user_function_start`` /
89
``on_user_function_end`` callbacks (the latter carries the attempt ``outcome``),
910
and the operation hooks are ``on_operation_start`` / ``on_operation_end``.
@@ -103,6 +104,21 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
103104
raise RuntimeError("faulty invocation-end")
104105

105106

107+
class FaultyPluginFactory:
108+
"""Builds one :class:`FaultyPlugin` for each invocation.
109+
110+
``durable_execution(plugins=[...])`` takes factory objects whose
111+
``create_plugin`` the SDK calls once per invocation. A bare callable is
112+
rejected while the handler is being initialized, so registering one would
113+
stop this handler from importing. This factory exists only to construct the
114+
plugin.
115+
"""
116+
117+
def create_plugin(self, info: InvocationStartInfo) -> FaultyPlugin:
118+
"""Return this invocation's plugin. ``info`` is unused."""
119+
return FaultyPlugin()
120+
121+
106122
class HealthyPlugin(DurableInstrumentationPlugin):
107123
def __init__(self) -> None:
108124
# Operation/attempt hooks do not carry the execution ARN, so capture it
@@ -182,14 +198,27 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
182198
)
183199

184200

201+
class HealthyPluginFactory:
202+
"""Builds one :class:`HealthyPlugin` for each invocation.
203+
204+
``durable_execution(plugins=[...])`` takes factory objects whose
205+
``create_plugin`` the SDK calls once per invocation. A bare callable is
206+
rejected while the handler is being initialized, so registering one would
207+
stop this handler from importing. This factory exists only to construct the
208+
plugin.
209+
"""
210+
211+
def create_plugin(self, info: InvocationStartInfo) -> HealthyPlugin:
212+
"""Return this invocation's plugin. ``info`` is unused."""
213+
return HealthyPlugin()
214+
215+
185216
@durable_step
186217
def greet(_step_context: StepContext, name: str) -> str:
187218
return f"Hello, {name}!"
188219

189220

190-
@durable_execution(
191-
plugins=[lambda _info: FaultyPlugin(), lambda _info: HealthyPlugin()]
192-
)
221+
@durable_execution(plugins=[FaultyPluginFactory(), HealthyPluginFactory()])
193222
def handler(event: Any, context: DurableContext) -> str:
194223
result: str = context.step(greet(event))
195224
return result

packages/aws-durable-execution-sdk-python-conformance-tests/handlers/plugin/plugin_first_invocation_flag.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,22 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
4747
)
4848

4949

50-
@durable_execution(plugins=[lambda _info: FirstInvocationPlugin()])
50+
class FirstInvocationPluginFactory:
51+
"""Builds one :class:`FirstInvocationPlugin` for each invocation.
52+
53+
``durable_execution(plugins=[...])`` takes factory objects whose
54+
``create_plugin`` the SDK calls once per invocation. A bare callable is
55+
rejected while the handler is being initialized, so registering one would
56+
stop this handler from importing. This factory exists only to construct the
57+
plugin.
58+
"""
59+
60+
def create_plugin(self, info: InvocationStartInfo) -> FirstInvocationPlugin:
61+
"""Return this invocation's plugin. ``info`` is unused."""
62+
return FirstInvocationPlugin()
63+
64+
65+
@durable_execution(plugins=[FirstInvocationPluginFactory()])
5166
def handler(_event: Any, context: DurableContext) -> str:
5267
context.wait(Duration.from_seconds(2))
5368
return "Wait completed"

packages/aws-durable-execution-sdk-python-conformance-tests/handlers/plugin/plugin_invocation_info_shape.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,22 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
5858
_emit(record, info.execution_arn)
5959

6060

61-
@durable_execution(plugins=[lambda _info: InvocationInfoShapePlugin()])
61+
class InvocationInfoShapePluginFactory:
62+
"""Builds one :class:`InvocationInfoShapePlugin` for each invocation.
63+
64+
``durable_execution(plugins=[...])`` takes factory objects whose
65+
``create_plugin`` the SDK calls once per invocation. A bare callable is
66+
rejected while the handler is being initialized, so registering one would
67+
stop this handler from importing. This factory exists only to construct the
68+
plugin.
69+
"""
70+
71+
def create_plugin(self, info: InvocationStartInfo) -> InvocationInfoShapePlugin:
72+
"""Return this invocation's plugin. ``info`` is unused."""
73+
return InvocationInfoShapePlugin()
74+
75+
76+
@durable_execution(plugins=[InvocationInfoShapePluginFactory()])
6277
def handler(event: Any, context: DurableContext) -> str:
6378
context.wait(Duration.from_seconds(2))
6479
return f"done-{event}"

0 commit comments

Comments
 (0)