diff --git a/docs/advanced/configuration.md b/docs/advanced/configuration.md index bc20f86a3..e1f890df4 100644 --- a/docs/advanced/configuration.md +++ b/docs/advanced/configuration.md @@ -38,4 +38,59 @@ public class OrderProcessor extends DurableHandler { | `withPollingStrategy()` | Backend polling strategy | Exponential backoff: 1s base, 2x rate, FULL jitter, 10s max | | `withCheckpointDelay()` | How often the SDK checkpoints updates | `Duration.ofSeconds(0)` (as soon as possible) | -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. \ No newline at end of file +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. + +### Dynamic plugin loading + +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: + +```text +DURABLE_EXECUTION_PLUGINS=otel-invocation,com.example.audit +``` + +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. + +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`. + +To distribute a provider in a Lambda layer, package its JAR under `java/lib`: + +```text +my-plugin-layer.zip +`-- java + `-- lib + `-- my-durable-plugin.jar +``` + +The provider JAR must contain: + +```text +META-INF/services/software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider +``` + +The service file contains the provider implementation class name. A minimal provider looks like: + +```java +public final class AuditPluginProvider implements DurableExecutionPluginProvider { + @Override + public String getName() { + return "com.example.audit"; + } + + @Override + public int getApiVersion() { + return API_VERSION; + } + + @Override + public Class getPluginType() { + return AuditPlugin.class; + } + + @Override + public DurableExecutionPlugin createPlugin() { + return new AuditPlugin(); + } +} +``` + +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. diff --git a/otel-plugin/README.md b/otel-plugin/README.md index 76cd2f96e..aa7114c5d 100644 --- a/otel-plugin/README.md +++ b/otel-plugin/README.md @@ -11,6 +11,7 @@ OpenTelemetry instrumentation plugin for the AWS Lambda Durable Execution SDK fo - **Attempt Spans**: Each user function execution (step attempt, child context run) gets a span, including retries - **Log Correlation**: Injects `trace_id`, `span_id`, and `traceSampled` into SLF4J MDC for end-to-end observability - **ADOT Java Agent Integration**: `new InvocationOtelPlugin()` uses the ADOT Java agent's global provider with no handler-side OpenTelemetry initialization +- **Lambda Layer Discovery**: `DURABLE_EXECUTION_PLUGINS` loads either OTel plugin from a JAR under a layer's `java/lib` directory ## Installation @@ -44,7 +45,7 @@ If you configure your own `SdkTracerProviderBuilder`, add the OpenTelemetry SDK 1. Add the ADOT Lambda Layer to your function 2. Enable X-Ray Active Tracing on the function 3. Configure environment variables -4. Register `InvocationOtelPlugin` in your handler's `DurableConfig` +4. Load `InvocationOtelPlugin` dynamically or register it in your handler's `DurableConfig` 5. Grant X-Ray write permissions ### 1. ADOT Lambda Layer @@ -70,10 +71,12 @@ MyFunction: LogFormat: JSON Layers: - !Sub arn:aws:lambda:${AWS::Region}:615299751070:layer:AWSOpenTelemetryDistroJava:16 + - Environment: Variables: AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-instrument - OTEL_JAVAAGENT_EXTENSIONS: /var/task/lib/aws-durable-execution-sdk-java-plugin-otel-.jar + OTEL_JAVAAGENT_EXTENSIONS: /opt/java/lib/aws-durable-execution-sdk-java-plugin-otel-.jar + DURABLE_EXECUTION_PLUGINS: otel-invocation ``` **AWS CLI:** @@ -81,11 +84,11 @@ MyFunction: ```bash aws lambda update-function-configuration \ --function-name your-function-name \ - --layers "arn:aws:lambda::615299751070:layer:AWSOpenTelemetryDistroJava:16" \ - --environment "Variables={AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument,OTEL_JAVAAGENT_EXTENSIONS=/var/task/lib/aws-durable-execution-sdk-java-plugin-otel-.jar}" + --layers "arn:aws:lambda::615299751070:layer:AWSOpenTelemetryDistroJava:16" "" \ + --environment "Variables={AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument,OTEL_JAVAAGENT_EXTENSIONS=/opt/java/lib/aws-durable-execution-sdk-java-plugin-otel-.jar,DURABLE_EXECUTION_PLUGINS=otel-invocation}" ``` -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. +Build the plugin layer ZIP with the OTel plugin JAR at `java/lib/aws-durable-execution-sdk-java-plugin-otel-.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`. ### 2. AWS X-Ray Active Tracing @@ -102,7 +105,20 @@ MyFunction: Tracing: Active ``` -### 3. In Your Lambda Handler +### 3. Plugin Registration + +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`. + +The OTel plugin JAR exposes two dynamic provider names: + +| Provider name | Plugin | Trace model | +|---------------|--------|-------------| +| `otel-invocation` | `InvocationOtelPlugin` | Invocation-rooted | +| `otel-execution` | `ExecutionOtelPlugin` | Workflow-rooted | + +Set `DURABLE_EXECUTION_PLUGINS=otel-execution` to select the Workflow-rooted plugin instead. + +Applications that prefer code-based configuration can continue to register the plugin explicitly: ```java import software.amazon.lambda.durable.DurableConfig; diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginProvider.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginProvider.java new file mode 100644 index 000000000..2137ed513 --- /dev/null +++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginProvider.java @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.otel; + +import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; +import software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider; + +/** + * Dynamically loads {@link ExecutionOtelPlugin} when {@code DURABLE_EXECUTION_PLUGINS} contains {@code otel-execution}. + * + * @deprecated This is a preview API that is experimental and may be changed or removed in future releases. + */ +@Deprecated +public final class ExecutionOtelPluginProvider implements DurableExecutionPluginProvider { + + @Override + public String getName() { + return "otel-execution"; + } + + @Override + public int getApiVersion() { + return API_VERSION; + } + + @Override + public Class getPluginType() { + return ExecutionOtelPlugin.class; + } + + @Override + public DurableExecutionPlugin createPlugin() { + return new ExecutionOtelPlugin(); + } +} diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/InvocationOtelPluginProvider.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/InvocationOtelPluginProvider.java new file mode 100644 index 000000000..396225795 --- /dev/null +++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/InvocationOtelPluginProvider.java @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.otel; + +import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; +import software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider; + +/** + * Dynamically loads {@link InvocationOtelPlugin} when {@code DURABLE_EXECUTION_PLUGINS} contains + * {@code otel-invocation}. + * + * @deprecated This is a preview API that is experimental and may be changed or removed in future releases. + */ +@Deprecated +public final class InvocationOtelPluginProvider implements DurableExecutionPluginProvider { + + @Override + public String getName() { + return "otel-invocation"; + } + + @Override + public int getApiVersion() { + return API_VERSION; + } + + @Override + public Class getPluginType() { + return InvocationOtelPlugin.class; + } + + @Override + public DurableExecutionPlugin createPlugin() { + return new InvocationOtelPlugin(); + } +} diff --git a/otel-plugin/src/main/resources/META-INF/services/software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider b/otel-plugin/src/main/resources/META-INF/services/software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider new file mode 100644 index 000000000..120f52f48 --- /dev/null +++ b/otel-plugin/src/main/resources/META-INF/services/software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider @@ -0,0 +1,2 @@ +software.amazon.lambda.durable.otel.InvocationOtelPluginProvider +software.amazon.lambda.durable.otel.ExecutionOtelPluginProvider diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java index 605bbaff8..9f3c9d735 100644 --- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java +++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java @@ -15,6 +15,7 @@ import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; import java.time.Instant; +import java.util.ServiceLoader; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -156,6 +157,19 @@ void defaultConstructor_usesGlobalSdkTracerProviderDirectly() { assertTrue(spans.stream().anyMatch(span -> span.getName().equals("step"))); } + @Test + void executionOtelPluginProvider_isRegisteredAsServiceProvider() { + var provider = ServiceLoader.load(DurableExecutionPluginProvider.class).stream() + .filter(candidate -> candidate.type().equals(ExecutionOtelPluginProvider.class)) + .findFirst() + .orElseThrow() + .get(); + + assertEquals("otel-execution", provider.getName()); + assertEquals(DurableExecutionPluginProvider.API_VERSION, provider.getApiVersion()); + assertEquals(ExecutionOtelPlugin.class, provider.getPluginType()); + } + // ─── Workflow root span lifecycle ──────────────────────────────────── @Test diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java index 2c92bfcef..fed449c40 100644 --- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java +++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java @@ -198,6 +198,19 @@ void autoConfigurationCustomizerProvider_isRegisteredAsServiceProvider() { .anyMatch(provider -> provider.type().equals(OtelPluginAutoConfigurationCustomizerProvider.class))); } + @Test + void invocationOtelPluginProvider_isRegisteredAsServiceProvider() { + var provider = ServiceLoader.load(DurableExecutionPluginProvider.class).stream() + .filter(candidate -> candidate.type().equals(InvocationOtelPluginProvider.class)) + .findFirst() + .orElseThrow() + .get(); + + assertEquals("otel-invocation", provider.getName()); + assertEquals(DurableExecutionPluginProvider.API_VERSION, provider.getApiVersion()); + assertEquals(InvocationOtelPlugin.class, provider.getPluginType()); + } + @Test void invocationStart_usesCurrentSpanContext_whenExtractorReturnsNull() { var traceId = "5759e988bd862e3fe1be46a994272793"; diff --git a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java index ad61c8953..5bd794b9c 100644 --- a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java +++ b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/PluginIntegrationTest.java @@ -26,6 +26,29 @@ class PluginIntegrationTest { // ─── Invocation-level hooks ────────────────────────────────────────── + @Test + void pluginsFromConfigurationAndEnvironment_receiveLifecycleEvents() { + var configuredPlugin = new RecordingPlugin(); + var dynamicPlugin = new RecordingPlugin(); + var provider = new RecordingPluginProvider(dynamicPlugin); + var plugins = + DynamicPluginLoader.loadConfiguredPlugins("recording", List.of(provider), List.of(configuredPlugin)); + var config = DurableConfig.builder() + .withPlugins(plugins.toArray(DurableExecutionPlugin[]::new)) + .build(); + + var runner = LocalDurableTestRunner.create( + String.class, + (input, context) -> context.step("greet", String.class, stepCtx -> "Hello " + input), + config); + + var result = runner.runUntilComplete("World"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertPluginReceivedLifecycleEvents(configuredPlugin); + assertPluginReceivedLifecycleEvents(dynamicPlugin); + } + @Test void plugin_receivesInvocationStartAndEnd_onSuccessfulExecution() { var plugin = new RecordingPlugin(); @@ -784,6 +807,34 @@ public void onOperationChange(OperationChangeInfo info) { } } + private record RecordingPluginProvider(RecordingPlugin plugin) implements DurableExecutionPluginProvider { + @Override + public String getName() { + return "recording"; + } + + @Override + public int getApiVersion() { + return API_VERSION; + } + + @Override + public Class getPluginType() { + return RecordingPlugin.class; + } + + @Override + public DurableExecutionPlugin createPlugin() { + return plugin; + } + } + + private static void assertPluginReceivedLifecycleEvents(RecordingPlugin plugin) { + assertEquals(1, plugin.invocationStarts.size()); + assertTrue(plugin.operationStarts.stream().anyMatch(info -> "greet".equals(info.name()))); + assertEquals(1, plugin.invocationEnds.size()); + } + /** Plugin that throws on every hook to verify error isolation. */ private static class ThrowingPlugin implements DurableExecutionPlugin { @Override diff --git a/sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java b/sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java index ecf88f78f..ff28385fa 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java @@ -103,6 +103,7 @@ public final class DurableConfig { private final PluginRunner pluginRunner; private DurableConfig(Builder builder) { + var plugins = DynamicPluginLoader.loadConfiguredPlugins(builder.plugins); this.durableExecutionClient = Objects.requireNonNullElseGet( builder.durableExecutionClient, DurableConfig::createDefaultDurableExecutionClient); this.serDes = Objects.requireNonNullElseGet(builder.serDes, JacksonSerDes::new); @@ -113,7 +114,7 @@ private DurableConfig(Builder builder) { this.checkpointDelay = Objects.requireNonNullElseGet(builder.checkpointDelay, () -> Duration.ofSeconds(0)); this.deserializeAfterSerialization = builder.deserializeAfterSerialization; this.checkpointEmptyMap = builder.checkpointEmptyMap; - this.pluginRunner = builder.plugins.isEmpty() ? PluginRunner.noOp() : new PluginRunner(builder.plugins); + this.pluginRunner = plugins.isEmpty() ? PluginRunner.noOp() : new PluginRunner(plugins); validateConfiguration(); } @@ -216,7 +217,7 @@ public boolean shouldCheckpointEmptyMap() { /** * Gets the plugin runner that dispatches lifecycle events to registered plugins. * - *

Returns a no-op runner if no plugins were registered via the builder. + *

Returns a no-op runner if no plugins were registered via the builder or loaded dynamically. * * @return PluginRunner instance (never null) * @deprecated This is a preview API that is experimental and may be changed or removed in future releases. diff --git a/sdk/src/main/java/software/amazon/lambda/durable/DynamicPluginLoader.java b/sdk/src/main/java/software/amazon/lambda/durable/DynamicPluginLoader.java new file mode 100644 index 000000000..efd2e86f0 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/DynamicPluginLoader.java @@ -0,0 +1,183 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable; + +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.ServiceConfigurationError; +import java.util.ServiceLoader; +import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; +import software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider; + +final class DynamicPluginLoader { + static final String PLUGINS_ENVIRONMENT_VARIABLE = "DURABLE_EXECUTION_PLUGINS"; + + private DynamicPluginLoader() {} + + static List loadConfiguredPlugins(List explicitPlugins) { + var configuredNames = System.getenv(PLUGINS_ENVIRONMENT_VARIABLE); + if (configuredNames == null || configuredNames.isBlank()) { + return List.copyOf(explicitPlugins); + } + + var classLoader = Thread.currentThread().getContextClassLoader(); + if (classLoader == null) { + classLoader = DurableExecutionPluginProvider.class.getClassLoader(); + } + return loadConfiguredPlugins( + configuredNames, + ServiceLoader.load(DurableExecutionPluginProvider.class, classLoader), + explicitPlugins); + } + + static List loadConfiguredPlugins( + String configuredNames, + Iterable providers, + List explicitPlugins) { + if (configuredNames == null || configuredNames.isBlank()) { + return List.copyOf(explicitPlugins); + } + + var requestedNames = parseProviderNames(configuredNames); + var providersByName = indexProviders(providers); + var plugins = new ArrayList(); + for (var name : requestedNames) { + addPlugin(name, getProvider(name, providersByName), plugins); + } + plugins.addAll(explicitPlugins); + return List.copyOf(plugins); + } + + private static List parseProviderNames(String configuredNames) { + var names = new ArrayList(); + var uniqueNames = new LinkedHashSet(); + for (var configuredName : configuredNames.split(",", -1)) { + var name = configuredName.trim(); + if (name.isEmpty()) { + throw configurationError("Plugin provider names in " + PLUGINS_ENVIRONMENT_VARIABLE + + " must be non-empty comma-separated values"); + } + if (!uniqueNames.add(name)) { + throw configurationError( + "Plugin provider '" + name + "' is listed more than once in " + PLUGINS_ENVIRONMENT_VARIABLE); + } + names.add(name); + } + return names; + } + + private static Map indexProviders( + Iterable providers) { + var providersByName = new LinkedHashMap(); + try { + for (var provider : providers) { + if (provider == null) { + throw configurationError("ServiceLoader returned a null DurableExecutionPluginProvider"); + } + var name = getProviderName(provider); + var previous = providersByName.putIfAbsent(name, provider); + if (previous != null) { + throw configurationError("Multiple DurableExecutionPluginProvider implementations use the name '" + + name + "': " + previous.getClass().getName() + " and " + + provider.getClass().getName()); + } + } + } catch (ServiceConfigurationError | LinkageError e) { + throw configurationError( + "Failed to discover DurableExecutionPluginProvider implementations. " + + "Verify that plugin JARs and the Durable Execution SDK use compatible versions", + e); + } + return providersByName; + } + + private static String getProviderName(DurableExecutionPluginProvider provider) { + String name; + try { + name = provider.getName(); + } catch (RuntimeException e) { + throw configurationError( + "Plugin provider " + provider.getClass().getName() + " failed to return its name", e); + } + if (name == null || name.isBlank() || !name.equals(name.trim())) { + throw configurationError("Plugin provider " + provider.getClass().getName() + + " returned an invalid name; names must be non-empty and must not have surrounding spaces"); + } + return name; + } + + private static DurableExecutionPluginProvider getProvider( + String name, Map providersByName) { + var provider = providersByName.get(name); + if (provider == null) { + var available = providersByName.isEmpty() ? "none" : String.join(", ", providersByName.keySet()); + throw configurationError("No DurableExecutionPluginProvider named '" + name + + "' was found on the application class path. Available providers: " + available); + } + return provider; + } + + private static void addPlugin( + String name, DurableExecutionPluginProvider provider, List plugins) { + var pluginType = validateProvider(name, provider); + var plugin = createPlugin(name, provider); + if (!pluginType.isInstance(plugin)) { + throw configurationError("Plugin provider '" + name + "' declared type '" + pluginType.getName() + + "' but created '" + plugin.getClass().getName() + "'"); + } + plugins.add(plugin); + } + + private static Class validateProvider( + String name, DurableExecutionPluginProvider provider) { + int apiVersion; + Class pluginType; + try { + apiVersion = provider.getApiVersion(); + pluginType = provider.getPluginType(); + } catch (RuntimeException | LinkageError e) { + throw configurationError( + "Plugin provider '" + name + "' is not compatible with this Durable Execution SDK version", e); + } + if (apiVersion != DurableExecutionPluginProvider.API_VERSION) { + throw configurationError("Plugin provider '" + name + "' uses provider API version " + apiVersion + + ", but this SDK requires version " + DurableExecutionPluginProvider.API_VERSION); + } + if (pluginType == null + || pluginType.isInterface() + || Modifier.isAbstract(pluginType.getModifiers()) + || !DurableExecutionPlugin.class.isAssignableFrom(pluginType)) { + throw configurationError( + "Plugin provider '" + name + "' must declare a concrete DurableExecutionPlugin type"); + } + return pluginType; + } + + private static DurableExecutionPlugin createPlugin(String name, DurableExecutionPluginProvider provider) { + DurableExecutionPlugin plugin; + try { + plugin = provider.createPlugin(); + } catch (RuntimeException | LinkageError e) { + throw configurationError( + "Plugin provider '" + name + "' failed to create its plugin. " + + "Verify its settings and compatibility with this Durable Execution SDK version", + e); + } + if (plugin == null) { + throw configurationError("Plugin provider '" + name + "' returned a null plugin"); + } + return plugin; + } + + private static IllegalStateException configurationError(String message) { + return new IllegalStateException("Dynamic plugin configuration failed: " + message); + } + + private static IllegalStateException configurationError(String message, Throwable cause) { + return new IllegalStateException("Dynamic plugin configuration failed: " + message, cause); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/DurableExecutionPluginProvider.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/DurableExecutionPluginProvider.java new file mode 100644 index 000000000..1e4535db7 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/DurableExecutionPluginProvider.java @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.plugin; + +/** + * Service provider interface for dynamically loading {@link DurableExecutionPlugin} implementations. + * + *

Provider JARs register implementations in + * {@code META-INF/services/software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider}. The SDK only creates + * plugins from providers explicitly selected through {@code DURABLE_EXECUTION_PLUGINS}. + * + * @deprecated This is a preview API that is experimental and may be changed or removed in future releases. + */ +@Deprecated +public interface DurableExecutionPluginProvider { + + /** Current version of the dynamic plugin provider contract. */ + int API_VERSION = 1; + + /** + * Returns the stable name used to select this provider. + * + * @return non-empty provider name + */ + String getName(); + + /** + * Returns the provider API version this implementation supports. + * + * @return provider API version + */ + int getApiVersion(); + + /** + * Returns the concrete plugin type created by this provider. + * + * @return plugin implementation class + */ + Class getPluginType(); + + /** + * Creates the plugin instance. + * + * @return plugin instance + */ + DurableExecutionPlugin createPlugin(); +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/DynamicPluginLoaderTest.java b/sdk/src/test/java/software/amazon/lambda/durable/DynamicPluginLoaderTest.java new file mode 100644 index 000000000..17fe9f5b3 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/DynamicPluginLoaderTest.java @@ -0,0 +1,248 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; +import software.amazon.lambda.durable.plugin.DurableExecutionPluginProvider; + +class DynamicPluginLoaderTest { + + @Test + void unsetConfigurationPreservesExplicitPluginsWithoutDiscoveringProviders() { + var explicitPlugin = new FirstPlugin(); + Iterable providers = () -> { + throw new AssertionError("Providers should not be discovered"); + }; + + var plugins = DynamicPluginLoader.loadConfiguredPlugins(null, providers, List.of(explicitPlugin)); + + assertEquals(1, plugins.size()); + assertSame(explicitPlugin, plugins.get(0)); + } + + @Test + void loadsRequestedProvidersBeforeExplicitPluginsInConfiguredOrder() { + var creationOrder = new ArrayList(); + var explicitPlugin = new ExplicitPlugin(); + var firstProvider = provider("first", FirstPlugin.class, () -> { + creationOrder.add("first"); + return new FirstPlugin(); + }); + var secondProvider = provider("second", SecondPlugin.class, () -> { + creationOrder.add("second"); + return new SecondPlugin(); + }); + + var plugins = DynamicPluginLoader.loadConfiguredPlugins( + " second, first ", List.of(firstProvider, secondProvider), List.of(explicitPlugin)); + + assertInstanceOf(SecondPlugin.class, plugins.get(0)); + assertInstanceOf(FirstPlugin.class, plugins.get(1)); + assertSame(explicitPlugin, plugins.get(2)); + assertEquals(List.of("second", "first"), creationOrder); + } + + @Test + void doesNotCreateProvidersOutsideTheAllowList() { + var unrequestedCreations = new AtomicInteger(); + var requestedProvider = provider("requested", FirstPlugin.class, FirstPlugin::new); + var unrequestedProvider = provider("unrequested", SecondPlugin.class, () -> { + unrequestedCreations.incrementAndGet(); + return new SecondPlugin(); + }); + + var plugins = DynamicPluginLoader.loadConfiguredPlugins( + "requested", List.of(requestedProvider, unrequestedProvider), List.of()); + + assertEquals(1, plugins.size()); + assertEquals(0, unrequestedCreations.get()); + } + + @Test + void loadsExplicitAndDynamicPluginsOfTheSameType() { + var creations = new AtomicInteger(); + var explicitPlugin = new FirstPlugin(); + var dynamicPlugin = new FirstPlugin(); + var duplicateProvider = provider("first", FirstPlugin.class, () -> { + creations.incrementAndGet(); + return dynamicPlugin; + }); + + var plugins = + DynamicPluginLoader.loadConfiguredPlugins("first", List.of(duplicateProvider), List.of(explicitPlugin)); + + assertEquals(2, plugins.size()); + assertSame(dynamicPlugin, plugins.get(0)); + assertSame(explicitPlugin, plugins.get(1)); + assertEquals(1, creations.get()); + } + + @Test + void rejectsEmptyConfiguredProviderName() { + var error = assertThrows( + IllegalStateException.class, + () -> DynamicPluginLoader.loadConfiguredPlugins("first,,second", List.of(), List.of())); + + assertTrue(error.getMessage().contains("must be non-empty")); + assertTrue(error.getMessage().contains(DynamicPluginLoader.PLUGINS_ENVIRONMENT_VARIABLE)); + } + + @Test + void rejectsDuplicateConfiguredProviderName() { + var error = assertThrows( + IllegalStateException.class, + () -> DynamicPluginLoader.loadConfiguredPlugins("first,first", List.of(), List.of())); + + assertTrue(error.getMessage().contains("listed more than once")); + } + + @Test + void rejectsUnknownProviderAndListsAvailableNames() { + var availableProvider = provider("available", FirstPlugin.class, FirstPlugin::new); + + var error = assertThrows( + IllegalStateException.class, + () -> DynamicPluginLoader.loadConfiguredPlugins("missing", List.of(availableProvider), List.of())); + + assertTrue(error.getMessage().contains("No DurableExecutionPluginProvider named 'missing'")); + assertTrue(error.getMessage().contains("available")); + } + + @Test + void rejectsDuplicateDiscoveredProviderNames() { + var firstProvider = provider("duplicate", FirstPlugin.class, FirstPlugin::new); + var secondProvider = provider("duplicate", SecondPlugin.class, SecondPlugin::new); + + var error = assertThrows( + IllegalStateException.class, + () -> DynamicPluginLoader.loadConfiguredPlugins( + "duplicate", List.of(firstProvider, secondProvider), List.of())); + + assertTrue(error.getMessage().contains("Multiple DurableExecutionPluginProvider implementations")); + assertTrue(error.getMessage().contains("duplicate")); + } + + @Test + void rejectsIncompatibleProviderApiVersion() { + var provider = new TestProvider("first", 2, FirstPlugin.class, FirstPlugin::new); + + var error = assertThrows( + IllegalStateException.class, + () -> DynamicPluginLoader.loadConfiguredPlugins("first", List.of(provider), List.of())); + + assertTrue(error.getMessage().contains("uses provider API version 2")); + assertTrue(error.getMessage().contains("requires version " + DurableExecutionPluginProvider.API_VERSION)); + } + + @Test + void rejectsInvalidDeclaredPluginType() { + var provider = provider("invalid", DurableExecutionPlugin.class, FirstPlugin::new); + + var error = assertThrows( + IllegalStateException.class, + () -> DynamicPluginLoader.loadConfiguredPlugins("invalid", List.of(provider), List.of())); + + assertTrue(error.getMessage().contains("must declare a concrete DurableExecutionPlugin type")); + } + + @Test + void rejectsPluginThatDoesNotMatchDeclaredType() { + var provider = provider("first", FirstPlugin.class, SecondPlugin::new); + + var error = assertThrows( + IllegalStateException.class, + () -> DynamicPluginLoader.loadConfiguredPlugins("first", List.of(provider), List.of())); + + assertTrue(error.getMessage().contains("declared type")); + assertTrue(error.getMessage().contains(SecondPlugin.class.getName())); + } + + @Test + void wrapsProviderDiscoveryFailure() { + Iterable providers = () -> new Iterator<>() { + @Override + public boolean hasNext() { + throw new LinkageError("incompatible"); + } + + @Override + public DurableExecutionPluginProvider next() { + throw new AssertionError("next should not be called"); + } + }; + + var error = assertThrows( + IllegalStateException.class, + () -> DynamicPluginLoader.loadConfiguredPlugins("first", providers, List.of())); + + assertTrue(error.getMessage().contains("Failed to discover")); + assertInstanceOf(LinkageError.class, error.getCause()); + } + + @Test + void wrapsPluginCreationFailure() { + var provider = provider("first", FirstPlugin.class, () -> { + throw new IllegalArgumentException("bad settings"); + }); + + var error = assertThrows( + IllegalStateException.class, + () -> DynamicPluginLoader.loadConfiguredPlugins("first", List.of(provider), List.of())); + + assertTrue(error.getMessage().contains("failed to create its plugin")); + assertInstanceOf(IllegalArgumentException.class, error.getCause()); + } + + private static TestProvider provider( + String name, + Class pluginType, + Supplier pluginSupplier) { + return new TestProvider(name, DurableExecutionPluginProvider.API_VERSION, pluginType, pluginSupplier); + } + + private record TestProvider( + String name, + int apiVersion, + Class pluginType, + Supplier pluginSupplier) + implements DurableExecutionPluginProvider { + + @Override + public String getName() { + return name; + } + + @Override + public int getApiVersion() { + return apiVersion; + } + + @Override + public Class getPluginType() { + return pluginType; + } + + @Override + public DurableExecutionPlugin createPlugin() { + return pluginSupplier.get(); + } + } + + private static final class ExplicitPlugin implements DurableExecutionPlugin {} + + private static final class FirstPlugin implements DurableExecutionPlugin {} + + private static final class SecondPlugin implements DurableExecutionPlugin {} +}