From e7b91f3c0fb7eef8dcfc5b7e2d40fd2275b53aa2 Mon Sep 17 00:00:00 2001 From: marcozabel Date: Thu, 16 Apr 2026 16:58:34 +0200 Subject: [PATCH 1/7] feat: support isolated API instances Signed-off-by: marcozabel --- .../dev/openfeature/sdk/EventProvider.java | 11 +- .../dev/openfeature/sdk/OpenFeatureAPI.java | 24 +- .../sdk/isolated/OpenFeatureAPIFactory.java | 48 ++++ .../openfeature/sdk/EventProviderTest.java | 17 +- .../openfeature/sdk/LockingSingeltonTest.java | 2 +- .../sdk/isolated/IsolatedAPITest.java | 225 ++++++++++++++++++ .../openfeature/sdk/isolated/NoOpHook.java | 8 + 7 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 src/main/java/dev/openfeature/sdk/isolated/OpenFeatureAPIFactory.java create mode 100644 src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java create mode 100644 src/test/java/dev/openfeature/sdk/isolated/NoOpHook.java diff --git a/src/main/java/dev/openfeature/sdk/EventProvider.java b/src/main/java/dev/openfeature/sdk/EventProvider.java index c126c1451..8a94028ac 100644 --- a/src/main/java/dev/openfeature/sdk/EventProvider.java +++ b/src/main/java/dev/openfeature/sdk/EventProvider.java @@ -1,5 +1,6 @@ package dev.openfeature.sdk; +import dev.openfeature.sdk.internal.AutoCloseableReentrantReadWriteLock; import dev.openfeature.sdk.internal.ConfigurableThreadFactory; import dev.openfeature.sdk.internal.TriConsumer; import java.util.concurrent.ExecutorService; @@ -30,20 +31,24 @@ void setEventProviderListener(EventProviderListener eventProviderListener) { } private TriConsumer onEmit = null; + private AutoCloseableReentrantReadWriteLock lock = null; /** * "Attach" this EventProvider to an SDK, which allows events to propagate from this provider. * No-op if the same onEmit is already attached. * * @param onEmit the function to run when a provider emits events. + * @param lock the API instance's read/write lock for thread safety. * @throws IllegalStateException if attempted to bind a new emitter for already bound provider */ - void attach(TriConsumer onEmit) { + void attach(TriConsumer onEmit, + AutoCloseableReentrantReadWriteLock lock) { if (this.onEmit != null && this.onEmit != onEmit) { // if we are trying to attach this provider to a different onEmit, something has gone wrong throw new IllegalStateException("Provider " + this.getMetadata().getName() + " is already attached."); } else { this.onEmit = onEmit; + this.lock = lock; } } @@ -52,6 +57,7 @@ void attach(TriConsumer onEm */ void detach() { this.onEmit = null; + this.lock = null; } /** @@ -81,6 +87,7 @@ public void shutdown() { public Awaitable emit(final ProviderEvent event, final ProviderEventDetails details) { final var localEventProviderListener = this.eventProviderListener; final var localOnEmit = this.onEmit; + final var localLock = this.lock; if (localEventProviderListener == null && localOnEmit == null) { return Awaitable.FINISHED; @@ -91,7 +98,7 @@ public Awaitable emit(final ProviderEvent event, final ProviderEventDetails deta // These calls need to be executed on a different thread to prevent deadlocks when the provider initialization // relies on a ready event to be emitted emitterExecutor.submit(() -> { - try (var ignored = OpenFeatureAPI.lock.readLockAutoCloseable()) { + try (var ignored = localLock != null ? localLock.readLockAutoCloseable() : null) { if (localEventProviderListener != null) { localEventProviderListener.onEmit(event, details); } diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java index 02c1edf25..df37ad89f 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java @@ -22,8 +22,8 @@ @Slf4j @SuppressWarnings("PMD.UnusedLocalVariable") public class OpenFeatureAPI implements EventBus { - // package-private multi-read/single-write lock - static AutoCloseableReentrantReadWriteLock lock = new AutoCloseableReentrantReadWriteLock(); + // package-private multi-read/single-write lock (instance-level for isolation) + AutoCloseableReentrantReadWriteLock lock = new AutoCloseableReentrantReadWriteLock(); private final ConcurrentLinkedQueue apiHooks; private ProviderRepository providerRepository; private EventSupport eventSupport; @@ -50,6 +50,24 @@ public static OpenFeatureAPI getInstance() { return SingletonHolder.INSTANCE; } + /** + * Creates a new, independent {@link OpenFeatureAPI} instance with fully + * isolated state. + * + *

Each instance maintains its own providers, evaluation context, hooks, + * event handlers, and transaction context propagators. Instances do not + * share state with the global singleton or with each other. + * + *

For better discoverability, prefer using + * {@link dev.openfeature.sdk.isolated.OpenFeatureAPIFactory#createAPI()}. + * + * @return a new API instance + * @see dev.openfeature.sdk.isolated.OpenFeatureAPIFactory#createAPI() + */ + public static OpenFeatureAPI createIsolated() { + return new OpenFeatureAPI(); + } + /** * Get metadata about the default provider. * @@ -251,7 +269,7 @@ public void setProviderAndWait(String domain, FeatureProvider provider) throws O private void attachEventProvider(FeatureProvider provider) { if (provider instanceof EventProvider) { - ((EventProvider) provider).attach(this::runHandlersForProvider); + ((EventProvider) provider).attach(this::runHandlersForProvider, this.lock); } } diff --git a/src/main/java/dev/openfeature/sdk/isolated/OpenFeatureAPIFactory.java b/src/main/java/dev/openfeature/sdk/isolated/OpenFeatureAPIFactory.java new file mode 100644 index 000000000..ff722eb63 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/isolated/OpenFeatureAPIFactory.java @@ -0,0 +1,48 @@ +package dev.openfeature.sdk.isolated; + +import dev.openfeature.sdk.OpenFeatureAPI; + +/** + * Factory for creating isolated OpenFeature API instances. + * + *

Each instance returned by {@link #createAPI()} maintains its own state, + * including providers, evaluation context, hooks, event handlers, and + * transaction context propagators. Instances do not share state with the + * global singleton ({@link OpenFeatureAPI#getInstance()}) or with each other. + * + *

This is useful for dependency injection frameworks, testing scenarios, + * and applications composed of multiple submodules requiring distinct providers. + * + *

Spec references: + *

    + *
  • Requirement 1.8.1 — factory function for isolated instances
  • + *
  • Requirement 1.8.3 — distinct package for discoverability
  • + *
+ * + * @see + * Spec §1.8 — Isolated API Instances + */ +public final class OpenFeatureAPIFactory { + + private OpenFeatureAPIFactory() { + // utility class + } + + /** + * Creates a new, independent {@link OpenFeatureAPI} instance with fully + * isolated state. + * + *

Usage: + *

{@code
+     * OpenFeatureAPI api = OpenFeatureAPIFactory.createAPI();
+     * api.setProvider(new MyProvider());
+     * Client client = api.getClient();
+     * }
+ * + * @return a new API instance + * @see OpenFeatureAPI#createIsolated() + */ + public static OpenFeatureAPI createAPI() { + return OpenFeatureAPI.createIsolated(); + } +} diff --git a/src/test/java/dev/openfeature/sdk/EventProviderTest.java b/src/test/java/dev/openfeature/sdk/EventProviderTest.java index d04fa88d1..daf9a2f25 100644 --- a/src/test/java/dev/openfeature/sdk/EventProviderTest.java +++ b/src/test/java/dev/openfeature/sdk/EventProviderTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import dev.openfeature.sdk.internal.AutoCloseableReentrantReadWriteLock; import dev.openfeature.sdk.internal.TriConsumer; import dev.openfeature.sdk.testutils.TestStackedEmitCallsProvider; import io.cucumber.java.AfterAll; @@ -36,7 +37,7 @@ public static void resetDefaultProvider() { @DisplayName("should run attached onEmit with emitters") void emitsEventsWhenAttached() { TriConsumer onEmit = mockOnEmit(); - eventProvider.attach(onEmit); + eventProvider.attach(onEmit, new AutoCloseableReentrantReadWriteLock()); ProviderEventDetails details = ProviderEventDetails.builder().build(); eventProvider.emit(ProviderEvent.PROVIDER_READY, details); @@ -73,8 +74,9 @@ void doesNotEmitsEventsWhenNotAttached() { void throwsWhenOnEmitDifferent() { TriConsumer onEmit1 = mockOnEmit(); TriConsumer onEmit2 = mockOnEmit(); - eventProvider.attach(onEmit1); - assertThrows(IllegalStateException.class, () -> eventProvider.attach(onEmit2)); + eventProvider.attach(onEmit1, new AutoCloseableReentrantReadWriteLock()); + assertThrows(IllegalStateException.class, + () -> eventProvider.attach(onEmit2, new AutoCloseableReentrantReadWriteLock())); } @Test @@ -82,8 +84,8 @@ void throwsWhenOnEmitDifferent() { void doesNotThrowWhenOnEmitSame() { TriConsumer onEmit1 = mockOnEmit(); TriConsumer onEmit2 = onEmit1; - eventProvider.attach(onEmit1); - eventProvider.attach(onEmit2); // should not throw, same instance. noop + eventProvider.attach(onEmit1, new AutoCloseableReentrantReadWriteLock()); + eventProvider.attach(onEmit2, new AutoCloseableReentrantReadWriteLock()); // should not throw, same instance. noop } @Test @@ -132,8 +134,9 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa } @Override - public void attach(TriConsumer onEmit) { - super.attach(onEmit); + public void attach(TriConsumer onEmit, + AutoCloseableReentrantReadWriteLock lock) { + super.attach(onEmit, lock); } } diff --git a/src/test/java/dev/openfeature/sdk/LockingSingeltonTest.java b/src/test/java/dev/openfeature/sdk/LockingSingeltonTest.java index ae3246cae..01e496f99 100644 --- a/src/test/java/dev/openfeature/sdk/LockingSingeltonTest.java +++ b/src/test/java/dev/openfeature/sdk/LockingSingeltonTest.java @@ -33,7 +33,7 @@ void beforeEach() { client = (OpenFeatureClient) api.getClient("LockingTest"); apiLock = setupLock(apiLock, mockInnerReadLock(), mockInnerWriteLock()); - OpenFeatureAPI.lock = apiLock; + api.lock = apiLock; clientHooksLock = setupLock(clientHooksLock, mockInnerReadLock(), mockInnerWriteLock()); } diff --git a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java new file mode 100644 index 000000000..b71601562 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java @@ -0,0 +1,225 @@ +package dev.openfeature.sdk.isolated; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.NoOpProvider; +import dev.openfeature.sdk.NoOpTransactionContextPropagator; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator; +import dev.openfeature.sdk.providers.memory.Flag; +import dev.openfeature.sdk.providers.memory.InMemoryProvider; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class IsolatedAPITest { + + private final OpenFeatureAPI singleton = OpenFeatureAPI.getInstance(); + + @AfterEach + void restoreSingleton() { + singleton.shutdown(); + singleton.clearHooks(); + singleton.setEvaluationContext(null); + singleton.setTransactionContextPropagator(new NoOpTransactionContextPropagator()); + } + + /** + * Requirement 1.8.1 — factory creates new, distinct instances that + * conform to the API contract. + */ + @Test + @DisplayName("factory creates distinct API instances") + void factoryCreatesDistinctInstances() { + OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI(); + OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI(); + + assertThat(api1).isInstanceOf(OpenFeatureAPI.class).isNotSameAs(api2); + } + + /** + * Requirement 1.8.1 — isolated instances do not share state with + * the global singleton. Singleton state is restored after the test + * via {@link #restoreSingleton()}. + */ + @Test + @DisplayName("isolated instance does not interfere with singleton") + void isolatedInstanceDoesNotInterfereWithSingleton() { + // record singleton baseline + FeatureProvider singletonProvider = singleton.getProvider(); + + OpenFeatureAPI isolated = OpenFeatureAPIFactory.createAPI(); + assertThat(isolated).isNotSameAs(singleton); + + // mutate only the isolated instance + isolated.setProvider(new InMemoryProvider(Map.of())); + isolated.addHooks(new NoOpHook()); + isolated.setEvaluationContext(new ImmutableContext("isolated-key")); + + // singleton remains at baseline + assertThat(singleton.getProvider()).isSameAs(singletonProvider); + assertThat(singleton.getHooks()).isEmpty(); + assertThat(singleton.getEvaluationContext()).isNull(); + } + + /** + * Requirement 1.8.1 — providers are isolated between instances. + */ + @Test + @DisplayName("providers are isolated between instances") + void providerIsolation() { + OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI(); + OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI(); + + InMemoryProvider provider = new InMemoryProvider(Map.of()); + api1.setProvider(provider); + + assertThat(api1.getProvider()).isSameAs(provider); + assertThat(api2.getProvider()).isInstanceOf(NoOpProvider.class); + } + + /** + * Requirement 1.8.1 — hooks are isolated between instances. + */ + @Test + @DisplayName("hooks are isolated between instances") + void hookIsolation() { + OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI(); + OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI(); + + api1.addHooks(new NoOpHook()); + + assertThat(api1.getHooks()).hasSize(1); + assertThat(api2.getHooks()).isEmpty(); + } + + /** + * Requirement 1.8.1 — evaluation context is isolated between instances. + */ + @Test + @DisplayName("evaluation context is isolated between instances") + void evaluationContextIsolation() { + OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI(); + OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI(); + + api1.setEvaluationContext(new ImmutableContext("key-1")); + api2.setEvaluationContext(new ImmutableContext("key-2")); + + assertThat(api1.getEvaluationContext().getTargetingKey()).isEqualTo("key-1"); + assertThat(api2.getEvaluationContext().getTargetingKey()).isEqualTo("key-2"); + } + + /** + * Requirement 1.8.1 — event handlers are isolated between instances. + */ + @Test + @DisplayName("event handlers are isolated between instances") + void eventHandlerIsolation() throws Exception { + OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI(); + OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI(); + + AtomicBoolean api1HandlerCalled = new AtomicBoolean(false); + AtomicBoolean api2HandlerCalled = new AtomicBoolean(false); + + api1.onProviderReady(details -> api1HandlerCalled.set(true)); + api2.onProviderReady(details -> api2HandlerCalled.set(true)); + + // setting a provider on api1 should only trigger api1's handler + api1.setProviderAndWait(new NoOpProvider()); + + assertThat(api1HandlerCalled.get()).isTrue(); + assertThat(api2HandlerCalled.get()).isFalse(); + } + + /** + * Requirement 1.8.1 — transaction context propagators are isolated + * between instances. + */ + @Test + @DisplayName("transaction context propagator is isolated between instances") + void transactionContextPropagatorIsolation() { + OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI(); + OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI(); + + ThreadLocalTransactionContextPropagator propagator = new ThreadLocalTransactionContextPropagator(); + api1.setTransactionContextPropagator(propagator); + + assertThat(api1.getTransactionContextPropagator()).isSameAs(propagator); + assertThat(api2.getTransactionContextPropagator()).isInstanceOf(NoOpTransactionContextPropagator.class); + } + + /** + * Requirement 1.8.2 — an isolated instance conforms to the same API + * contract (provider, hooks, context, client creation, flag evaluation). + */ + @Test + @DisplayName("isolated instance conforms to API contract") + void isolatedInstanceConformsToAPIContract() { + OpenFeatureAPI api = OpenFeatureAPIFactory.createAPI(); + + // provider management + InMemoryProvider provider = new InMemoryProvider(Map.of( + "flag1", Flag.builder().variant("on", true).variant("off", false).defaultVariant("on").build())); + api.setProvider(provider); + assertThat(api.getProvider()).isSameAs(provider); + assertThat(api.getProviderMetadata()).isNotNull(); + + // hooks + NoOpHook hook = new NoOpHook(); + api.addHooks(hook); + assertThat(api.getHooks()).containsExactly(hook); + + // context + api.setEvaluationContext(new ImmutableContext("targeting-key")); + assertThat(api.getEvaluationContext().getTargetingKey()).isEqualTo("targeting-key"); + + // client creation and flag evaluation + var client = api.getClient("test-domain", "1.0"); + assertThat(client.getMetadata().getDomain()).isEqualTo("test-domain"); + assertThat(client.getBooleanValue("flag1", false)).isTrue(); + } + + /** + * Requirement 1.8.1 — clearHooks on one instance does not affect another. + */ + @Test + @DisplayName("clearHooks does not affect other instances") + void clearHooksDoesNotAffectOtherInstances() { + OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI(); + OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI(); + + NoOpHook hook = new NoOpHook(); + api1.addHooks(hook); + api2.addHooks(hook); + + api1.clearHooks(); + + assertThat(api1.getHooks()).isEmpty(); + assertThat(api2.getHooks()).hasSize(1); + } + + /** + * Requirement 1.8.2 — clients from different isolated instances use + * their own instance's provider. + */ + @Test + @DisplayName("clients use their own instance's provider") + void clientUsesItsOwnInstanceProvider() { + OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI(); + OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI(); + + api1.setProvider(new InMemoryProvider(Map.of( + "flag1", Flag.builder().variant("on", true).variant("off", false).defaultVariant("on").build()))); + + var client1 = api1.getClient(); + var client2 = api2.getClient(); + + assertThat(client1.getBooleanValue("flag1", false)).isTrue(); + // api2 has NoOpProvider, so it returns the default + assertThat(client2.getBooleanValue("flag1", false)).isFalse(); + } +} diff --git a/src/test/java/dev/openfeature/sdk/isolated/NoOpHook.java b/src/test/java/dev/openfeature/sdk/isolated/NoOpHook.java new file mode 100644 index 000000000..3aa0c76ed --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/isolated/NoOpHook.java @@ -0,0 +1,8 @@ +package dev.openfeature.sdk.isolated; + +import dev.openfeature.sdk.Hook; + +/** + * Minimal no-op hook for testing purposes. + */ +class NoOpHook implements Hook {} From b084f9ca52fa70fe82d86f71efa2bad07a5aa9d6 Mon Sep 17 00:00:00 2001 From: marcozabel Date: Thu, 16 Apr 2026 17:28:16 +0200 Subject: [PATCH 2/7] fix(tests): separate singleton tests Signed-off-by: marcozabel --- .../isolated/IsolatedAPISingeltonTest.java | 50 +++++++++++++++++++ .../sdk/isolated/IsolatedAPITest.java | 45 ++--------------- 2 files changed, 54 insertions(+), 41 deletions(-) create mode 100644 src/test/java/dev/openfeature/sdk/isolated/IsolatedAPISingeltonTest.java diff --git a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPISingeltonTest.java b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPISingeltonTest.java new file mode 100644 index 000000000..ab806a1b2 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPISingeltonTest.java @@ -0,0 +1,50 @@ +package dev.openfeature.sdk.isolated; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.NoOpTransactionContextPropagator; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.providers.memory.InMemoryProvider; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class IsolatedAPISingeltonTest { + + private final OpenFeatureAPI singleton = OpenFeatureAPI.getInstance(); + + @AfterEach + void restoreSingleton() { + singleton.shutdown(); + singleton.clearHooks(); + singleton.setEvaluationContext(null); + singleton.setTransactionContextPropagator(new NoOpTransactionContextPropagator()); + } + + /** + * Requirement 1.8.1 — isolated instances do not share state with + * the global singleton. + */ + @Test + @DisplayName("isolated instance does not interfere with singleton") + void isolatedInstanceDoesNotInterfereWithSingleton() { + // record singleton baseline + FeatureProvider singletonProvider = singleton.getProvider(); + + OpenFeatureAPI isolated = OpenFeatureAPIFactory.createAPI(); + assertThat(isolated).isNotSameAs(singleton); + + // mutate only the isolated instance + isolated.setProvider(new InMemoryProvider(Map.of())); + isolated.addHooks(new NoOpHook()); + isolated.setEvaluationContext(new ImmutableContext("isolated-key")); + + // singleton remains at baseline + assertThat(singleton.getProvider()).isSameAs(singletonProvider); + assertThat(singleton.getHooks()).isEmpty(); + assertThat(singleton.getEvaluationContext()).isNull(); + } +} diff --git a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java index b71601562..18d857ad8 100644 --- a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java +++ b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; -import dev.openfeature.sdk.FeatureProvider; import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.NoOpProvider; import dev.openfeature.sdk.NoOpTransactionContextPropagator; @@ -12,22 +11,11 @@ import dev.openfeature.sdk.providers.memory.InMemoryProvider; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class IsolatedAPITest { - private final OpenFeatureAPI singleton = OpenFeatureAPI.getInstance(); - - @AfterEach - void restoreSingleton() { - singleton.shutdown(); - singleton.clearHooks(); - singleton.setEvaluationContext(null); - singleton.setTransactionContextPropagator(new NoOpTransactionContextPropagator()); - } - /** * Requirement 1.8.1 — factory creates new, distinct instances that * conform to the API contract. @@ -41,31 +29,6 @@ void factoryCreatesDistinctInstances() { assertThat(api1).isInstanceOf(OpenFeatureAPI.class).isNotSameAs(api2); } - /** - * Requirement 1.8.1 — isolated instances do not share state with - * the global singleton. Singleton state is restored after the test - * via {@link #restoreSingleton()}. - */ - @Test - @DisplayName("isolated instance does not interfere with singleton") - void isolatedInstanceDoesNotInterfereWithSingleton() { - // record singleton baseline - FeatureProvider singletonProvider = singleton.getProvider(); - - OpenFeatureAPI isolated = OpenFeatureAPIFactory.createAPI(); - assertThat(isolated).isNotSameAs(singleton); - - // mutate only the isolated instance - isolated.setProvider(new InMemoryProvider(Map.of())); - isolated.addHooks(new NoOpHook()); - isolated.setEvaluationContext(new ImmutableContext("isolated-key")); - - // singleton remains at baseline - assertThat(singleton.getProvider()).isSameAs(singletonProvider); - assertThat(singleton.getHooks()).isEmpty(); - assertThat(singleton.getEvaluationContext()).isNull(); - } - /** * Requirement 1.8.1 — providers are isolated between instances. */ @@ -158,13 +121,13 @@ void transactionContextPropagatorIsolation() { */ @Test @DisplayName("isolated instance conforms to API contract") - void isolatedInstanceConformsToAPIContract() { + void isolatedInstanceConformsToAPIContract() throws Exception { OpenFeatureAPI api = OpenFeatureAPIFactory.createAPI(); // provider management InMemoryProvider provider = new InMemoryProvider(Map.of( "flag1", Flag.builder().variant("on", true).variant("off", false).defaultVariant("on").build())); - api.setProvider(provider); + api.setProviderAndWait(provider); assertThat(api.getProvider()).isSameAs(provider); assertThat(api.getProviderMetadata()).isNotNull(); @@ -208,11 +171,11 @@ void clearHooksDoesNotAffectOtherInstances() { */ @Test @DisplayName("clients use their own instance's provider") - void clientUsesItsOwnInstanceProvider() { + void clientUsesItsOwnInstanceProvider() throws Exception { OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI(); OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI(); - api1.setProvider(new InMemoryProvider(Map.of( + api1.setProviderAndWait(new InMemoryProvider(Map.of( "flag1", Flag.builder().variant("on", true).variant("off", false).defaultVariant("on").build()))); var client1 = api1.getClient(); From 3815faa6b094cd5206055b4ee1d7fce1198b1ca7 Mon Sep 17 00:00:00 2001 From: marcozabel Date: Thu, 16 Apr 2026 17:36:15 +0200 Subject: [PATCH 3/7] fix: spotless formatting and arch test compliance Signed-off-by: marcozabel --- .../java/dev/openfeature/sdk/EventProvider.java | 5 +++-- .../dev/openfeature/sdk/EventProviderTest.java | 11 +++++++---- .../openfeature/sdk/isolated/IsolatedAPITest.java | 14 ++++++++++++-- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/main/java/dev/openfeature/sdk/EventProvider.java b/src/main/java/dev/openfeature/sdk/EventProvider.java index 8a94028ac..a5bd969c8 100644 --- a/src/main/java/dev/openfeature/sdk/EventProvider.java +++ b/src/main/java/dev/openfeature/sdk/EventProvider.java @@ -41,8 +41,9 @@ void setEventProviderListener(EventProviderListener eventProviderListener) { * @param lock the API instance's read/write lock for thread safety. * @throws IllegalStateException if attempted to bind a new emitter for already bound provider */ - void attach(TriConsumer onEmit, - AutoCloseableReentrantReadWriteLock lock) { + void attach( + TriConsumer onEmit, + AutoCloseableReentrantReadWriteLock lock) { if (this.onEmit != null && this.onEmit != onEmit) { // if we are trying to attach this provider to a different onEmit, something has gone wrong throw new IllegalStateException("Provider " + this.getMetadata().getName() + " is already attached."); diff --git a/src/test/java/dev/openfeature/sdk/EventProviderTest.java b/src/test/java/dev/openfeature/sdk/EventProviderTest.java index daf9a2f25..f0d39265d 100644 --- a/src/test/java/dev/openfeature/sdk/EventProviderTest.java +++ b/src/test/java/dev/openfeature/sdk/EventProviderTest.java @@ -75,7 +75,8 @@ void throwsWhenOnEmitDifferent() { TriConsumer onEmit1 = mockOnEmit(); TriConsumer onEmit2 = mockOnEmit(); eventProvider.attach(onEmit1, new AutoCloseableReentrantReadWriteLock()); - assertThrows(IllegalStateException.class, + assertThrows( + IllegalStateException.class, () -> eventProvider.attach(onEmit2, new AutoCloseableReentrantReadWriteLock())); } @@ -85,7 +86,8 @@ void doesNotThrowWhenOnEmitSame() { TriConsumer onEmit1 = mockOnEmit(); TriConsumer onEmit2 = onEmit1; eventProvider.attach(onEmit1, new AutoCloseableReentrantReadWriteLock()); - eventProvider.attach(onEmit2, new AutoCloseableReentrantReadWriteLock()); // should not throw, same instance. noop + eventProvider.attach( + onEmit2, new AutoCloseableReentrantReadWriteLock()); // should not throw, same instance. noop } @Test @@ -134,8 +136,9 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa } @Override - public void attach(TriConsumer onEmit, - AutoCloseableReentrantReadWriteLock lock) { + public void attach( + TriConsumer onEmit, + AutoCloseableReentrantReadWriteLock lock) { super.attach(onEmit, lock); } } diff --git a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java index 18d857ad8..b0c4d4b2e 100644 --- a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java +++ b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java @@ -126,7 +126,12 @@ void isolatedInstanceConformsToAPIContract() throws Exception { // provider management InMemoryProvider provider = new InMemoryProvider(Map.of( - "flag1", Flag.builder().variant("on", true).variant("off", false).defaultVariant("on").build())); + "flag1", + Flag.builder() + .variant("on", true) + .variant("off", false) + .defaultVariant("on") + .build())); api.setProviderAndWait(provider); assertThat(api.getProvider()).isSameAs(provider); assertThat(api.getProviderMetadata()).isNotNull(); @@ -176,7 +181,12 @@ void clientUsesItsOwnInstanceProvider() throws Exception { OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI(); api1.setProviderAndWait(new InMemoryProvider(Map.of( - "flag1", Flag.builder().variant("on", true).variant("off", false).defaultVariant("on").build()))); + "flag1", + Flag.builder() + .variant("on", true) + .variant("off", false) + .defaultVariant("on") + .build()))); var client1 = api1.getClient(); var client2 = api2.getClient(); From da9d472ae9a31e489b442c0dcecd14e4e76ab340 Mon Sep 17 00:00:00 2001 From: marcozabel Date: Mon, 20 Apr 2026 11:41:36 +0200 Subject: [PATCH 4/7] refactor(test): inject lock via constructor instead of field mutation Signed-off-by: marcozabel --- .../java/dev/openfeature/sdk/OpenFeatureAPI.java | 8 +++++++- .../dev/openfeature/sdk/LockingSingeltonTest.java | 14 +++----------- .../openfeature/sdk/isolated/IsolatedAPITest.java | 11 ++++++++--- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java index df37ad89f..2143d0524 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java @@ -23,7 +23,7 @@ @SuppressWarnings("PMD.UnusedLocalVariable") public class OpenFeatureAPI implements EventBus { // package-private multi-read/single-write lock (instance-level for isolation) - AutoCloseableReentrantReadWriteLock lock = new AutoCloseableReentrantReadWriteLock(); + AutoCloseableReentrantReadWriteLock lock; private final ConcurrentLinkedQueue apiHooks; private ProviderRepository providerRepository; private EventSupport eventSupport; @@ -31,6 +31,12 @@ public class OpenFeatureAPI implements EventBus { private TransactionContextPropagator transactionContextPropagator; protected OpenFeatureAPI() { + this(new AutoCloseableReentrantReadWriteLock()); + } + + // Package-private constructor for testing with a custom lock. + OpenFeatureAPI(AutoCloseableReentrantReadWriteLock lock) { + this.lock = lock; apiHooks = new ConcurrentLinkedQueue<>(); providerRepository = new ProviderRepository(this); eventSupport = new EventSupport(); diff --git a/src/test/java/dev/openfeature/sdk/LockingSingeltonTest.java b/src/test/java/dev/openfeature/sdk/LockingSingeltonTest.java index 01e496f99..b1fc0a6b2 100644 --- a/src/test/java/dev/openfeature/sdk/LockingSingeltonTest.java +++ b/src/test/java/dev/openfeature/sdk/LockingSingeltonTest.java @@ -8,7 +8,6 @@ import dev.openfeature.sdk.internal.AutoCloseableReentrantReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Consumer; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -17,23 +16,16 @@ @Isolated() class LockingSingeltonTest { - private static OpenFeatureAPI api; + private OpenFeatureAPI api; private OpenFeatureClient client; private AutoCloseableReentrantReadWriteLock apiLock; private AutoCloseableReentrantReadWriteLock clientHooksLock; - @BeforeAll - static void beforeAll() { - api = OpenFeatureAPI.getInstance(); - OpenFeatureAPI.getInstance().setProvider("LockingTest", new NoOpProvider()); - } - @BeforeEach void beforeEach() { - client = (OpenFeatureClient) api.getClient("LockingTest"); - apiLock = setupLock(apiLock, mockInnerReadLock(), mockInnerWriteLock()); - api.lock = apiLock; + api = new OpenFeatureAPI(apiLock); + client = (OpenFeatureClient) api.getClient("LockingTest"); clientHooksLock = setupLock(clientHooksLock, mockInnerReadLock(), mockInnerWriteLock()); } diff --git a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java index b0c4d4b2e..d2f494d98 100644 --- a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java +++ b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java @@ -10,9 +10,12 @@ import dev.openfeature.sdk.providers.memory.Flag; import dev.openfeature.sdk.providers.memory.InMemoryProvider; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; class IsolatedAPITest { @@ -80,21 +83,23 @@ void evaluationContextIsolation() { * Requirement 1.8.1 — event handlers are isolated between instances. */ @Test + @Timeout(value = 2, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) @DisplayName("event handlers are isolated between instances") void eventHandlerIsolation() throws Exception { OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI(); OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI(); - AtomicBoolean api1HandlerCalled = new AtomicBoolean(false); + CountDownLatch api1HandlerLatch = new CountDownLatch(1); AtomicBoolean api2HandlerCalled = new AtomicBoolean(false); - api1.onProviderReady(details -> api1HandlerCalled.set(true)); + // Handlers are dispatched asynchronously; use a latch to await api1's handler. + api1.onProviderReady(details -> api1HandlerLatch.countDown()); api2.onProviderReady(details -> api2HandlerCalled.set(true)); // setting a provider on api1 should only trigger api1's handler api1.setProviderAndWait(new NoOpProvider()); - assertThat(api1HandlerCalled.get()).isTrue(); + assertThat(api1HandlerLatch.await(1, TimeUnit.SECONDS)).isTrue(); assertThat(api2HandlerCalled.get()).isFalse(); } From 7b9b39d22e0bc20bfcbb072708c2e467125428b6 Mon Sep 17 00:00:00 2001 From: marcozabel Date: Mon, 20 Apr 2026 11:42:59 +0200 Subject: [PATCH 5/7] refactor: make OpenFeatureAPI lock field final Signed-off-by: marcozabel --- src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java index 2143d0524..1f2e8ec62 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java @@ -23,7 +23,7 @@ @SuppressWarnings("PMD.UnusedLocalVariable") public class OpenFeatureAPI implements EventBus { // package-private multi-read/single-write lock (instance-level for isolation) - AutoCloseableReentrantReadWriteLock lock; + final AutoCloseableReentrantReadWriteLock lock; private final ConcurrentLinkedQueue apiHooks; private ProviderRepository providerRepository; private EventSupport eventSupport; From 5f14bb9d74683f8a4aced034e3c9c8647a4cdec1 Mon Sep 17 00:00:00 2001 From: marcozabel Date: Mon, 20 Apr 2026 11:44:49 +0200 Subject: [PATCH 6/7] refactor: move OpenFeatureAPIFactory to sdk package and remove createIsolated() Signed-off-by: marcozabel --- .../dev/openfeature/sdk/OpenFeatureAPI.java | 18 ------------------ .../{isolated => }/OpenFeatureAPIFactory.java | 8 ++------ .../sdk/isolated/IsolatedAPISingeltonTest.java | 1 + .../sdk/isolated/IsolatedAPITest.java | 1 + 4 files changed, 4 insertions(+), 24 deletions(-) rename src/main/java/dev/openfeature/sdk/{isolated => }/OpenFeatureAPIFactory.java (84%) diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java index 1f2e8ec62..cf3cf0d80 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java @@ -56,24 +56,6 @@ public static OpenFeatureAPI getInstance() { return SingletonHolder.INSTANCE; } - /** - * Creates a new, independent {@link OpenFeatureAPI} instance with fully - * isolated state. - * - *

Each instance maintains its own providers, evaluation context, hooks, - * event handlers, and transaction context propagators. Instances do not - * share state with the global singleton or with each other. - * - *

For better discoverability, prefer using - * {@link dev.openfeature.sdk.isolated.OpenFeatureAPIFactory#createAPI()}. - * - * @return a new API instance - * @see dev.openfeature.sdk.isolated.OpenFeatureAPIFactory#createAPI() - */ - public static OpenFeatureAPI createIsolated() { - return new OpenFeatureAPI(); - } - /** * Get metadata about the default provider. * diff --git a/src/main/java/dev/openfeature/sdk/isolated/OpenFeatureAPIFactory.java b/src/main/java/dev/openfeature/sdk/OpenFeatureAPIFactory.java similarity index 84% rename from src/main/java/dev/openfeature/sdk/isolated/OpenFeatureAPIFactory.java rename to src/main/java/dev/openfeature/sdk/OpenFeatureAPIFactory.java index ff722eb63..283876932 100644 --- a/src/main/java/dev/openfeature/sdk/isolated/OpenFeatureAPIFactory.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureAPIFactory.java @@ -1,6 +1,4 @@ -package dev.openfeature.sdk.isolated; - -import dev.openfeature.sdk.OpenFeatureAPI; +package dev.openfeature.sdk; /** * Factory for creating isolated OpenFeature API instances. @@ -16,7 +14,6 @@ *

Spec references: *

    *
  • Requirement 1.8.1 — factory function for isolated instances
  • - *
  • Requirement 1.8.3 — distinct package for discoverability
  • *
* * @see @@ -40,9 +37,8 @@ private OpenFeatureAPIFactory() { * } * * @return a new API instance - * @see OpenFeatureAPI#createIsolated() */ public static OpenFeatureAPI createAPI() { - return OpenFeatureAPI.createIsolated(); + return new OpenFeatureAPI(); } } diff --git a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPISingeltonTest.java b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPISingeltonTest.java index ab806a1b2..7699d4910 100644 --- a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPISingeltonTest.java +++ b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPISingeltonTest.java @@ -6,6 +6,7 @@ import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.NoOpTransactionContextPropagator; import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.OpenFeatureAPIFactory; import dev.openfeature.sdk.providers.memory.InMemoryProvider; import java.util.Map; import org.junit.jupiter.api.AfterEach; diff --git a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java index d2f494d98..2a673a95d 100644 --- a/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java +++ b/src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java @@ -6,6 +6,7 @@ import dev.openfeature.sdk.NoOpProvider; import dev.openfeature.sdk.NoOpTransactionContextPropagator; import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.OpenFeatureAPIFactory; import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator; import dev.openfeature.sdk.providers.memory.Flag; import dev.openfeature.sdk.providers.memory.InMemoryProvider; From c06e500bfffbe0c53fd9eac63f2808bea9ebd23a Mon Sep 17 00:00:00 2001 From: marcozabel Date: Mon, 20 Apr 2026 12:44:37 +0200 Subject: [PATCH 7/7] refactor: bundle EventProvider onEmit and lock into atomic attachment Signed-off-by: marcozabel --- .../dev/openfeature/sdk/EventProvider.java | 40 ++++++++++++------- .../openfeature/sdk/EventProviderTest.java | 7 ++-- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/main/java/dev/openfeature/sdk/EventProvider.java b/src/main/java/dev/openfeature/sdk/EventProvider.java index a5bd969c8..08d84040d 100644 --- a/src/main/java/dev/openfeature/sdk/EventProvider.java +++ b/src/main/java/dev/openfeature/sdk/EventProvider.java @@ -30,8 +30,21 @@ void setEventProviderListener(EventProviderListener eventProviderListener) { this.eventProviderListener = eventProviderListener; } - private TriConsumer onEmit = null; - private AutoCloseableReentrantReadWriteLock lock = null; + // Bundles onEmit and lock into a single volatile reference so they are always read atomically: + // a non-null attachment guarantees a non-null lock. + private static final class Attachment { + final TriConsumer onEmit; + final AutoCloseableReentrantReadWriteLock lock; + + Attachment( + TriConsumer onEmit, + AutoCloseableReentrantReadWriteLock lock) { + this.onEmit = onEmit; + this.lock = lock; + } + } + + private volatile Attachment attachment = null; /** * "Attach" this EventProvider to an SDK, which allows events to propagate from this provider. @@ -44,21 +57,19 @@ void setEventProviderListener(EventProviderListener eventProviderListener) { void attach( TriConsumer onEmit, AutoCloseableReentrantReadWriteLock lock) { - if (this.onEmit != null && this.onEmit != onEmit) { + Attachment existing = this.attachment; + if (existing != null && existing.onEmit != onEmit) { // if we are trying to attach this provider to a different onEmit, something has gone wrong throw new IllegalStateException("Provider " + this.getMetadata().getName() + " is already attached."); - } else { - this.onEmit = onEmit; - this.lock = lock; } + this.attachment = new Attachment(onEmit, lock); } /** * "Detach" this EventProvider from an SDK, stopping propagation of all events. */ void detach() { - this.onEmit = null; - this.lock = null; + this.attachment = null; } /** @@ -87,10 +98,9 @@ public void shutdown() { */ public Awaitable emit(final ProviderEvent event, final ProviderEventDetails details) { final var localEventProviderListener = this.eventProviderListener; - final var localOnEmit = this.onEmit; - final var localLock = this.lock; + final var localAttachment = this.attachment; - if (localEventProviderListener == null && localOnEmit == null) { + if (localEventProviderListener == null && localAttachment == null) { return Awaitable.FINISHED; } @@ -99,12 +109,14 @@ public Awaitable emit(final ProviderEvent event, final ProviderEventDetails deta // These calls need to be executed on a different thread to prevent deadlocks when the provider initialization // relies on a ready event to be emitted emitterExecutor.submit(() -> { - try (var ignored = localLock != null ? localLock.readLockAutoCloseable() : null) { + // Lock is only needed when attached to an API instance. A non-null attachment always + // carries a non-null lock, so no null check on the lock itself is required. + try (var ignored = localAttachment != null ? localAttachment.lock.readLockAutoCloseable() : null) { if (localEventProviderListener != null) { localEventProviderListener.onEmit(event, details); } - if (localOnEmit != null) { - localOnEmit.accept(this, event, details); + if (localAttachment != null) { + localAttachment.onEmit.accept(this, event, details); } } finally { awaitable.wakeup(); diff --git a/src/test/java/dev/openfeature/sdk/EventProviderTest.java b/src/test/java/dev/openfeature/sdk/EventProviderTest.java index f0d39265d..253a0329c 100644 --- a/src/test/java/dev/openfeature/sdk/EventProviderTest.java +++ b/src/test/java/dev/openfeature/sdk/EventProviderTest.java @@ -74,10 +74,9 @@ void doesNotEmitsEventsWhenNotAttached() { void throwsWhenOnEmitDifferent() { TriConsumer onEmit1 = mockOnEmit(); TriConsumer onEmit2 = mockOnEmit(); - eventProvider.attach(onEmit1, new AutoCloseableReentrantReadWriteLock()); - assertThrows( - IllegalStateException.class, - () -> eventProvider.attach(onEmit2, new AutoCloseableReentrantReadWriteLock())); + AutoCloseableReentrantReadWriteLock lock = new AutoCloseableReentrantReadWriteLock(); + eventProvider.attach(onEmit1, lock); + assertThrows(IllegalStateException.class, () -> eventProvider.attach(onEmit2, lock)); } @Test