Skip to content

Commit 73f8ed0

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

7 files changed

Lines changed: 309 additions & 6 deletions

File tree

mvnw

100644100755
File mode changed.

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/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
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package dev.openfeature.sdk.isolated;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import dev.openfeature.sdk.FeatureProvider;
6+
import dev.openfeature.sdk.ImmutableContext;
7+
import dev.openfeature.sdk.NoOpProvider;
8+
import dev.openfeature.sdk.NoOpTransactionContextPropagator;
9+
import dev.openfeature.sdk.OpenFeatureAPI;
10+
import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator;
11+
import dev.openfeature.sdk.providers.memory.Flag;
12+
import dev.openfeature.sdk.providers.memory.InMemoryProvider;
13+
import java.util.Map;
14+
import java.util.concurrent.atomic.AtomicBoolean;
15+
import org.junit.jupiter.api.AfterEach;
16+
import org.junit.jupiter.api.DisplayName;
17+
import org.junit.jupiter.api.Test;
18+
19+
class IsolatedAPITest {
20+
21+
private final OpenFeatureAPI singleton = OpenFeatureAPI.getInstance();
22+
23+
@AfterEach
24+
void restoreSingleton() {
25+
singleton.shutdown();
26+
}
27+
28+
/**
29+
* Requirement 1.8.1 — factory creates new, distinct instances that
30+
* conform to the API contract.
31+
*/
32+
@Test
33+
@DisplayName("factory creates distinct API instances")
34+
void factoryCreatesDistinctInstances() {
35+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
36+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
37+
38+
assertThat(api1).isInstanceOf(OpenFeatureAPI.class).isNotSameAs(api2);
39+
}
40+
41+
/**
42+
* Requirement 1.8.1 — isolated instances do not share state with
43+
* the global singleton. Singleton state is restored after the test
44+
* via {@link #restoreSingleton()}.
45+
*/
46+
@Test
47+
@DisplayName("isolated instance does not interfere with singleton")
48+
void isolatedInstanceDoesNotInterfereWithSingleton() {
49+
// record singleton baseline
50+
FeatureProvider singletonProvider = singleton.getProvider();
51+
52+
OpenFeatureAPI isolated = OpenFeatureAPIFactory.createAPI();
53+
assertThat(isolated).isNotSameAs(singleton);
54+
55+
// mutate only the isolated instance
56+
isolated.setProvider(new InMemoryProvider(Map.of()));
57+
isolated.addHooks(new NoOpHook());
58+
isolated.setEvaluationContext(new ImmutableContext("isolated-key"));
59+
60+
// singleton remains at baseline
61+
assertThat(singleton.getProvider()).isSameAs(singletonProvider);
62+
assertThat(singleton.getHooks()).isEmpty();
63+
assertThat(singleton.getEvaluationContext()).isNull();
64+
}
65+
66+
/**
67+
* Requirement 1.8.1 — providers are isolated between instances.
68+
*/
69+
@Test
70+
@DisplayName("providers are isolated between instances")
71+
void providerIsolation() {
72+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
73+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
74+
75+
InMemoryProvider provider = new InMemoryProvider(Map.of());
76+
api1.setProvider(provider);
77+
78+
assertThat(api1.getProvider()).isSameAs(provider);
79+
assertThat(api2.getProvider()).isInstanceOf(NoOpProvider.class);
80+
}
81+
82+
/**
83+
* Requirement 1.8.1 — hooks are isolated between instances.
84+
*/
85+
@Test
86+
@DisplayName("hooks are isolated between instances")
87+
void hookIsolation() {
88+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
89+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
90+
91+
api1.addHooks(new NoOpHook());
92+
93+
assertThat(api1.getHooks()).hasSize(1);
94+
assertThat(api2.getHooks()).isEmpty();
95+
}
96+
97+
/**
98+
* Requirement 1.8.1 — evaluation context is isolated between instances.
99+
*/
100+
@Test
101+
@DisplayName("evaluation context is isolated between instances")
102+
void evaluationContextIsolation() {
103+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
104+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
105+
106+
api1.setEvaluationContext(new ImmutableContext("key-1"));
107+
api2.setEvaluationContext(new ImmutableContext("key-2"));
108+
109+
assertThat(api1.getEvaluationContext().getTargetingKey()).isEqualTo("key-1");
110+
assertThat(api2.getEvaluationContext().getTargetingKey()).isEqualTo("key-2");
111+
}
112+
113+
/**
114+
* Requirement 1.8.1 — event handlers are isolated between instances.
115+
*/
116+
@Test
117+
@DisplayName("event handlers are isolated between instances")
118+
void eventHandlerIsolation() throws Exception {
119+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
120+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
121+
122+
AtomicBoolean api1HandlerCalled = new AtomicBoolean(false);
123+
AtomicBoolean api2HandlerCalled = new AtomicBoolean(false);
124+
125+
api1.onProviderReady(details -> api1HandlerCalled.set(true));
126+
api2.onProviderReady(details -> api2HandlerCalled.set(true));
127+
128+
// setting a provider on api1 should only trigger api1's handler
129+
api1.setProviderAndWait(new NoOpProvider());
130+
131+
assertThat(api1HandlerCalled.get()).isTrue();
132+
assertThat(api2HandlerCalled.get()).isFalse();
133+
}
134+
135+
/**
136+
* Requirement 1.8.1 — transaction context propagators are isolated
137+
* between instances.
138+
*/
139+
@Test
140+
@DisplayName("transaction context propagator is isolated between instances")
141+
void transactionContextPropagatorIsolation() {
142+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
143+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
144+
145+
ThreadLocalTransactionContextPropagator propagator = new ThreadLocalTransactionContextPropagator();
146+
api1.setTransactionContextPropagator(propagator);
147+
148+
assertThat(api1.getTransactionContextPropagator()).isSameAs(propagator);
149+
assertThat(api2.getTransactionContextPropagator()).isInstanceOf(NoOpTransactionContextPropagator.class);
150+
}
151+
152+
/**
153+
* Requirement 1.8.2 — an isolated instance conforms to the same API
154+
* contract (provider, hooks, context, client creation, flag evaluation).
155+
*/
156+
@Test
157+
@DisplayName("isolated instance conforms to API contract")
158+
void isolatedInstanceConformsToAPIContract() {
159+
OpenFeatureAPI api = OpenFeatureAPIFactory.createAPI();
160+
161+
// provider management
162+
InMemoryProvider provider = new InMemoryProvider(Map.of(
163+
"flag1", Flag.builder().variant("on", true).variant("off", false).defaultVariant("on").build()));
164+
api.setProvider(provider);
165+
assertThat(api.getProvider()).isSameAs(provider);
166+
assertThat(api.getProviderMetadata()).isNotNull();
167+
168+
// hooks
169+
NoOpHook hook = new NoOpHook();
170+
api.addHooks(hook);
171+
assertThat(api.getHooks()).containsExactly(hook);
172+
173+
// context
174+
api.setEvaluationContext(new ImmutableContext("targeting-key"));
175+
assertThat(api.getEvaluationContext().getTargetingKey()).isEqualTo("targeting-key");
176+
177+
// client creation and flag evaluation
178+
var client = api.getClient("test-domain", "1.0");
179+
assertThat(client.getMetadata().getDomain()).isEqualTo("test-domain");
180+
assertThat(client.getBooleanValue("flag1", false)).isTrue();
181+
}
182+
183+
/**
184+
* Requirement 1.8.1 — clearHooks on one instance does not affect another.
185+
*/
186+
@Test
187+
@DisplayName("clearHooks does not affect other instances")
188+
void clearHooksDoesNotAffectOtherInstances() {
189+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
190+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
191+
192+
NoOpHook hook = new NoOpHook();
193+
api1.addHooks(hook);
194+
api2.addHooks(hook);
195+
196+
api1.clearHooks();
197+
198+
assertThat(api1.getHooks()).isEmpty();
199+
assertThat(api2.getHooks()).hasSize(1);
200+
}
201+
202+
/**
203+
* Requirement 1.8.2 — clients from different isolated instances use
204+
* their own instance's provider.
205+
*/
206+
@Test
207+
@DisplayName("clients use their own instance's provider")
208+
void clientUsesItsOwnInstanceProvider() {
209+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
210+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
211+
212+
api1.setProvider(new InMemoryProvider(Map.of(
213+
"flag1", Flag.builder().variant("on", true).variant("off", false).defaultVariant("on").build())));
214+
215+
var client1 = api1.getClient();
216+
var client2 = api2.getClient();
217+
218+
assertThat(client1.getBooleanValue("flag1", false)).isTrue();
219+
// api2 has NoOpProvider, so it returns the default
220+
assertThat(client2.getBooleanValue("flag1", false)).isFalse();
221+
}
222+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package dev.openfeature.sdk.isolated;
2+
3+
import dev.openfeature.sdk.Hook;
4+
5+
/**
6+
* Minimal no-op hook for testing purposes.
7+
*/
8+
class NoOpHook implements Hook<Object> {}

0 commit comments

Comments
 (0)