diff --git a/README.md b/README.md index dc10c94..032d773 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ 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 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 `Provider(String, LDConfig, Duration)` constructor bounds the whole of initialization with that duration: the constructor blocks for up to that long, and `initialize` then completes immediately with whatever the outcome was, failing if the client did not become ready in time. A zero duration means the provider waits indefinitely instead. The other constructors leave the config's start wait untouched, and the provider waits indefinitely for the data source to become valid or permanently fail. 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. diff --git a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java index 3031d61..50a2fc8 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; @@ -55,28 +57,56 @@ 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; use {@link Provider#Provider(String, LDConfig, Duration)} when a bounded wait + * is wanted. * * @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; use {@link Provider#Provider(String, LDConfig, Duration)} when a bounded wait + * is wanted. * * @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 bounds the + * whole of initialization: the constructor blocks for up to that long, and initialization then completes with + * whatever the outcome was, rather than waiting again. + * + * @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 means wait indefinitely + */ + 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); @@ -168,9 +198,15 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { } handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus(), completer); - var successfullyInitialized = completer.get(); - if(!successfullyInitialized) { + // 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."); + } + + if (!completer.get()) { 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 5f4304f..b935af7 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 { @@ -98,6 +100,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. *

@@ -139,6 +161,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() @@ -221,4 +256,75 @@ 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(); + } }