diff --git a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java index b54d895..eadbdc2 100644 --- a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java +++ b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java @@ -51,6 +51,8 @@ public String getName() { private final Object stateLock = new Object(); + private boolean initializing = false; + /** * Create a provider with the specified SDK and default configuration. *

@@ -152,6 +154,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { setState(ProviderState.READY); } + setInitializing(true); var completer = new CompletableFuture(); client.getFlagTracker().addFlagChangeListener(detail -> { @@ -164,11 +167,17 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { }); if (getState() == ProviderState.READY) { + setInitializing(false); return; } - handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus(), completer); - var successfullyInitialized = completer.get(); + boolean successfullyInitialized; + try { + handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus(), completer); + successfullyInitialized = completer.get(); + } finally { + setInitializing(false); + } if(!successfullyInitialized) { throw new RuntimeException("Failed to initialize LaunchDarkly client."); @@ -189,19 +198,24 @@ private void handleDataSourceStatus(DataSourceStatusProvider.Status res, Complet } break; case VALID: { + boolean becameReady = false; boolean emit = false; synchronized (stateLock) { // If we are ready, then we don't want to emit it again. Other conditions we may be updating the // reason we are stale or interrupted, so we want to emit an event each time. if (state != ProviderState.READY) { - emit = true; - setState(ProviderState.READY); + becameReady = true; + // The OpenFeature SDK emits its own ready event when initialization succeeds. + emit = !initializing; + state = ProviderState.READY; } } - if (emit) { + if (becameReady) { completer.complete(true); - emitProviderReady(ProviderEventDetails.builder().build()); + if (emit) { + emitProviderReady(ProviderEventDetails.builder().build()); + } } } break; @@ -218,6 +232,12 @@ private void handleDataSourceStatus(DataSourceStatusProvider.Status res, Complet } } + private void setInitializing(boolean initializing) { + synchronized (stateLock) { + this.initializing = initializing; + } + } + private void setState(ProviderState state) { synchronized (stateLock) { this.state = state; diff --git a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java index fafd372..929b204 100644 --- a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java +++ b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java @@ -104,6 +104,29 @@ public void close() throws IOException { } } +class ControllableDataSource implements DataSource { + public Future start() { + return new CompletableFuture<>(); + } + + public boolean isInitialized() { + return false; + } + + public void close() throws IOException { + } +} + +class ControllableDataSourceFactory implements ComponentConfigurer { + final CompletableFuture sink = new CompletableFuture<>(); + + @Override + public DataSource build(ClientContext clientContext) { + sink.complete(clientContext.getDataSourceUpdateSink()); + return new ControllableDataSource(); + } +} + class DelayedDataSourceFactory implements ComponentConfigurer { private Duration startDelay; private boolean willError; @@ -219,14 +242,19 @@ public void itCanHandleClientThatIsNotInitializedImmediately() throws Exception assertEquals(ProviderState.NOT_READY, provider.getState()); var readyCount = new AtomicInteger(); + CompletableFuture gotReadyEvent = new CompletableFuture<>(); OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_READY, (detail) -> { readyCount.getAndIncrement(); + gotReadyEvent.complete(true); }); OpenFeatureAPI.getInstance().setProviderAndWait(provider); assertEquals(ProviderState.READY, provider.getState()); + assertTrue(gotReadyEvent.get(1000, TimeUnit.MILLISECONDS)); + + Thread.sleep(100); assertEquals(1, readyCount.get()); } @@ -260,6 +288,54 @@ public void itCanHandleClientThatIsNotInitializedImmediatelyAndErrors() throws E assertTrue(gotErrorEvent.get(1000, TimeUnit.MILLISECONDS)); } + @Test + public void itEmitsReadyWhenTheDataSourceRecoversFromAFailedInitialization() throws Exception { + var dataSourceFactory = new ControllableDataSourceFactory(); + var config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataSource(dataSourceFactory) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config); + var sink = dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS); + + var readyCount = new AtomicInteger(); + CompletableFuture gotReadyEvent = new CompletableFuture<>(); + CompletableFuture gotErrorEvent = new CompletableFuture<>(); + + OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_READY, (detail) -> { + readyCount.getAndIncrement(); + gotReadyEvent.complete(true); + }); + + OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_ERROR, (detail) -> { + gotErrorEvent.complete(true); + }); + + sink.updateStatus(DataSourceStatusProvider.State.OFF, new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, + 404, + "bad", + LocalDateTime.now().toInstant(ZoneOffset.UTC))); + + GeneralError initializationError = null; + try { + OpenFeatureAPI.getInstance().setProviderAndWait(provider); + } catch (GeneralError e) { + initializationError = e; + } + + assertNotNull(initializationError); + assertTrue(gotErrorEvent.get(1000, TimeUnit.MILLISECONDS)); + + sink.updateStatus(DataSourceStatusProvider.State.VALID, null); + + assertTrue(gotReadyEvent.get(1000, TimeUnit.MILLISECONDS)); + + Thread.sleep(100); + assertEquals(1, readyCount.get()); + } + @Test public void itIncludesTheDataSourceErrorInErrorEvents() throws Exception { var config = new LDConfig.Builder()