Skip to content

Commit cf0ed18

Browse files
authored
fix: Improve client initialization handling. (#27)
1 parent 16b328c commit cf0ed18

File tree

2 files changed

+204
-34
lines changed

2 files changed

+204
-34
lines changed

src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
import dev.openfeature.sdk.*;
1212

1313
import java.io.IOException;
14-
import java.time.temporal.ChronoUnit;
1514
import java.util.Collections;
15+
import java.util.concurrent.CompletableFuture;
16+
import java.util.concurrent.Future;
1617

1718
/**
1819
* An OpenFeature {@link FeatureProvider} which enables the use of the LaunchDarkly Server-Side SDK for Java
@@ -48,6 +49,8 @@ public String getName() {
4849

4950
private ProviderState state = ProviderState.NOT_READY;
5051

52+
private final Object stateLock = new Object();
53+
5154
/**
5255
* Create a provider with the specified SDK and default configuration.
5356
* <p>
@@ -128,7 +131,9 @@ public ProviderEvaluation<Value> getObjectEvaluation(String key, Value defaultVa
128131

129132
@Override
130133
public ProviderState getState() {
131-
return state;
134+
synchronized (state) {
135+
return state;
136+
}
132137
}
133138

134139
@Override
@@ -139,50 +144,72 @@ public void initialize(EvaluationContext evaluationContext) throws Exception {
139144
state = ProviderState.READY;
140145
}
141146

147+
var completer = new CompletableFuture<Boolean>();
148+
142149
client.getFlagTracker().addFlagChangeListener(detail -> {
143150
emitProviderConfigurationChanged(
144151
ProviderEventDetails.builder().flagsChanged(Collections.singletonList(detail.getKey())).build());
145152
});
146153
// Listen for future status changes.
147154
client.getDataSourceStatusProvider().addStatusListener((res) -> {
148-
switch (res.getState()) {
149-
// We will not re-enter INITIALIZING, but it is here to make the switch exhaustive.
150-
case INITIALIZING: {
151-
}
152-
break;
153-
case INTERRUPTED: {
154-
state = ProviderState.STALE;
155-
var message = res.getLastError() != null ? res.getLastError().getMessage() : "encountered an unknown error";
156-
emitProviderStale(ProviderEventDetails.builder().message(message).build());
157-
}
158-
break;
159-
case VALID: {
155+
handleDataSourceStatus(res, completer);
156+
});
157+
158+
if(state == ProviderState.READY) {
159+
return;
160+
}
161+
162+
handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus(), completer);
163+
var successfullyInitialized = completer.get();
164+
165+
if(!successfullyInitialized) {
166+
throw new RuntimeException("Failed to initialize LaunchDarkly client.");
167+
}
168+
}
169+
170+
private void handleDataSourceStatus(DataSourceStatusProvider.Status res, CompletableFuture<Boolean> completer) {
171+
switch (res.getState()) {
172+
// We will not re-enter INITIALIZING, but it is here to make the switch exhaustive.
173+
case INITIALIZING: {
174+
}
175+
break;
176+
case INTERRUPTED: {
177+
setState(ProviderState.STALE);
178+
179+
var message = res.getLastError() != null ? res.getLastError().getMessage() : "encountered an unknown error";
180+
emitProviderStale(ProviderEventDetails.builder().message(message).build());
181+
}
182+
break;
183+
case VALID: {
184+
boolean emit = false;
185+
synchronized (stateLock) {
160186
// If we are ready, then we don't want to emit it again. Other conditions we may be updating the
161187
// reason we are stale or interrupted, so we want to emit an event each time.
162188
if (state != ProviderState.READY) {
163-
state = ProviderState.READY;
164-
emitProviderReady(ProviderEventDetails.builder().build());
189+
emit = true;
190+
setState(ProviderState.READY);
165191
}
166192
}
167-
break;
168-
case OFF: {
169-
// Currently there is not a shutdown state.
170-
// Our client/provider cannot be restarted, so we just go to error.
171-
state = ProviderState.ERROR;
172-
emitProviderError(ProviderEventDetails.builder().message("Provider shutdown").build());
193+
194+
if (emit) {
195+
completer.complete(true);
196+
emitProviderReady(ProviderEventDetails.builder().build());
173197
}
174198
}
175-
});
176-
if (state == ProviderState.READY) {
177-
return;
199+
break;
200+
case OFF: {
201+
// Currently there is not a shutdown state.
202+
// Our client/provider cannot be restarted, so we just go to error.
203+
setState(ProviderState.ERROR);
204+
completer.complete(false);
205+
emitProviderError(ProviderEventDetails.builder().message("Provider shutdown").build());
206+
}
178207
}
208+
}
179209

180-
boolean initialized = client.getDataSourceStatusProvider().waitFor(DataSourceStatusProvider.State.VALID,
181-
ChronoUnit.FOREVER.getDuration());
182-
183-
if (!initialized) {
184-
// Here we throw an exception for the OpenFeature SDK, which will handle emitting an event.
185-
throw new RuntimeException("Failed to initialize LaunchDarkly client.");
210+
private void setState(ProviderState state) {
211+
synchronized (stateLock) {
212+
this.state = state;
186213
}
187214
}
188215

src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,101 @@
11
package com.launchdarkly.openfeature.serverprovider;
22

3+
import com.launchdarkly.sdk.server.Components;
34
import com.launchdarkly.sdk.server.LDConfig;
45
import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider;
6+
import com.launchdarkly.sdk.server.subsystems.ClientContext;
7+
import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer;
8+
import com.launchdarkly.sdk.server.subsystems.DataSource;
9+
import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink;
510
import dev.openfeature.sdk.ImmutableContext;
611
import dev.openfeature.sdk.OpenFeatureAPI;
712
import dev.openfeature.sdk.ProviderEvent;
813
import dev.openfeature.sdk.ProviderState;
14+
import dev.openfeature.sdk.exceptions.GeneralError;
915
import org.junit.jupiter.api.Test;
1016

17+
import java.io.IOException;
18+
import java.time.Duration;
19+
import java.time.LocalDateTime;
20+
import java.time.ZoneOffset;
21+
import java.time.temporal.ChronoUnit;
22+
import java.util.Timer;
23+
import java.util.TimerTask;
24+
import java.util.concurrent.CompletableFuture;
25+
import java.util.concurrent.ExecutionException;
26+
import java.util.concurrent.Future;
27+
import java.util.concurrent.TimeUnit;
28+
import java.util.concurrent.TimeoutException;
1129
import java.util.concurrent.atomic.AtomicInteger;
1230

1331
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
1432
import static org.junit.jupiter.api.Assertions.assertEquals;
33+
import static org.junit.jupiter.api.Assertions.assertNotNull;
34+
import static org.junit.jupiter.api.Assertions.assertTrue;
35+
36+
class DelayedDataSource implements DataSource {
37+
private Duration startDelay;
38+
private boolean willError;
39+
private boolean initialized = false;
40+
private Object lock = new Object();
41+
DataSourceUpdateSink sink;
42+
43+
DelayedDataSource(Duration delay, boolean error, DataSourceUpdateSink sink) {
44+
startDelay = delay;
45+
willError = error;
46+
this.sink = sink;
47+
}
48+
49+
public Future<Void> start() {
50+
var future = new CompletableFuture<Void>();
51+
var timer = new Timer();
52+
timer.schedule(new TimerTask() {
53+
@Override
54+
public void run() {
55+
if (!willError) {
56+
sink.updateStatus(DataSourceStatusProvider.State.VALID, null);
57+
synchronized (lock) {
58+
initialized = true;
59+
}
60+
} else {
61+
sink.updateStatus(DataSourceStatusProvider.State.OFF,
62+
new DataSourceStatusProvider.ErrorInfo(
63+
DataSourceStatusProvider.ErrorKind.NETWORK_ERROR,
64+
404,
65+
"bad",
66+
LocalDateTime.now().toInstant(ZoneOffset.UTC)));
67+
}
68+
future.complete(null);
69+
}
70+
}, startDelay.toMillis());
71+
72+
return future;
73+
}
74+
75+
public boolean isInitialized() {
76+
synchronized (lock) {
77+
return initialized;
78+
}
79+
}
80+
81+
public void close() throws IOException {
82+
}
83+
}
84+
85+
class DelayedDataSourceFactory implements ComponentConfigurer<DataSource> {
86+
private Duration startDelay;
87+
private boolean willError;
88+
89+
DelayedDataSourceFactory(Duration delay, boolean error) {
90+
startDelay = delay;
91+
willError = error;
92+
}
93+
94+
@Override
95+
public DataSource build(ClientContext clientContext) {
96+
return new DelayedDataSource(startDelay, willError, clientContext.getDataSourceUpdateSink());
97+
}
98+
}
1599

16100
/**
17101
* Tests in this suite use a real client instance and the public constructor.
@@ -54,16 +138,18 @@ public void canShutdownAnOfflineClient() {
54138
}
55139

56140
@Test
57-
public void itEmitsReadyEvents() {
141+
public void itEmitsReadyEvents() throws ExecutionException, InterruptedException, TimeoutException {
58142
var provider = new Provider("fake-key", new LDConfig.Builder()
59143
.offline(true).build());
60144

61145
var readyCount = new AtomicInteger();
62146
var errorCount = new AtomicInteger();
63147
var staleCount = new AtomicInteger();
148+
CompletableFuture<Boolean> gotReadyEvent = new CompletableFuture<>();
64149

65150
OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_READY, (detail) -> {
66151
readyCount.getAndIncrement();
152+
gotReadyEvent.complete(true);
67153
});
68154

69155
OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_STALE, (detail) -> {
@@ -76,10 +162,67 @@ public void itEmitsReadyEvents() {
76162

77163
OpenFeatureAPI.getInstance().setProviderAndWait(provider);
78164

79-
OpenFeatureAPI.getInstance().shutdown();
80-
165+
assertTrue(gotReadyEvent.get(1000, TimeUnit.MILLISECONDS));
81166
assertEquals(1, readyCount.get());
82167
assertEquals(0, staleCount.get());
83168
assertEquals(0, errorCount.get());
169+
170+
OpenFeatureAPI.getInstance().shutdown();
171+
}
172+
173+
@Test
174+
public void itCanHandleClientThatIsNotInitializedImmediately() throws Exception {
175+
var config = new LDConfig.Builder()
176+
.startWait(Duration.ZERO)
177+
.dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), false))
178+
.events(Components.noEvents())
179+
.build();
180+
var provider = new Provider("fake-key", config);
181+
assertEquals(ProviderState.NOT_READY, provider.getState());
182+
183+
var readyCount = new AtomicInteger();
184+
185+
OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_READY, (detail) -> {
186+
readyCount.getAndIncrement();
187+
});
188+
189+
OpenFeatureAPI.getInstance().setProviderAndWait(provider);
190+
191+
OpenFeatureAPI.getInstance().shutdown();
192+
193+
assertEquals(ProviderState.READY, provider.getState());
194+
assertEquals(1, readyCount.get());
195+
}
196+
197+
@Test
198+
public void itCanHandleClientThatIsNotInitializedImmediatelyAndErrors() throws Exception {
199+
var config = new LDConfig.Builder()
200+
.startWait(Duration.ZERO)
201+
.dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), true))
202+
.events(Components.noEvents())
203+
.build();
204+
var provider = new Provider("fake-key", config);
205+
assertEquals(ProviderState.NOT_READY, provider.getState());
206+
207+
CompletableFuture<Boolean> gotErrorEvent = new CompletableFuture<>();
208+
209+
OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_ERROR, (detail) -> {
210+
gotErrorEvent.complete(true);
211+
});
212+
213+
GeneralError error = null;
214+
try {
215+
OpenFeatureAPI.getInstance().setProviderAndWait(provider);
216+
} catch (GeneralError e) {
217+
error = e;
218+
}
219+
220+
assertNotNull(error);
221+
222+
assertEquals(ProviderState.ERROR, provider.getState());
223+
224+
assertTrue(gotErrorEvent.get(1000, TimeUnit.MILLISECONDS));
225+
226+
OpenFeatureAPI.getInstance().shutdown();
84227
}
85228
}

0 commit comments

Comments
 (0)