Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b0f5aaf
feat: support isolated API instances
marcozabel Apr 16, 2026
cbd6381
fix(tests): separate singleton tests
marcozabel Apr 16, 2026
e920cbf
fix: spotless formatting and arch test compliance
marcozabel Apr 16, 2026
c12cdd0
refactor(test): inject lock via constructor instead of field mutation
marcozabel Apr 20, 2026
cbf5fd9
refactor: make OpenFeatureAPI lock field final
marcozabel Apr 20, 2026
1700230
refactor: move OpenFeatureAPIFactory to sdk package and remove create…
marcozabel Apr 20, 2026
960f005
refactor: bundle EventProvider onEmit and lock into atomic attachment
marcozabel Apr 20, 2026
55de038
feat: move factory to isolated package and warn on multi-instance pro…
marcozabel Apr 22, 2026
0e549bc
docs: add experimental @apiNote to OpenFeatureAPIFactory
marcozabel Apr 29, 2026
15a90e4
test: add reverse-direction singleton isolation test
marcozabel Apr 29, 2026
333abd9
test: add delay before negative assertion in event isolation test
marcozabel Apr 29, 2026
4721222
test: use direct constructor in isolated tests
marcozabel May 7, 2026
e969f3a
refactor: remove OpenFeatureAPIFactory class
marcozabel May 7, 2026
10e3450
fix: replace @apiNote with prose to satisfy javadoc plugin
marcozabel May 7, 2026
7885372
fix: use AtomicReference with CAS in EventProvider.attach
marcozabel May 21, 2026
54466db
feat: restore OpenFeatureAPIFactory with package-private constructor
marcozabel May 21, 2026
01748e2
fixup: configure javadoc plugin to recognize JDK doc-tags
toddbaert Jun 9, 2026
2b4135f
fixup: remove OpenFeatureAPIFactory class per review consensus
toddbaert Jun 9, 2026
e8f348a
fixup: simplify EventProvider.attach to single CAS
toddbaert Jun 9, 2026
cfb2a51
fixup: spotless
toddbaert Jun 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified mvnw
100644 → 100755
Empty file.
20 changes: 20 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,26 @@
<failOnWarnings>true</failOnWarnings>
<doclint>all,-missing
</doclint> <!-- ignore missing javadoc, these are enforced with more customizability in the checkstyle plugin -->
<!-- recognize JDK doc-tags (@apiNote, @implSpec, @implNote); these are
standard in the JDK source but not enabled by default in javadoc.
No runtime dependency; only affects generated javadoc. -->
<tags>
<tag>
<name>apiNote</name>
<placement>a</placement>
<head>API Note:</head>
</tag>
<tag>
<name>implSpec</name>
<placement>a</placement>
<head>Implementation Requirements:</head>
</tag>
<tag>
<name>implNote</name>
<placement>a</placement>
<head>Implementation Note:</head>
</tag>
</tags>
</configuration>
<executions>
<execution>
Expand Down
44 changes: 31 additions & 13 deletions src/main/java/dev/openfeature/sdk/EventProvider.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
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;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j;

/**
Expand All @@ -29,29 +31,43 @@ void setEventProviderListener(EventProviderListener eventProviderListener) {
this.eventProviderListener = eventProviderListener;
}

private TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit = 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<EventProvider, ProviderEvent, ProviderEventDetails> onEmit;
final AutoCloseableReentrantReadWriteLock lock;

Attachment(
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit,
AutoCloseableReentrantReadWriteLock lock) {
this.onEmit = onEmit;
this.lock = lock;
}
}

private final AtomicReference<Attachment> attachment = new AtomicReference<>(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<EventProvider, ProviderEvent, ProviderEventDetails> onEmit) {
if (this.onEmit != null && this.onEmit != onEmit) {
// if we are trying to attach this provider to a different onEmit, something has gone wrong
void attach(
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit,
AutoCloseableReentrantReadWriteLock lock) {
Attachment newAttachment = new Attachment(onEmit, lock);
if (!this.attachment.compareAndSet(null, newAttachment)) {
throw new IllegalStateException("Provider " + this.getMetadata().getName() + " is already attached.");
} else {
this.onEmit = onEmit;
}
}

/**
* "Detach" this EventProvider from an SDK, stopping propagation of all events.
*/
void detach() {
this.onEmit = null;
this.attachment.set(null);
}

