diff --git a/README.md b/README.md index 1a6e57f..b3227c7 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for Java](https:/ | ✅ | Logging | The provider logs through the logging configuration of the `LDConfig` it is given. | | ✅ | Domains | Domains bind clients to providers in the OpenFeature SDK; a separate provider instance may be registered per domain. | | ✅ | Eventing | LaunchDarkly data source status changes are emitted as `PROVIDER_READY`, `PROVIDER_STALE`, and `PROVIDER_ERROR`. Flag changes are emitted as `PROVIDER_CONFIGURATION_CHANGED` with the changed flag key. | -| ⚠️ | Initialization | `initialize` reports whether the LaunchDarkly client became ready, and a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. It has no timeout of its own and waits until the data source becomes valid or permanently fails: [#58](https://github.com/launchdarkly/openfeature-java-server/issues/58). | +| ✅ | Initialization | `initialize` reports whether the LaunchDarkly client became ready, and a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. `Provider(String, LDConfig, Duration)` bounds initialization with a start wait duration; the other constructors wait until the data source becomes valid or permanently fails. | | ✅ | Shutdown | `shutdown` closes the LaunchDarkly client. A closed client cannot be restarted, so a new provider instance is required afterward. | | ✅ | Transaction Context Propagation | Provided by the OpenFeature SDK, which merges the transaction context into the evaluation context before the provider is called; no provider support is required. | | ✅ | Extending | This provider is itself an extension of the OpenFeature SDK. The underlying LaunchDarkly client is available through `getLdClient()` for functionality with no OpenFeature equivalent. | @@ -107,10 +107,14 @@ There are several other attributes which have special functionality within a sin ### Initialization and Shutdown -The LaunchDarkly supports Initialization and Shutdown using the OpenFeature API. The provider begins initialization as soon as it is constructed, and the underlying LaunchDarkly SDK will block execution based on the configured start wait time. If you wish to defer the blocking behavior, then you can use the `startWait` function when building the `LDConfig`. +The LaunchDarkly provider supports Initialization and Shutdown using the OpenFeature API. Initialization starts as soon as the provider is constructed: the underlying LaunchDarkly SDK is created in the provider's constructor, and it blocks there for up to its configured start wait time. -OpenFeature will report when the provider is ready, and additionally the `setProviderAndWait` function of the OpenFeature -API can be used to wait until the provider is ready, or it has encountered a permanent error. +The `Provider(String, LDConfig, Duration)` constructor sets that start wait and bounds the whole of initialization with it. The provider's `initialize` does not wait a second time; it reports the outcome of that single wait, and fails if the client did not become ready in time. A non-zero duration is strongly recommended. A zero duration means the provider applies no deadline at all: the constructor does not block, and `initialize` waits until the data source becomes valid or fails permanently, which may be indefinitely if neither happens. The other constructors leave the start wait of the given `LDConfig` untouched and also wait indefinitely. + +How initialization surfaces depends on how the provider is registered with the OpenFeature API: + +- `setProviderAndWait` runs initialization on the calling thread, so the call blocks until the provider is ready or has permanently failed, and throws if it failed. With a zero duration this call can block indefinitely. +- `setProvider` runs initialization on a background thread and returns immediately. A failure is reported as a `PROVIDER_ERROR` event rather than thrown, and evaluations made before the provider is ready return their default value with the `PROVIDER_NOT_READY` error code. It the provider has been shutdown, because the OpenFeature API has been shutdown, or because the provider was no longer in use by the OpenFeature API, then the underlying LaunchDarkly SDK will be closed. This is an important consideration if you are using the `getLdClient` method of the provider to access the underlying SDK instance. diff --git a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java index eadbdc2..2c9b258 100644 --- a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java +++ b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java @@ -11,6 +11,7 @@ import dev.openfeature.sdk.*; import java.io.IOException; +import java.time.Duration; import java.util.Collections; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; @@ -46,6 +47,7 @@ public String getName() { private final EvaluationContextConverter evaluationContextConverter; private final LDClientInterface client; + private final Duration startWait; private ProviderState state = ProviderState.NOT_READY; @@ -57,28 +59,65 @@ public String getName() { * Create a provider with the specified SDK and default configuration. *

* If you need to specify any configuration use {@link Provider#Provider(String, LDConfig)} instead. + * Initialization waits indefinitely; using {@link Provider#Provider(String, LDConfig, Duration)} with a non-zero + * duration is strongly recommended. * * @param sdkKey the SDK key for your LaunchDarkly environment */ public Provider(String sdkKey) { - this(sdkKey, new LDConfig.Builder().build()); + this(new LDClient(sdkKey, withWrapper(new LDConfig.Builder().build())), Duration.ZERO); } /** - * Crate a provider with the specified SDK key and configuration. + * Create a provider with the specified SDK key and configuration. + * Initialization waits indefinitely; using {@link Provider#Provider(String, LDConfig, Duration)} with a non-zero + * duration is strongly recommended. * * @param sdkKey the SDK key for your LaunchDarkly environment - * @param config a client configuration object + * @param config a client configuration object; its start wait setting is preserved, and provider initialization + * waits indefinitely */ public Provider(String sdkKey, LDConfig config) { - this(new LDClient(sdkKey, LDConfig.Builder.fromConfig(config) + this(new LDClient(sdkKey, withWrapper(config)), Duration.ZERO); + } + + /** + * Create a provider with the specified SDK key, configuration, and start wait duration. + *

+ * The duration is applied to the LaunchDarkly SDK as its start wait, and it bounds the whole of initialization: + * the constructor blocks for up to that long, and initialization then reports that outcome rather than waiting + * again. A non-zero duration is strongly recommended. A duration of zero applies no deadline: the constructor does + * not block, and initialization waits until the LaunchDarkly data source becomes valid or fails permanently, which + * may be indefinitely. + *

+ * When the provider is registered with {@code setProviderAndWait}, initialization runs on the calling thread, so + * that call blocks for up to the duration and throws if the client did not become ready. When it is registered + * with {@code setProvider}, initialization runs in the background and a failure is reported as a + * {@code PROVIDER_ERROR} event instead, with evaluations before readiness returning their default value. + * + * @param sdkKey the SDK key for your LaunchDarkly environment + * @param config a client configuration object + * @param startWait the maximum duration to wait for initialization; zero applies no deadline + */ + public Provider(String sdkKey, LDConfig config, Duration startWait) { + this(new LDClient(sdkKey, + withWrapper(LDConfig.Builder.fromConfig(config).startWait(startWait).build())), startWait); + } + + private static LDConfig withWrapper(LDConfig config) { + return LDConfig.Builder.fromConfig(config) .wrapper(Components.wrapperInfo() .wrapperName("open-feature-java-server") - .wrapperVersion(Version.SDK_VERSION)).build())); + .wrapperVersion(Version.SDK_VERSION)).build(); } Provider(LDClientInterface client) { + this(client, Duration.ZERO); + } + + Provider(LDClientInterface client, Duration startWait) { this.client = client; + this.startWait = startWait; logger = client.getLogger(); evaluationContextConverter = new EvaluationContextConverter(logger); evaluationDetailConverter = new EvaluationDetailConverter(logger); @@ -174,12 +213,19 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { boolean successfullyInitialized; try { handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus(), completer); + + // With a start wait the client constructor has already waited, so the data source has either become valid, + // failed permanently, or run out of time; the outcome is whatever it is now. + if (!startWait.isZero() && !completer.isDone()) { + setState(ProviderState.ERROR); + throw new RuntimeException("The client did not initialize within the start wait duration."); + } successfullyInitialized = completer.get(); } finally { setInitializing(false); } - if(!successfullyInitialized) { + if (!successfullyInitialized) { throw new RuntimeException("Failed to initialize LaunchDarkly client."); } } diff --git a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java index 929b204..9a8133c 100644 --- a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java +++ b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java @@ -32,6 +32,8 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; class DelayedDataSource implements DataSource { @@ -159,6 +161,26 @@ public DataSource build(ClientContext clientContext) { } } +class NeverReadyDataSource implements DataSource { + public Future start() { + return new CompletableFuture<>(); + } + + public boolean isInitialized() { + return false; + } + + public void close() throws IOException { + } +} + +class NeverReadyDataSourceFactory implements ComponentConfigurer { + @Override + public DataSource build(ClientContext clientContext) { + return new NeverReadyDataSource(); + } +} + /** * Tests in this suite use a real client instance and the public constructor. *

@@ -200,6 +222,19 @@ public void canShutdownAnOfflineClient() { }); } + @Test + public void twoArgumentConstructorPreservesConfigStartWait() { + assertTimeoutPreemptively(Duration.ofSeconds(1), () -> { + var config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataSource(new NeverReadyDataSourceFactory()) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config); + provider.shutdown(); + }); + } + @Test public void itEmitsReadyEvents() throws ExecutionException, InterruptedException, TimeoutException { var provider = new Provider("fake-key", new LDConfig.Builder() @@ -288,6 +323,77 @@ public void itCanHandleClientThatIsNotInitializedImmediatelyAndErrors() throws E assertTrue(gotErrorEvent.get(1000, TimeUnit.MILLISECONDS)); } + @Test + public void initializationFailsWithoutWaitingAgainWhenStartWaitIsPositive() { + assertTimeoutPreemptively(Duration.ofSeconds(1), () -> { + var config = new LDConfig.Builder() + .dataSource(new NeverReadyDataSourceFactory()) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config, Duration.ofMillis(300)); + try { + // The constructor consumed the start wait, so initialization must not wait a second time. + assertTimeoutPreemptively(Duration.ofMillis(100), () -> { + var error = assertThrows(RuntimeException.class, + () -> provider.initialize(new ImmutableContext("context-key"))); + assertTrue(error.getMessage() + .contains("The client did not initialize within the start wait duration.")); + }); + assertEquals(ProviderState.ERROR, provider.getState()); + } finally { + provider.shutdown(); + } + }); + } + + @Test + public void initializationSucceedsWhenTheClientBecomesReadyDuringTheStartWait() { + assertTimeoutPreemptively(Duration.ofSeconds(1), () -> { + var config = new LDConfig.Builder() + .dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), false)) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config, Duration.ofMillis(500)); + try { + assertDoesNotThrow(() -> provider.initialize(new ImmutableContext("context-key"))); + assertEquals(ProviderState.READY, provider.getState()); + } finally { + provider.shutdown(); + } + }); + } + + @Test + public void initializationReportsPermanentFailureAfterAPositiveStartWait() { + assertTimeoutPreemptively(Duration.ofSeconds(1), () -> { + var config = new LDConfig.Builder() + .dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), true)) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config, Duration.ofMillis(500)); + try { + var error = assertThrows(RuntimeException.class, + () -> provider.initialize(new ImmutableContext("context-key"))); + assertEquals("Failed to initialize LaunchDarkly client.", error.getMessage()); + } finally { + provider.shutdown(); + } + }); + } + + @Test + public void initializationWaitsIndefinitelyWhenStartWaitIsZero() throws Exception { + var config = new LDConfig.Builder() + .dataSource(new DelayedDataSourceFactory(Duration.ofMillis(200), false)) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config, Duration.ZERO); + + assertDoesNotThrow(() -> provider.initialize(new ImmutableContext("context-key"))); + assertEquals(ProviderState.READY, provider.getState()); + provider.shutdown(); + } + @Test public void itEmitsReadyWhenTheDataSourceRecoversFromAFailedInitialization() throws Exception { var dataSourceFactory = new ControllableDataSourceFactory();