Skip to content

Commit dcf7a52

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

6 files changed

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