/**
Expand Down Expand Up @@ -80,9 +96,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 localAttachment = this.attachment.get();

if (localEventProviderListener == null && localOnEmit == null) {
if (localEventProviderListener == null && localAttachment == null) {
return Awaitable.FINISHED;
}

Expand All @@ -91,12 +107,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 = OpenFeatureAPI.lock.readLockAutoCloseable()) {
// 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();
Expand Down
78 changes: 71 additions & 7 deletions src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,68 @@
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import lombok.extern.slf4j.Slf4j;

/**
* A global singleton which holds base configuration for the OpenFeature
* library.
* Configuration here will be shared across all {@link Client}s.
* Holds base configuration for the OpenFeature library.
*
* <p>Most applications should use the global singleton via {@link #getInstance()}; configuration
* there is shared across all {@link Client}s. For dependency-injection frameworks, testing, or
* multi-tenant scenarios that need fully independent state (providers, hooks, evaluation context,
* event handlers, transaction context propagators), create isolated instances via
* {@link #createIsolated()}.
*
* <p><strong>Note:</strong> Isolated API instances (per spec section 1.8) are experimental and
* subject to change.
*
* @see <a href="https://openfeature.dev/specification/sections/flag-evaluation#18-isolated-api-instances">
* Spec &sect;1.8 &mdash; Isolated API Instances</a>
*/
@Slf4j
@SuppressWarnings("PMD.UnusedLocalVariable")
public class OpenFeatureAPI implements EventBus<OpenFeatureAPI> {
// package-private multi-read/single-write lock
static AutoCloseableReentrantReadWriteLock lock = new AutoCloseableReentrantReadWriteLock();

/**
* Global registry tracking which API instance each provider is currently bound to.
* Used to detect violations of spec requirement 1.8.4 (a provider SHOULD NOT be
* registered with more than one API instance simultaneously).
*/
private static final ConcurrentHashMap<FeatureProvider, OpenFeatureAPI> GLOBAL_PROVIDER_REGISTRY =
new ConcurrentHashMap<>();

// package-private multi-read/single-write lock (instance-level for isolation)
final AutoCloseableReentrantReadWriteLock lock;
private final ConcurrentLinkedQueue<Hook> apiHooks;
private ProviderRepository providerRepository;
private EventSupport eventSupport;
private final AtomicReference<EvaluationContext> evaluationContext = new AtomicReference<>();
private TransactionContextPropagator transactionContextPropagator;

protected OpenFeatureAPI() {
/**
* Creates and returns a new, independent {@link OpenFeatureAPI} instance with fully isolated
* state (providers, hooks, evaluation context, event handlers, transaction context
* propagators).
*
* @apiNote This API is experimental and subject to change.
* @see <a href="https://openfeature.dev/specification/sections/flag-evaluation#18-isolated-api-instances">
* Spec &sect;1.8 &mdash; Isolated API Instances</a>
*/
public static OpenFeatureAPI createIsolated() {
return new OpenFeatureAPI();
}

// Package-private: not part of the public API; use createIsolated().
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();
Expand Down Expand Up @@ -251,7 +291,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);
}
}

Expand Down Expand Up @@ -332,6 +372,30 @@ public void clearHooks() {
this.apiHooks.clear();
}

/**
* Registers a provider with the global registry, warning if it is already
* bound to a different API instance (spec requirement 1.8.4).
*/
void registerGlobalProvider(FeatureProvider provider) {
GLOBAL_PROVIDER_REGISTRY.compute(provider, (p, existing) -> {
if (existing != null && existing != this) {
log.warn("Provider "
+ provider.getClass().getName()
+ " is already registered with another API instance. "
+ "A provider SHOULD NOT be bound to more than one API instance "
+ "simultaneously (spec requirement 1.8.4).");
}
return this;
});
}

/**
* Removes the provider from the global registry if this instance is the current owner.
*/
void deregisterGlobalProvider(FeatureProvider provider) {
GLOBAL_PROVIDER_REGISTRY.remove(provider, this);
}

