Skip to content
39 changes: 38 additions & 1 deletion src/main/java/dev/openfeature/sdk/EventProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import dev.openfeature.sdk.internal.AutoCloseableReentrantReadWriteLock;
import dev.openfeature.sdk.internal.ConfigurableThreadFactory;
import dev.openfeature.sdk.internal.TriConsumer;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import lombok.extern.slf4j.Slf4j;

/**
Expand All @@ -24,6 +27,7 @@
@Slf4j
public abstract class EventProvider implements FeatureProvider {
private EventProviderListener eventProviderListener;
private final List<BiConsumer<ProviderEvent, ProviderEventDetails>> eventObservers = new CopyOnWriteArrayList<>();
private final ExecutorService emitterExecutor =
Executors.newCachedThreadPool(new ConfigurableThreadFactory("openfeature-event-emitter-thread", true));

Expand Down Expand Up @@ -70,6 +74,31 @@ void detach() {
this.attachment.set(null);
}

/**
* Add a provider event observer.
*
* <p>Observers are invoked whenever this provider emits an event and are intended for advanced
* provider composition scenarios.
*
* @param observer observer callback
*/
public void addEventObserver(BiConsumer<ProviderEvent, ProviderEventDetails> observer) {
if (observer != null) {
eventObservers.add(observer);
}
}

/**
* Remove a previously registered provider event observer.
*
* @param observer observer callback
*/
public void removeEventObserver(BiConsumer<ProviderEvent, ProviderEventDetails> observer) {
if (observer != null) {
eventObservers.remove(observer);
}
}

