Skip to content

Commit 6b311bb

Browse files
authored
feat: load plugins dynamically from classpath (#606)
1 parent a09b695 commit 6b311bb

12 files changed

Lines changed: 710 additions & 9 deletions

File tree

‎docs/advanced/configuration.md‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,59 @@ public class OrderProcessor extends DurableHandler<Order, OrderResult> {
3838
| `withPollingStrategy()` | Backend polling strategy | Exponential backoff: 1s base, 2x rate, FULL jitter, 10s max |
3939
| `withCheckpointDelay()` | How often the SDK checkpoints updates | `Duration.ofSeconds(0)` (as soon as possible) |
4040

41-
The `withExecutorService()` option configures the thread pool used for running user-defined operations. Internal SDK coordination (checkpoint batching, polling) runs on an SDK-managed thread pool.
41+
The `withExecutorService()` option configures the thread pool used for running user-defined operations. Internal SDK coordination (checkpoint batching, polling) runs on an SDK-managed thread pool.
42+
43+
### Dynamic plugin loading
44+
45+
Dynamic plugin loading is an experimental, opt-in alternative to registering plugins in application code. Put provider JARs on the application class path, then set `DURABLE_EXECUTION_PLUGINS` to an ordered, comma-separated list of provider names:
46+
47+
```text
48+
DURABLE_EXECUTION_PLUGINS=otel-invocation,com.example.audit
49+
```
50+
51+
When the variable is unset or blank, the SDK does not perform provider discovery. During `DurableConfig` construction, the SDK uses `ServiceLoader` and the thread context class loader to find `DurableExecutionPluginProvider` implementations. Only named providers create plugins.
52+
53+
Dynamically loaded plugins run first in the order listed in `DURABLE_EXECUTION_PLUGINS`. Plugins registered through `withPlugins(...)` follow in configuration order. Both sources are additive: if the same plugin type is selected dynamically and registered explicitly, both instances are registered and receive lifecycle hooks. Duplicate configured provider names, duplicate discovered provider names, missing providers, incompatible provider API versions, invalid plugin types, and provider construction failures stop configuration with an `IllegalStateException`.
54+
55+
To distribute a provider in a Lambda layer, package its JAR under `java/lib`:
56+
57+
```text
58+
my-plugin-layer.zip
59+
`-- java
60+
`-- lib
61+
`-- my-durable-plugin.jar
62+
```
63+
64+
The provider JAR must contain:
65+
66+
```text
67+
META-INF/services/software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider
68+
```
69+
70+
The service file contains the provider implementation class name. A minimal provider looks like:
71+
72+
```java
73+
public final class AuditPluginProvider implements DurableExecutionPluginProvider {
74+
@Override
75+
public String getName() {
76+
return "com.example.audit";
77+
}
78+
79+
@Override
80+
public int getApiVersion() {
81+
return API_VERSION;
82+
}
83+
84+
@Override
85+
public Class<? extends DurableExecutionPlugin> getPluginType() {
86+
return AuditPlugin.class;
87+
}
88+
89+
@Override
90+
public DurableExecutionPlugin createPlugin() {
91+
return new AuditPlugin();
92+
}
93+
}
94+
```
95+
96+
Provider-specific settings can use namespaced environment variables. If an application shades provider JARs into one artifact, its build must preserve and merge `META-INF/services` entries.

‎otel-plugin/README.md‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ OpenTelemetry instrumentation plugin for the AWS Lambda Durable Execution SDK fo
1111
- **Attempt Spans**: Each user function execution (step attempt, child context run) gets a span, including retries
1212
- **Log Correlation**: Injects `trace_id`, `span_id`, and `traceSampled` into SLF4J MDC for end-to-end observability
1313
- **ADOT Java Agent Integration**: `new InvocationOtelPlugin()` uses the ADOT Java agent's global provider with no handler-side OpenTelemetry initialization
14+
- **Lambda Layer Discovery**: `DURABLE_EXECUTION_PLUGINS` loads either OTel plugin from a JAR under a layer's `java/lib` directory
1415

1516
## Installation
1617

@@ -44,7 +45,7 @@ If you configure your own `SdkTracerProviderBuilder`, add the OpenTelemetry SDK
4445
1. Add the ADOT Lambda Layer to your function
4546
2. Enable X-Ray Active Tracing on the function
4647
3. Configure environment variables
47-
4. Register `InvocationOtelPlugin` in your handler's `DurableConfig`
48+
4. Load `InvocationOtelPlugin` dynamically or register it in your handler's `DurableConfig`
4849
5. Grant X-Ray write permissions
4950

5051
### 1. ADOT Lambda Layer
@@ -70,22 +71,24 @@ MyFunction:
7071
LogFormat: JSON
7172
Layers:
7273
- !Sub arn:aws:lambda:${AWS::Region}:615299751070:layer:AWSOpenTelemetryDistroJava:16
74+
- <otel-plugin-layer-arn>
7375
Environment:
7476
Variables:
7577
AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-instrument
76-
OTEL_JAVAAGENT_EXTENSIONS: /var/task/lib/aws-durable-execution-sdk-java-plugin-otel-<version>.jar
78+
OTEL_JAVAAGENT_EXTENSIONS: /opt/java/lib/aws-durable-execution-sdk-java-plugin-otel-<version>.jar
79+
DURABLE_EXECUTION_PLUGINS: otel-invocation
7780
```
7881
7982
**AWS CLI:**
8083
8184
```bash
8285
aws lambda update-function-configuration \
8386
--function-name your-function-name \
84-
--layers "arn:aws:lambda:<region>:615299751070:layer:AWSOpenTelemetryDistroJava:16" \
85-
--environment "Variables={AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument,OTEL_JAVAAGENT_EXTENSIONS=/var/task/lib/aws-durable-execution-sdk-java-plugin-otel-<version>.jar}"
87+
--layers "arn:aws:lambda:<region>:615299751070:layer:AWSOpenTelemetryDistroJava:16" "<otel-plugin-layer-arn>" \
88+
--environment "Variables={AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument,OTEL_JAVAAGENT_EXTENSIONS=/opt/java/lib/aws-durable-execution-sdk-java-plugin-otel-<version>.jar,DURABLE_EXECUTION_PLUGINS=otel-invocation}"
8689
```
8790

88-
Set `OTEL_JAVAAGENT_EXTENSIONS` to the deployed OTel plugin jar that contains this plugin's `META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider` entry.
91+
Build the plugin layer ZIP with the OTel plugin JAR at `java/lib/aws-durable-execution-sdk-java-plugin-otel-<version>.jar`. Lambda adds JARs in this directory to the Java class path. Set `OTEL_JAVAAGENT_EXTENSIONS` to the deployed JAR so the ADOT Java agent also loads its `AutoConfigurationCustomizerProvider`, and set `DURABLE_EXECUTION_PLUGINS=otel-invocation` so the Durable Execution SDK loads its `InvocationOtelPluginProvider`.
8992

9093
### 2. AWS X-Ray Active Tracing
9194

@@ -102,7 +105,20 @@ MyFunction:
102105
Tracing: Active
103106
```
104107
105-
### 3. In Your Lambda Handler
108+
### 3. Plugin Registration
109+
110+
With the layer and `DURABLE_EXECUTION_PLUGINS=otel-invocation` configured above, no OTel plugin dependency or registration code is required in the function artifact. The function can use its existing `DurableConfig`.
111+
112+
The OTel plugin JAR exposes two dynamic provider names:
113+
114+
| Provider name | Plugin | Trace model |
115+
|---------------|--------|-------------|
116+
| `otel-invocation` | `InvocationOtelPlugin` | Invocation-rooted |
117+
| `otel-execution` | `ExecutionOtelPlugin` | Workflow-rooted |
118+
119+
Set `DURABLE_EXECUTION_PLUGINS=otel-execution` to select the Workflow-rooted plugin instead.
120+
121+
Applications that prefer code-based configuration can continue to register the plugin explicitly:
106122

107123
```java
108124
import software.amazon.lambda.durable.DurableConfig;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.otel;
4+
5+
import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
6+
import software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider;
7+
8+
/**
9+
* Dynamically loads {@link ExecutionOtelPlugin} when {@code DURABLE_EXECUTION_PLUGINS} contains {@code otel-execution}.
10+
*
11+
* @deprecated This is a preview API that is experimental and may be changed or removed in future releases.
12+
*/
13+
@Deprecated
14+
public final class ExecutionOtelPluginProvider implements DurableExecutionPluginProvider {
15+
16+
@Override
17+
public String getName() {
18+
return "otel-execution";
19+
}
20+
21+
@Override
22+
public int getApiVersion() {
23+
return API_VERSION;
24+
}
25+
26+
@Override
27+
public Class<? extends DurableExecutionPlugin> getPluginType() {
28+
return ExecutionOtelPlugin.class;
29+
}
30+
31+
@Override
32+
public DurableExecutionPlugin createPlugin() {
33+
return new ExecutionOtelPlugin();
34+
}
35+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.otel;
4+
5+
import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
6+
import software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider;
7+
8+
/**
9+
* Dynamically loads {@link InvocationOtelPlugin} when {@code DURABLE_EXECUTION_PLUGINS} contains
10+
* {@code otel-invocation}.
11+
*
12+
* @deprecated This is a preview API that is experimental and may be changed or removed in future releases.
13+
*/
14+
@Deprecated
15+
public final class InvocationOtelPluginProvider implements DurableExecutionPluginProvider {
16+
17+
@Override
18+
public String getName() {
19+
return "otel-invocation";
20+
}
21+
22+
@Override
23+
public int getApiVersion() {
24+
return API_VERSION;
25+
}
26+
27+
@Override
28+
public Class<? extends DurableExecutionPlugin> getPluginType() {
29+
return InvocationOtelPlugin.class;
30+
}
31+
32+
@Override
33+
public DurableExecutionPlugin createPlugin() {
34+
return new InvocationOtelPlugin();
35+
}
36+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
software.amazon.lambda.durable.otel.InvocationOtelPluginProvider
2+
software.amazon.lambda.durable.otel.ExecutionOtelPluginProvider

‎otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import io.opentelemetry.sdk.trace.SdkTracerProvider;
1616
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
1717
import java.time.Instant;
18+
import java.util.ServiceLoader;
1819
import org.junit.jupiter.api.AfterEach;
1920
import org.junit.jupiter.api.BeforeEach;
2021
import org.junit.jupiter.api.Test;
@@ -156,6 +157,19 @@ void defaultConstructor_usesGlobalSdkTracerProviderDirectly() {
156157
assertTrue(spans.stream().anyMatch(span -> span.getName().equals("step")));
157158
}
158159

160+
@Test
161+
void executionOtelPluginProvider_isRegisteredAsServiceProvider() {
162+
var provider = ServiceLoader.load(DurableExecutionPluginProvider.class).stream()
163+
.filter(candidate -> candidate.type().equals(ExecutionOtelPluginProvider.class))
164+
.findFirst()
165+
.orElseThrow()
166+
.get();
167+
168+
assertEquals("otel-execution", provider.getName());
169+
assertEquals(DurableExecutionPluginProvider.API_VERSION, provider.getApiVersion());
170+
assertEquals(ExecutionOtelPlugin.class, provider.getPluginType());
171+
}
172+
159173
// ─── Workflow root span lifecycle ────────────────────────────────────
160174

161175
@Test

‎otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,19 @@ void autoConfigurationCustomizerProvider_isRegisteredAsServiceProvider() {
198198
.anyMatch(provider -> provider.type().equals(OtelPluginAutoConfigurationCustomizerProvider.class)));
199199
}
200200

201+
@Test
202+
void invocationOtelPluginProvider_isRegisteredAsServiceProvider() {
203+
var provider = ServiceLoader.load(DurableExecutionPluginProvider.class).stream()
204+
.filter(candidate -> candidate.type().equals(InvocationOtelPluginProvider.class))
205+
.findFirst()
206+
.orElseThrow()
207+
.get();
208+
209+
assertEquals("otel-invocation", provider.getName());
210+
assertEquals(DurableExecutionPluginProvider.API_VERSION, provider.getApiVersion());
211+
assertEquals(InvocationOtelPlugin.class, provider.getPluginType());
212+
}
213+
201214
@Test
202215
void invocationStart_usesCurrentSpanContext_whenExtractorReturnsNull() {
203216
var traceId = "5759e988bd862e3fe1be46a994272793";

‎sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,29 @@ class PluginIntegrationTest {
2626

2727
// ─── Invocation-level hooks ──────────────────────────────────────────
2828

29+
@Test
30+
void pluginsFromConfigurationAndEnvironment_receiveLifecycleEvents() {
31+
var configuredPlugin = new RecordingPlugin();
32+
var dynamicPlugin = new RecordingPlugin();
33+
var provider = new RecordingPluginProvider(dynamicPlugin);
34+
var plugins =
35+
DynamicPluginLoader.loadConfiguredPlugins("recording", List.of(provider), List.of(configuredPlugin));
36+
var config = DurableConfig.builder()
37+
.withPlugins(plugins.toArray(DurableExecutionPlugin[]::new))
38+
.build();
39+
40+
var runner = LocalDurableTestRunner.create(
41+
String.class,
42+
(input, context) -> context.step("greet", String.class, stepCtx -> "Hello " + input),
43+
config);
44+
45+
var result = runner.runUntilComplete("World");
46+
47+
assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
48+
assertPluginReceivedLifecycleEvents(configuredPlugin);
49+
assertPluginReceivedLifecycleEvents(dynamicPlugin);
50+
}
51+
2952
@Test
3053
void plugin_receivesInvocationStartAndEnd_onSuccessfulExecution() {
3154
var plugin = new RecordingPlugin();
@@ -784,6 +807,34 @@ public void onOperationChange(OperationChangeInfo info) {
784807
}
785808
}
786809

810+
private record RecordingPluginProvider(RecordingPlugin plugin) implements DurableExecutionPluginProvider {
811+
@Override
812+
public String getName() {
813+
return "recording";
814+
}
815+
816+
@Override
817+
public int getApiVersion() {
818+
return API_VERSION;
819+
}
820+
821+
@Override
822+
public Class<? extends DurableExecutionPlugin> getPluginType() {
823+
return RecordingPlugin.class;
824+
}
825+
826+
@Override
827+
public DurableExecutionPlugin createPlugin() {
828+
return plugin;
829+
}
830+
}
831+
832+
private static void assertPluginReceivedLifecycleEvents(RecordingPlugin plugin) {
833+
assertEquals(1, plugin.invocationStarts.size());
834+
assertTrue(plugin.operationStarts.stream().anyMatch(info -> "greet".equals(info.name())));
835+
assertEquals(1, plugin.invocationEnds.size());
836+
}
837+
787838
/** Plugin that throws on every hook to verify error isolation. */
788839
private static class ThrowingPlugin implements DurableExecutionPlugin {
789840
@Override

‎sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ public final class DurableConfig {
103103
private final PluginRunner pluginRunner;
104104

105105
private DurableConfig(Builder builder) {
106+
var plugins = DynamicPluginLoader.loadConfiguredPlugins(builder.plugins);
106107
this.durableExecutionClient = Objects.requireNonNullElseGet(
107108
builder.durableExecutionClient, DurableConfig::createDefaultDurableExecutionClient);
108109
this.serDes = Objects.requireNonNullElseGet(builder.serDes, JacksonSerDes::new);
@@ -113,7 +114,7 @@ private DurableConfig(Builder builder) {
113114
this.checkpointDelay = Objects.requireNonNullElseGet(builder.checkpointDelay, () -> Duration.ofSeconds(0));
114115
this.deserializeAfterSerialization = builder.deserializeAfterSerialization;
115116
this.checkpointEmptyMap = builder.checkpointEmptyMap;
116-
this.pluginRunner = builder.plugins.isEmpty() ? PluginRunner.noOp() : new PluginRunner(builder.plugins);
117+
this.pluginRunner = plugins.isEmpty() ? PluginRunner.noOp() : new PluginRunner(plugins);
117118

118119
validateConfiguration();
119120
}
@@ -216,7 +217,7 @@ public boolean shouldCheckpointEmptyMap() {
216217
/**
217218
* Gets the plugin runner that dispatches lifecycle events to registered plugins.
218219
*
219-
* <p>Returns a no-op runner if no plugins were registered via the builder.
220+
* <p>Returns a no-op runner if no plugins were registered via the builder or loaded dynamically.
220221
*
221222
* @return PluginRunner instance (never null)
222223
* @deprecated This is a preview API that is experimental and may be changed or removed in future releases.

0 commit comments

Comments
 (0)