/**
* Shut down and reset the current status of OpenFeature API.
* This call cleans up all active providers and attempts to shut down internal
Expand Down
10 changes: 9 additions & 1 deletion src/main/java/dev/openfeature/sdk/ProviderRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ private void prepareAndInitializeProvider(
newStateManager = new FeatureProviderStateManager(newProvider);
// only run afterSet if new provider is not already attached
afterSet.accept(newProvider);
// spec 1.8.4: warn if this provider is already bound to another API instance
openFeatureAPI.registerGlobalProvider(newProvider);
} else {
newStateManager = existing;
}
Expand Down Expand Up @@ -236,6 +238,8 @@ private void initializeProvider(
private void shutDownOld(FeatureProviderStateManager oldManager, Consumer<FeatureProvider> afterShutdown) {
synchronized (registerStateManagerLock) {
if (oldManager != null && !isStateManagerRegistered(oldManager)) {
// spec 1.8.4: release the provider from the global registry
openFeatureAPI.deregisterGlobalProvider(oldManager.getProvider());
shutdownProvider(oldManager);
afterShutdown.accept(oldManager.getProvider());
}
Expand Down Expand Up @@ -327,7 +331,11 @@ List<FeatureProviderStateManager> prepareShutdown() {
* @param managersToShutdown the managers to shut down (from prepareShutdown)
*/
void completeShutdown(List<FeatureProviderStateManager> managersToShutdown) {
managersToShutdown.forEach(this::shutdownProvider);
managersToShutdown.forEach(m -> {
// spec 1.8.4: release all providers from the global registry on shutdown
openFeatureAPI.deregisterGlobalProvider(m.getProvider());
shutdownProvider(m);
});
taskExecutor.shutdown();
try {
if (!taskExecutor.awaitTermination(EventSupport.SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
Expand Down
50 changes: 39 additions & 11 deletions src/test/java/dev/openfeature/sdk/EventProviderTest.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package dev.openfeature.sdk;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
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;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import lombok.SneakyThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
Expand Down Expand Up @@ -36,7 +40,7 @@ public static void resetDefaultProvider() {
@DisplayName("should run attached onEmit with emitters")
void emitsEventsWhenAttached() {
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit = mockOnEmit();
eventProvider.attach(onEmit);
eventProvider.attach(onEmit, new AutoCloseableReentrantReadWriteLock());

ProviderEventDetails details = ProviderEventDetails.builder().build();
eventProvider.emit(ProviderEvent.PROVIDER_READY, details);
Expand Down Expand Up @@ -73,17 +77,39 @@ void doesNotEmitsEventsWhenNotAttached() {
void throwsWhenOnEmitDifferent() {
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit1 = mockOnEmit();
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit2 = mockOnEmit();
eventProvider.attach(onEmit1);
assertThrows(IllegalStateException.class, () -> eventProvider.attach(onEmit2));
AutoCloseableReentrantReadWriteLock lock = new AutoCloseableReentrantReadWriteLock();
eventProvider.attach(onEmit1, lock);
assertThrows(IllegalStateException.class, () -> eventProvider.attach(onEmit2, lock));
}

@Test
@DisplayName("should not throw if second same onEmit attached")
void doesNotThrowWhenOnEmitSame() {
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit1 = mockOnEmit();
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit2 = onEmit1;
eventProvider.attach(onEmit1);
eventProvider.attach(onEmit2); // should not throw, same instance. noop
@Timeout(value = 2, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
@DisplayName("emit should acquire read lock when attached")
void emitAcquiresReadLockWhenAttached() throws Exception {
AutoCloseableReentrantReadWriteLock lock = new AutoCloseableReentrantReadWriteLock();
CountDownLatch lockAcquired = new CountDownLatch(1);

TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit = (ep, event, details) -> {
// When the onEmit callback runs, the read lock must already be held
assertThat(lock.getReadLockCount()).isGreaterThan(0);
lockAcquired.countDown();
};

eventProvider.attach(onEmit, lock);
eventProvider.emit(
ProviderEvent.PROVIDER_READY, ProviderEventDetails.builder().build());

assertThat(lockAcquired.await(1, TimeUnit.SECONDS)).isTrue();
}

@Test
@Timeout(value = 2, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
@DisplayName("emit should not acquire lock when not attached")
void emitDoesNotAcquireLockWhenNotAttached() {
// emit without attaching — should return immediately without error
Awaitable result = eventProvider.emit(
ProviderEvent.PROVIDER_READY, ProviderEventDetails.builder().build());
assertThat(result).isSameAs(Awaitable.FINISHED);
}

@Test
Expand Down Expand Up @@ -132,8 +158,10 @@ public ProviderEvaluation<Value> getObjectEvaluation(String key, Value defaultVa
}

@Override
public void attach(TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit) {
super.attach(onEmit);
public void attach(
TriConsumer<EventProvider, ProviderEvent, ProviderEventDetails> onEmit,
AutoCloseableReentrantReadWriteLock lock) {
super.attach(onEmit, lock);
}
}

Expand Down
Loading
Loading