Skip to content

Commit e7b91f3

Browse files
committed
feat: support isolated API instances
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
1 parent c69bb0e commit e7b91f3

7 files changed

Lines changed: 322 additions & 13 deletions

File tree

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package dev.openfeature.sdk;
22

3+
import dev.openfeature.sdk.internal.AutoCloseableReentrantReadWriteLock;
34
import dev.openfeature.sdk.internal.ConfigurableThreadFactory;
45
import dev.openfeature.sdk.internal.TriConsumer;
56
import java.util.concurrent.ExecutorService;
@@ -30,20 +31,24 @@ void setEventProviderListener(EventProviderListener eventProviderListener) {
3031
}
3132

3233
private TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit = null;
34+
private AutoCloseableReentrantReadWriteLock lock = null;
3335

3436
/**
3537
* "Attach" this EventProvider to an SDK, which allows events to propagate from this provider.
3638
* No-op if the same onEmit is already attached.
3739
*
3840
* @param onEmit the function to run when a provider emits events.
41+
* @param lock the API instance's read/write lock for thread safety.
3942
* @throws IllegalStateException if attempted to bind a new emitter for already bound provider
4043
*/
41-
void attach(TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit) {
44+
void attach(TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit,
45+
AutoCloseableReentrantReadWriteLock lock) {
4246
if (this.onEmit != null && this.onEmit != onEmit) {
4347
// if we are trying to attach this provider to a different onEmit, something has gone wrong
4448
throw new IllegalStateException("Provider " + this.getMetadata().getName() + " is already attached.");
4549
} else {
4650
this.onEmit = onEmit;
51+
this.lock = lock;
4752
}
4853
}
4954

@@ -52,6 +57,7 @@ void attach(TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEm
5257
*/
5358
void detach() {
5459
this.onEmit = null;
60+
this.lock = null;
5561
}
5662

5763
/**
@@ -81,6 +87,7 @@ public void shutdown() {
8187
public Awaitable emit(final ProviderEvent event, final ProviderEventDetails details) {
8288
final var localEventProviderListener = this.eventProviderListener;
8389
final var localOnEmit = this.onEmit;
90+
final var localLock = this.lock;
8491

8592
if (localEventProviderListener == null && localOnEmit == null) {
8693
return Awaitable.FINISHED;
@@ -91,7 +98,7 @@ public Awaitable emit(final ProviderEvent event, final ProviderEventDetails deta
9198
// These calls need to be executed on a different thread to prevent deadlocks when the provider initialization
9299
// relies on a ready event to be emitted
93100
emitterExecutor.submit(() -> {
94-
try (var ignored = OpenFeatureAPI.lock.readLockAutoCloseable()) {
101+
try (var ignored = localLock != null ? localLock.readLockAutoCloseable() : null) {
95102
if (localEventProviderListener != null) {
96103
localEventProviderListener.onEmit(event, details);
97104
}

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
@Slf4j
2323
@SuppressWarnings("PMD.UnusedLocalVariable")
2424
public class OpenFeatureAPI implements EventBus<OpenFeatureAPI> {
25-
// package-private multi-read/single-write lock
26-
static AutoCloseableReentrantReadWriteLock lock = new AutoCloseableReentrantReadWriteLock();
25+
// package-private multi-read/single-write lock (instance-level for isolation)
26+
AutoCloseableReentrantReadWriteLock lock = new AutoCloseableReentrantReadWriteLock();
2727
private final ConcurrentLinkedQueue<Hook> apiHooks;
2828
private ProviderRepository providerRepository;
2929
private EventSupport eventSupport;
@@ -50,6 +50,24 @@ public static OpenFeatureAPI getInstance() {
5050
return SingletonHolder.INSTANCE;
5151
}
5252

53+
/**
54+
* Creates a new, independent {@link OpenFeatureAPI} instance with fully
55+
* isolated state.
56+
*
57+
* <p>Each instance maintains its own providers, evaluation context, hooks,
58+
* event handlers, and transaction context propagators. Instances do not
59+
* share state with the global singleton or with each other.
60+
*
61+
* <p>For better discoverability, prefer using
62+
* {@link dev.openfeature.sdk.isolated.OpenFeatureAPIFactory#createAPI()}.
63+
*
64+
* @return a new API instance
65+
* @see dev.openfeature.sdk.isolated.OpenFeatureAPIFactory#createAPI()
66+
*/
67+
public static OpenFeatureAPI createIsolated() {
68+
return new OpenFeatureAPI();
69+
}
70+
5371
/**
5472
* Get metadata about the default provider.
5573
*
@@ -251,7 +269,7 @@ public void setProviderAndWait(String domain, FeatureProvider provider) throws O
251269

252270
private void attachEventProvider(FeatureProvider provider) {
253271
if (provider instanceof EventProvider) {
254-
((EventProvider) provider).attach(this::runHandlersForProvider);
272+
((EventProvider) provider).attach(this::runHandlersForProvider, this.lock);
255273
}
256274
}
257275

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package dev.openfeature.sdk.isolated;
2+
3+
import dev.openfeature.sdk.OpenFeatureAPI;
4+
5+
/**
6+
* Factory for creating isolated OpenFeature API instances.
7+
*
8+
* <p>Each instance returned by {@link #createAPI()} maintains its own state,
9+
* including providers, evaluation context, hooks, event handlers, and
10+
* transaction context propagators. Instances do not share state with the
11+
* global singleton ({@link OpenFeatureAPI#getInstance()}) or with each other.
12+
*
13+
* <p>This is useful for dependency injection frameworks, testing scenarios,
14+
* and applications composed of multiple submodules requiring distinct providers.
15+
*
16+
* <p><strong>Spec references:</strong>
17+
* <ul>
18+
* <li>Requirement 1.8.1 &mdash; factory function for isolated instances</li>
19+
* <li>Requirement 1.8.3 &mdash; distinct package for discoverability</li>
20+
* </ul>
21+
*
22+
* @see <a href="https://openfeature.dev/specification/sections/flag-evaluation#18-isolated-api-instances">
23+
* Spec &sect;1.8 &mdash; Isolated API Instances</a>
24+
*/
25+
public final class OpenFeatureAPIFactory {
26+
27+
private OpenFeatureAPIFactory() {
28+
// utility class
29+
}
30+
31+
/**
32+
* Creates a new, independent {@link OpenFeatureAPI} instance with fully
33+
* isolated state.
34+
*
35+
* <p>Usage:
36+
* <pre>{@code
37+
* OpenFeatureAPI api = OpenFeatureAPIFactory.createAPI();
38+
* api.setProvider(new MyProvider());
39+
* Client client = api.getClient();
40+
* }</pre>
41+
*
42+
* @return a new API instance
43+
* @see OpenFeatureAPI#createIsolated()
44+
*/
45+
public static OpenFeatureAPI createAPI() {
46+
return OpenFeatureAPI.createIsolated();
47+
}
48+
}

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import static org.mockito.ArgumentMatchers.any;
55
import static org.mockito.Mockito.*;
66

7+
import dev.openfeature.sdk.internal.AutoCloseableReentrantReadWriteLock;
78
import dev.openfeature.sdk.internal.TriConsumer;
89
import dev.openfeature.sdk.testutils.TestStackedEmitCallsProvider;
910
import io.cucumber.java.AfterAll;
@@ -36,7 +37,7 @@ public static void resetDefaultProvider() {
3637
@DisplayName("should run attached onEmit with emitters")
3738
void emitsEventsWhenAttached() {
3839
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit = mockOnEmit();
39-
eventProvider.attach(onEmit);
40+
eventProvider.attach(onEmit, new AutoCloseableReentrantReadWriteLock());
4041

4142
ProviderEventDetails details = ProviderEventDetails.builder().build();
4243
eventProvider.emit(ProviderEvent.PROVIDER_READY, details);
@@ -73,17 +74,18 @@ void doesNotEmitsEventsWhenNotAttached() {
7374
void throwsWhenOnEmitDifferent() {
7475
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit1 = mockOnEmit();
7576
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit2 = mockOnEmit();
76-
eventProvider.attach(onEmit1);
77-
assertThrows(IllegalStateException.class, () -> eventProvider.attach(onEmit2));
77+
eventProvider.attach(onEmit1, new AutoCloseableReentrantReadWriteLock());
78+
assertThrows(IllegalStateException.class,
79+
() -> eventProvider.attach(onEmit2, new AutoCloseableReentrantReadWriteLock()));
7880
}
7981

8082
@Test
8183
@DisplayName("should not throw if second same onEmit attached")
8284
void doesNotThrowWhenOnEmitSame() {
8385
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit1 = mockOnEmit();
8486
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit2 = onEmit1;
85-
eventProvider.attach(onEmit1);
86-
eventProvider.attach(onEmit2); // should not throw, same instance. noop
87+
eventProvider.attach(onEmit1, new AutoCloseableReentrantReadWriteLock());
88+
eventProvider.attach(onEmit2, new AutoCloseableReentrantReadWriteLock()); // should not throw, same instance. noop
8789
}
8890

8991
@Test
@@ -132,8 +134,9 @@ public ProviderEvaluation<Value> getObjectEvaluation(String key, Value defaultVa
132134
}
133135

134136
@Override
135-
public void attach(TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit) {
136-
super.attach(onEmit);
137+
public void attach(TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit,
138+
AutoCloseableReentrantReadWriteLock lock) {
139+
super.attach(onEmit, lock);
137140
}
138141
}
139142

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ void beforeEach() {
3333
client = (OpenFeatureClient) api.getClient("LockingTest");
3434

3535
apiLock = setupLock(apiLock, mockInnerReadLock(), mockInnerWriteLock());
36-
OpenFeatureAPI.lock = apiLock;
36+
api.lock = apiLock;
3737

3838
clientHooksLock = setupLock(clientHooksLock, mockInnerReadLock(), mockInnerWriteLock());
3939
}

0 commit comments

Comments
 (0)