Skip to content

Commit 7271a12

Browse files
committed
fix: prevent race conditions during repository shutdown
Signed-off-by: Nicklas Lundin <nicklasl@spotify.com>
1 parent e30bde7 commit 7271a12

File tree

2 files changed

+124
-4
lines changed

2 files changed

+124
-4
lines changed

src/main/java/dev/openfeature/sdk/ProviderRepository.java

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import java.util.concurrent.ExecutorService;
1212
import java.util.concurrent.Executors;
1313
import java.util.concurrent.TimeUnit;
14+
import java.util.concurrent.atomic.AtomicBoolean;
1415
import java.util.concurrent.atomic.AtomicReference;
1516
import java.util.function.BiConsumer;
1617
import java.util.function.Consumer;
@@ -24,6 +25,7 @@ class ProviderRepository {
2425
private final Map<String, FeatureProviderStateManager> stateManagers = new ConcurrentHashMap<>();
2526
private final AtomicReference<FeatureProviderStateManager> defaultStateManger =
2627
new AtomicReference<>(new FeatureProviderStateManager(new NoOpProvider()));
28+
private final AtomicBoolean isShuttingDown = new AtomicBoolean(false);
2729
private final ExecutorService taskExecutor =
2830
Executors.newCachedThreadPool(new ConfigurableThreadFactory("openfeature-provider-thread", true));
2931
private final Object registerStateManagerLock = new Object();
@@ -159,6 +161,10 @@ private void prepareAndInitializeProvider(
159161
Consumer<FeatureProvider> afterShutdown,
160162
BiConsumer<FeatureProvider, OpenFeatureError> afterError,
161163
boolean waitForInit) {
164+
if (isShuttingDown.get()) {
165+
throw new IllegalStateException("Provider cannot be set while repository is shutting down");
166+
}
167+
162168
final FeatureProviderStateManager newStateManager;
163169
final FeatureProviderStateManager oldStateManager;
164170

@@ -255,16 +261,27 @@ private void shutdownProvider(FeatureProviderStateManager manager) {
255261
}
256262

257263
private void shutdownProvider(FeatureProvider provider) {
258-
taskExecutor.submit(() -> {
264+
try {
265+
taskExecutor.submit(() -> {
266+
try {
267+
provider.shutdown();
268+
} catch (Exception e) {
269+
log.error(
270+
"Exception when shutting down feature provider {}",
271+
provider.getClass().getName(),
272+
e);
273+
}
274+
});
275+
} catch (java.util.concurrent.RejectedExecutionException e) {
259276
try {
260277
provider.shutdown();
261-
} catch (Exception e) {
278+
} catch (Exception ex) {
262279
log.error(
263280
"Exception when shutting down feature provider {}",
264281
provider.getClass().getName(),
265-
e);
282+
ex);
266283
}
267-
});
284+
}
268285
}
269286

270287
/**
@@ -273,6 +290,10 @@ private void shutdownProvider(FeatureProvider provider) {
273290
* including the default feature provider.
274291
*/
275292
public void shutdown() {
293+
if (isShuttingDown.getAndSet(true)) {
294+
return;
295+
}
296+
276297
Stream.concat(Stream.of(this.defaultStateManger.get()), this.stateManagers.values().stream())
277298
.distinct()
278299
.forEach(this::shutdownProvider);

src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import static dev.openfeature.sdk.testutils.stubbing.ConditionStubber.doDelayResponse;
55
import static org.assertj.core.api.Assertions.assertThat;
66
import static org.assertj.core.api.Assertions.assertThatCode;
7+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
78
import static org.awaitility.Awaitility.await;
89
import static org.mockito.ArgumentMatchers.any;
910
import static org.mockito.ArgumentMatchers.eq;
@@ -363,6 +364,104 @@ void shouldNotHangIndefinitelyOnShutdown() {
363364
return true;
364365
});
365366
}
367+
368+
@Test
369+
@DisplayName("should handle shutdown during provider initialization")
370+
void shouldHandleShutdownDuringProviderInitialization() throws Exception {
371+
FeatureProvider slowInitProvider = createMockedProvider();
372+
AtomicBoolean initStarted = new AtomicBoolean(false);
373+
AtomicBoolean shutdownCalled = new AtomicBoolean(false);
374+
375+
doAnswer(invocation -> {
376+
initStarted.set(true);
377+
Thread.sleep(500);
378+
return null;
379+
})
380+
.when(slowInitProvider)
381+
.initialize(any());
382+
383+
doAnswer(invocation -> {
384+
shutdownCalled.set(true);
385+
return null;
386+
})
387+
.when(slowInitProvider)
388+
.shutdown();
389+
390+
providerRepository.setProvider(slowInitProvider, mockAfterSet(), mockAfterInit(), mockAfterShutdown(), mockAfterError(), false);
391+
392+
await().atMost(Duration.ofSeconds(1)).untilTrue(initStarted);
393+
394+
// Call shutdown while initialization is in progress
395+
assertThatCode(() -> providerRepository.shutdown()).doesNotThrowAnyException();
396+
397+
await().atMost(Duration.ofSeconds(1)).untilTrue(shutdownCalled);
398+
verify(slowInitProvider, times(1)).shutdown();
399+
}
400+
401+
@Test
402+
@DisplayName("should handle provider replacement during shutdown")
403+
void shouldHandleProviderReplacementDuringShutdown() throws Exception {
404+
FeatureProvider oldProvider = createMockedProvider();
405+
FeatureProvider newProvider = createMockedProvider();
406+
AtomicBoolean oldProviderShutdownCalled = new AtomicBoolean(false);
407+
408+
doAnswer(invocation -> {
409+
oldProviderShutdownCalled.set(true);
410+
return null;
411+
})
412+
.when(oldProvider)
413+
.shutdown();
414+
415+
providerRepository.setProvider(oldProvider, mockAfterSet(), mockAfterInit(), mockAfterShutdown(), mockAfterError(), true);
416+
417+
// Replace provider (this will trigger old provider shutdown in background)
418+
providerRepository.setProvider(newProvider, mockAfterSet(), mockAfterInit(), mockAfterShutdown(), mockAfterError(), false);
419+
420+
assertThatCode(() -> providerRepository.shutdown()).doesNotThrowAnyException();
421+
422+
await().atMost(Duration.ofSeconds(1)).untilTrue(oldProviderShutdownCalled);
423+
verify(oldProvider, times(1)).shutdown();
424+
verify(newProvider, times(1)).shutdown();
425+
}
426+
427+
@Test
428+
@DisplayName("should prevent adding providers after shutdown has started")
429+
void shouldPreventAddingProvidersAfterShutdownHasStarted() {
430+
FeatureProvider provider = createMockedProvider();
431+
setFeatureProvider(provider);
432+
433+
providerRepository.shutdown();
434+
435+
FeatureProvider newProvider = createMockedProvider();
436+
assertThatThrownBy(() -> providerRepository.setProvider(
437+
newProvider, mockAfterSet(), mockAfterInit(), mockAfterShutdown(), mockAfterError(), false))
438+
.isInstanceOf(IllegalStateException.class)
439+
.hasMessageContaining("shutting down");
440+
}
441+
442+
@Test
443+
@DisplayName("should handle concurrent shutdown calls gracefully")
444+
void shouldHandleConcurrentShutdownCallsGracefully() {
445+
FeatureProvider provider = createMockedProvider();
446+
setFeatureProvider(provider);
447+
448+
assertThatCode(() -> {
449+
Thread t1 = new Thread(() -> providerRepository.shutdown());
450+
Thread t2 = new Thread(() -> providerRepository.shutdown());
451+
Thread t3 = new Thread(() -> providerRepository.shutdown());
452+
453+
t1.start();
454+
t2.start();
455+
t3.start();
456+
457+
t1.join(TIMEOUT);
458+
t2.join(TIMEOUT);
459+
t3.join(TIMEOUT);
460+
})
461+
.doesNotThrowAnyException();
462+
463+
verify(provider, times(1)).shutdown();
464+
}
366465
}
367466
}
368467

0 commit comments

Comments
 (0)