/**
* Stop the event emitter executor and block until either termination has completed
* or timeout period has elapsed.
Expand Down Expand Up @@ -97,8 +126,9 @@ public void shutdown() {
public Awaitable emit(final ProviderEvent event, final ProviderEventDetails details) {
final var localEventProviderListener = this.eventProviderListener;
final var localAttachment = this.attachment.get();
final var localEventObservers = this.eventObservers;

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

Expand All @@ -116,6 +146,13 @@ public Awaitable emit(final ProviderEvent event, final ProviderEventDetails deta
if (localAttachment != null) {
localAttachment.onEmit.accept(this, event, details);
}
for (BiConsumer<ProviderEvent, ProviderEventDetails> observer : localEventObservers) {
try {
observer.accept(event, details);
} catch (Exception e) {
log.error("Exception in provider event observer {}", observer, e);
}
}
} finally {
awaitable.wakeup();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package dev.openfeature.sdk.multiprovider;

import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.FeatureProvider;
import dev.openfeature.sdk.ProviderEvaluation;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Function;
import lombok.Getter;

/**
* Comparison strategy.
*
* <p>Evaluates all providers in parallel and compares successful results.
* If all providers agree on the value, the fallback provider's result is returned.
* If providers disagree, the optional {@code onMismatch} callback is invoked
* and the fallback provider's result is returned.
* If any provider returns an error, all errors are collected and a {@link MultiProviderEvaluation}
* with {@link ErrorCode#GENERAL} and per-provider {@link ProviderError} details is returned.
*/
public class ComparisonStrategy implements Strategy {

private static final long DEFAULT_TIMEOUT_MS = 30_000;

@Getter
private final String fallbackProvider;

private final BiConsumer<String, Map<String, ProviderEvaluation<?>>> onMismatch;
private final ExecutorService executorService;
private final long timeoutMs;

/**
* Constructs a comparison strategy with a fallback provider.
*
* <p>Uses a shared {@link ForkJoinPool#commonPool()} for parallel evaluation.
*
* @param fallbackProvider provider name to use as fallback when successful
* providers disagree
*/
public ComparisonStrategy(String fallbackProvider) {
this(fallbackProvider, null);
}

/**
* Constructs a comparison strategy with fallback provider and mismatch callback.
*
* <p>Uses a shared {@link ForkJoinPool#commonPool()} for parallel evaluation.
*
* @param fallbackProvider provider name to use as fallback when successful
* providers disagree
* @param onMismatch callback invoked with all successful evaluations
* when they disagree
*/
public ComparisonStrategy(
String fallbackProvider, BiConsumer<String, Map<String, ProviderEvaluation<?>>> onMismatch) {
this(fallbackProvider, onMismatch, ForkJoinPool.commonPool(), DEFAULT_TIMEOUT_MS);
}

/**
* Constructs a comparison strategy with a caller-supplied executor.
*
* @param fallbackProvider provider name to use as fallback when successful
* providers disagree
* @param onMismatch callback invoked with all successful evaluations
* when they disagree (may be {@code null})
* @param executorService executor to use for parallel evaluation
* @param timeoutMs maximum time in milliseconds to wait for all
* providers to complete
*/
public ComparisonStrategy(
String fallbackProvider,
BiConsumer<String, Map<String, ProviderEvaluation<?>>> onMismatch,
ExecutorService executorService,
long timeoutMs) {
this.fallbackProvider = Objects.requireNonNull(fallbackProvider, "fallbackProvider must not be null");
this.onMismatch = onMismatch;
this.executorService = Objects.requireNonNull(executorService, "executorService must not be null");
this.timeoutMs = timeoutMs;
}

@Override
public <T> ProviderEvaluation<T> evaluate(

Check failure on line 94 in src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=open-feature_java-sdk&issues=AZ_TunwAcxQN4hDLcMwd&open=AZ_TunwAcxQN4hDLcMwd&pullRequest=1897
Map<String, FeatureProvider> providers,
String key,
T defaultValue,
EvaluationContext ctx,
Function<FeatureProvider, ProviderEvaluation<T>> providerFunction) {
if (providers.isEmpty()) {
return ProviderEvaluation.<T>builder()
.errorCode(ErrorCode.GENERAL)
.errorMessage("No providers configured")
.build();
}
if (!providers.containsKey(fallbackProvider)) {
throw new IllegalArgumentException("fallbackProvider not found in providers: " + fallbackProvider);
}

int capacity = providers.size() * 4 / 3 + 1;
Map<String, ProviderEvaluation<T>> successfulResults = new ConcurrentHashMap<>(capacity);
Map<String, ProviderError> providerErrors = new ConcurrentHashMap<>(capacity);

try {
List<Callable<Void>> tasks = new ArrayList<>(providers.size());
for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
String providerName = entry.getKey();
FeatureProvider provider = entry.getValue();
tasks.add(() -> {
try {
ProviderEvaluation<T> evaluation = providerFunction.apply(provider);
if (evaluation == null) {
providerErrors.put(
providerName,
ProviderError.fromResult(providerName, ErrorCode.GENERAL, "null evaluation"));
} else if (evaluation.getErrorCode() == null) {
successfulResults.put(providerName, evaluation);
} else {
providerErrors.put(
providerName,
ProviderError.fromResult(
providerName, evaluation.getErrorCode(), evaluation.getErrorMessage()));
}
} catch (Exception e) {
providerErrors.put(providerName, ProviderError.fromException(providerName, e));
}
return null;
});
}
List<Future<Void>> futures = executorService.invokeAll(tasks, timeoutMs, TimeUnit.MILLISECONDS);
for (Future<Void> future : futures) {
if (future.isCancelled()) {
return errorResult(
"Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);
}
future.get();
}
Comment on lines +115 to +147

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Record each timed-out provider before returning the aggregate error.

At Line 140, invokeAll cancels unfinished tasks. Lines 142-145 return an error before adding their provider names to providerErrors. A timeout can therefore produce an empty MultiProviderEvaluation.providerErrors list.

Track each future with its provider name. Add a ProviderError for every cancelled future before calling errorResult. Add a regression test that blocks a provider and asserts its timeout error is present.

Proposed fix
 List<Callable<Void>> tasks = new ArrayList<>(providers.size());
+List<String> providerNames = new ArrayList<>(providers.size());
 for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
     String providerName = entry.getKey();
     FeatureProvider provider = entry.getValue();
+    providerNames.add(providerName);
     tasks.add(() -> {
         // ...
     });
 }
 List<Future<Void>> futures = executorService.invokeAll(tasks, timeoutMs, TimeUnit.MILLISECONDS);
-for (Future<Void> future : futures) {
+boolean timedOut = false;
+for (int index = 0; index < futures.size(); index++) {
+    Future<Void> future = futures.get(index);
     if (future.isCancelled()) {
-        return errorResult(
-                "Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);
+        String providerName = providerNames.get(index);
+        providerErrors.putIfAbsent(
+                providerName,
+                ProviderError.fromResult(
+                        providerName,
+                        ErrorCode.GENERAL,
+                        "timed out after " + timeoutMs + "ms"));
+        timedOut = true;
+        continue;
     }
     future.get();
 }
+if (timedOut) {
+    return errorResult(
+            "Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
List<Callable<Void>> tasks = new ArrayList<>(providers.size());
for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
String providerName = entry.getKey();
FeatureProvider provider = entry.getValue();
tasks.add(() -> {
try {
ProviderEvaluation<T> evaluation = providerFunction.apply(provider);
if (evaluation == null) {
providerErrors.put(
providerName,
ProviderError.fromResult(providerName, ErrorCode.GENERAL, "null evaluation"));
} else if (evaluation.getErrorCode() == null) {
successfulResults.put(providerName, evaluation);
} else {
providerErrors.put(
providerName,
ProviderError.fromResult(
providerName, evaluation.getErrorCode(), evaluation.getErrorMessage()));
}
} catch (Exception e) {
providerErrors.put(providerName, ProviderError.fromException(providerName, e));
}
return null;
});
}
List<Future<Void>> futures = executorService.invokeAll(tasks, timeoutMs, TimeUnit.MILLISECONDS);
for (Future<Void> future : futures) {
if (future.isCancelled()) {
return errorResult(
"Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);
}
future.get();
}
List<Callable<Void>> tasks = new ArrayList<>(providers.size());
List<String> providerNames = new ArrayList<>(providers.size());
for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
String providerName = entry.getKey();
FeatureProvider provider = entry.getValue();
providerNames.add(providerName);
tasks.add(() -> {
try {
ProviderEvaluation<T> evaluation = providerFunction.apply(provider);
if (evaluation == null) {
providerErrors.put(
providerName,
ProviderError.fromResult(providerName, ErrorCode.GENERAL, "null evaluation"));
} else if (evaluation.getErrorCode() == null) {
successfulResults.put(providerName, evaluation);
} else {
providerErrors.put(
providerName,
ProviderError.fromResult(
providerName, evaluation.getErrorCode(), evaluation.getErrorMessage()));
}
} catch (Exception e) {
providerErrors.put(providerName, ProviderError.fromException(providerName, e));
}
return null;
});
}
List<Future<Void>> futures = executorService.invokeAll(tasks, timeoutMs, TimeUnit.MILLISECONDS);
boolean timedOut = false;
for (int index = 0; index < futures.size(); index++) {
Future<Void> future = futures.get(index);
if (future.isCancelled()) {
String providerName = providerNames.get(index);
providerErrors.putIfAbsent(
providerName,
ProviderError.fromResult(
providerName,
ErrorCode.GENERAL,
"timed out after " + timeoutMs + "ms"));
timedOut = true;
continue;
}
future.get();
}
if (timedOut) {
return errorResult(
"Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java`
around lines 115 - 147, Update the task/future tracking in the comparison
strategy around providerFunction and invokeAll so each Future is associated with
its provider name. Before returning from the future.isCancelled() branch, add a
ProviderError for every cancelled provider to providerErrors, then call
errorResult as before. Add a regression test that blocks a provider, triggers
the timeout, and verifies the timed-out provider appears in the aggregate
providerErrors.

} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return errorResult("Comparison strategy interrupted: " + e.getMessage(), providers, providerErrors);
} catch (Exception e) {
return errorResult("Comparison strategy failed: " + e.getMessage(), providers, providerErrors);
}

if (!providerErrors.isEmpty()) {
return errorResult("Provider errors during comparison", providers, providerErrors);
}

ProviderEvaluation<T> fallbackResult = successfulResults.get(fallbackProvider);
if (fallbackResult == null) {
return errorResult(
"Fallback provider did not return a successful evaluation: " + fallbackProvider,
providers,
providerErrors);
}

if (allEvaluationsMatch(successfulResults)) {
return fallbackResult;
}

if (onMismatch != null) {
onMismatch.accept(key, orderedResults(providers, successfulResults));
}
return fallbackResult;
}
Comment on lines +94 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Reduce evaluate cognitive complexity to pass the configured check.

SonarCloud reports complexity 20 at Line 94. The configured limit is 15. Extract provider task creation and future-result collection into private methods. Preserve the current ordering, timeout, and interruption behavior.

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[failure] 94-94: Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=open-feature_java-sdk&issues=AZ_TunwAcxQN4hDLcMwd&open=AZ_TunwAcxQN4hDLcMwd&pullRequest=1897

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java`
around lines 94 - 175, Reduce cognitive complexity in
ComparisonStrategy.evaluate by extracting provider task creation and
future-result collection into private helper methods. Keep the existing provider
iteration order, timeout handling, cancellation behavior, Future.get
propagation, and interruption restoration unchanged; evaluate should continue
delegating to those helpers while preserving current errorResult handling.

Source: Linters/SAST tools


/**
* Builds a {@link MultiProviderEvaluation} carrying per-provider error details, ordered by the
* provider registration order so the aggregate message is stable across runs.
*/
private <T> ProviderEvaluation<T> errorResult(
String baseMessage, Map<String, FeatureProvider> providers, Map<String, ProviderError> providerErrors) {
List<ProviderError> orderedErrors = new ArrayList<>(providerErrors.size());
for (String providerName : providers.keySet()) {
ProviderError error = providerErrors.get(providerName);
if (error != null) {
orderedErrors.add(error);
}
}
return MultiProviderEvaluation.<T>builder()
.errorCode(ErrorCode.GENERAL)
.errorMessage(ProviderError.buildAggregateMessage(baseMessage, orderedErrors))
.providerErrors(orderedErrors)
.build();
}

/** Returns the successful evaluations in provider registration order. */
private <T> Map<String, ProviderEvaluation<?>> orderedResults(
Map<String, FeatureProvider> providers, Map<String, ProviderEvaluation<T>> successfulResults) {
Map<String, ProviderEvaluation<?>> ordered = new LinkedHashMap<>();
for (String providerName : providers.keySet()) {
ProviderEvaluation<T> evaluation = successfulResults.get(providerName);
if (evaluation != null) {
ordered.put(providerName, evaluation);
}
}
return Collections.unmodifiableMap(ordered);
}

private <T> boolean allEvaluationsMatch(Map<String, ProviderEvaluation<T>> results) {
ProviderEvaluation<T> baseline = null;
for (ProviderEvaluation<T> evaluation : results.values()) {
if (baseline == null) {
baseline = evaluation;
continue;
}
if (!Objects.equals(baseline.getValue(), evaluation.getValue())) {
return false;
}
}
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package dev.openfeature.sdk.multiprovider;

import dev.openfeature.sdk.ClientMetadata;
import java.util.Map;

/** Captures hook lifecycle context (client metadata and hints) for per-provider hook execution. */
final class HookExecutionContext {
final ClientMetadata clientMetadata;
final Map<String, Object> hints;

HookExecutionContext(ClientMetadata clientMetadata, Map<String, Object> hints) {
this.clientMetadata = clientMetadata;
this.hints = hints;
}
}
Loading
Loading