Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package dev.openfeature.sdk;

import dev.openfeature.sdk.exceptions.ExceptionUtils;
import dev.openfeature.sdk.exceptions.OpenFeatureError;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;

/**
* Runs a single provider's own hooks around one evaluation, delegating to {@link HookSupport} so a
* child provider's hooks behave the same as when the provider is used standalone.
*
* <p><b>Internal.</b> Public only so {@link dev.openfeature.sdk.multiprovider.MultiProvider} can
* reach it across packages; {@link HookSupport} and its collaborators are package-private. Not part
* of the public API; may change or be removed without notice.
*
* @hidden
*/
public final class MultiProviderHookExecutor {

private final HookSupport hookSupport = new HookSupport();

/**
* Runs {@code provider}'s hooks around the evaluation performed by {@code providerFunction}.
*/
public <T> ProviderEvaluation<T> execute(

Check warning on line 27 in src/main/java/dev/openfeature/sdk/MultiProviderHookExecutor.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method has 8 parameters, which is greater than 7 authorized.

See more on https://sonarcloud.io/project/issues?id=open-feature_java-sdk&issues=AaDZ4WNGbvARA-dVO04V&open=AaDZ4WNGbvARA-dVO04V&pullRequest=2005
FeatureProvider provider,
String key,
T defaultValue,
FlagValueType type,
EvaluationContext context,
ClientMetadata clientMetadata,
Map<String, Object> hints,
BiFunction<FeatureProvider, EvaluationContext, ProviderEvaluation<T>> providerFunction) {

List<Hook> providerHooks = provider.getProviderHooks();
if (providerHooks == null || providerHooks.isEmpty()) {
return providerFunction.apply(provider, context);
}

HookSupportData data = new HookSupportData();
data.hints = hints == null ? Collections.emptyMap() : hints;
LayeredEvaluationContext layeredContext = new LayeredEvaluationContext(null, null, null, context);
data.evaluationContext = layeredContext;

hookSupport.setHooks(
data, providerHooks, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), type);
if (data.getHooks().isEmpty()) {
// no hooks support this type
return providerFunction.apply(provider, context);
}

SharedHookContext<T> sharedHookContext =
new SharedHookContext<>(key, type, clientMetadata, provider.getMetadata(), defaultValue);
hookSupport.setHookContexts(data, sharedHookContext, layeredContext);

FlagEvaluationDetails<T> details = null;
try {
hookSupport.executeBeforeHooks(data);
ProviderEvaluation<T> providerEvaluation = providerFunction.apply(provider, data.getEvaluationContext());
details = FlagEvaluationDetails.from(providerEvaluation, key);
if (details.getErrorCode() != null) {
Exception error =
ExceptionUtils.instantiateErrorByErrorCode(details.getErrorCode(), details.getErrorMessage());
enrichDetailsWithErrorDefaults(defaultValue, details);
hookSupport.executeErrorHooks(data, error);
} else {
hookSupport.executeAfterHooks(data, details);
}
return providerEvaluation;
} catch (Exception e) {
if (details == null) {
details = FlagEvaluationDetails.<T>builder().flagKey(key).build();
}
if (e instanceof OpenFeatureError) {
details.setErrorCode(((OpenFeatureError) e).getErrorCode());
} else {
details.setErrorCode(ErrorCode.GENERAL);
}
details.setErrorMessage(e.getMessage());
enrichDetailsWithErrorDefaults(defaultValue, details);
hookSupport.executeErrorHooks(data, e);
throw e;
} finally {
// details is always set by now: from the evaluation on success, or the catch on failure
hookSupport.executeAfterAllHooks(data, details);
}
}

private static <T> void enrichDetailsWithErrorDefaults(T defaultValue, FlagEvaluationDetails<T> details) {
details.setValue(defaultValue);
details.setReason(Reason.ERROR.toString());
}
}
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;
}
}
146 changes: 137 additions & 9 deletions src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package dev.openfeature.sdk.multiprovider;

import dev.openfeature.sdk.ClientMetadata;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.EventProvider;
import dev.openfeature.sdk.FeatureProvider;
import dev.openfeature.sdk.FlagEvaluationDetails;
import dev.openfeature.sdk.FlagValueType;
import dev.openfeature.sdk.Hook;
import dev.openfeature.sdk.HookContext;
import dev.openfeature.sdk.Metadata;
import dev.openfeature.sdk.MultiProviderHookExecutor;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.Value;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
Expand All @@ -15,10 +21,12 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.function.BiFunction;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;

Expand All @@ -28,6 +36,9 @@
* <p>This provider delegates flag evaluations to multiple underlying providers using a configurable
* {@link Strategy}. It also exposes combined metadata containing the original metadata of each
* underlying provider.
*
* <p>Hooks registered on the child providers are executed around each child evaluation, so a child
* provider's own hooks observe the evaluation it takes part in.
*/
@Slf4j
public class MultiProvider extends EventProvider {
Expand All @@ -40,6 +51,12 @@ public class MultiProvider extends EventProvider {

private final Map<String, FeatureProvider> providers;
private final Strategy strategy;

// side-channel for hook-context not passed to resolvers; assumes before/resolve share a thread
private final ThreadLocal<HookExecutionContext> localHookExecutionContext = new ThreadLocal<>();
private final ClientMetadata hookClientMetadata = MultiProvider::getNAME;
private final MultiProviderHookExecutor hookExecutor = new MultiProviderHookExecutor();

private MultiProviderMetadata metadata;

/**
Expand All @@ -63,6 +80,37 @@ public MultiProvider(List<FeatureProvider> providers, Strategy strategy) {
this.strategy = Objects.requireNonNull(strategy, "strategy must not be null");
}

@SuppressWarnings({"rawtypes", "unchecked"})
private final List<Hook> providerHooks = List.of(new Hook() {
@Override
public Optional before(HookContext ctx, Map hints) {
localHookExecutionContext.set(new HookExecutionContext(ctx.getClientMetadata(), snapshotHints(hints)));
return Optional.empty();
}

@Override
public void finallyAfter(HookContext ctx, FlagEvaluationDetails details, Map hints) {
localHookExecutionContext.remove();
}
});

/**
* Provider-level hooks for this MultiProvider. Includes a {@code before} hook that captures the
* {@link ClientMetadata} and hints from the SDK lifecycle for use during per-child hook execution.
*/
@Override
public List<Hook> getProviderHooks() {
return providerHooks;
}

// defensive copy: mutable hookHints may be read by parallel strategies; plain copy allows nulls
private static Map<String, Object> snapshotHints(Map<String, Object> hints) {
if (hints == null || hints.isEmpty()) {
return Collections.emptyMap();
}
return Collections.unmodifiableMap(new HashMap<>(hints));
}

protected static Map<String, FeatureProvider> buildProviders(List<FeatureProvider> providers) {
Map<String, FeatureProvider> providersMap = new LinkedHashMap<>(providers.size());
for (FeatureProvider provider : providers) {
Expand Down Expand Up @@ -119,13 +167,11 @@ public void initialize(EvaluationContext evaluationContext, String domain) throw

List<Future<Void>> results = executorService.invokeAll(tasks);
for (Future<Void> result : results) {
// This will re-throw any exception from the provider's initialize method,
// wrapped in an ExecutionException.
// re-throws any provider initialize exception, wrapped in an ExecutionException
result.get();
}
} catch (Exception e) {
// If initialization fails for any provider, attempt to shut down via the
// standard shutdown path to avoid a partial/limbo state.
// on any provider init failure, shut down via the standard path to avoid a limbo state
try {
shutdown();
} catch (Exception shutdownEx) {
Expand All @@ -147,29 +193,111 @@ public Metadata getMetadata() {

@Override
public ProviderEvaluation<Boolean> getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) {
HookExecutionContext hookCtx = currentHookExecutionContext();
return strategy.evaluate(
providers, key, defaultValue, ctx, p -> p.getBooleanEvaluation(key, defaultValue, ctx));
providers,
key,
defaultValue,
ctx,
provider -> evaluateChild(
hookCtx,
provider,
key,
defaultValue,
ctx,
FlagValueType.BOOLEAN,
(p, evaluationContext) -> p.getBooleanEvaluation(key, defaultValue, evaluationContext)));
}

@Override
public ProviderEvaluation<String> getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) {
return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getStringEvaluation(key, defaultValue, ctx));
HookExecutionContext hookCtx = currentHookExecutionContext();
return strategy.evaluate(
providers,
key,
defaultValue,
ctx,
provider -> evaluateChild(
hookCtx,
provider,
key,
defaultValue,
ctx,
FlagValueType.STRING,
(p, evaluationContext) -> p.getStringEvaluation(key, defaultValue, evaluationContext)));
}

@Override
public ProviderEvaluation<Integer> getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) {
HookExecutionContext hookCtx = currentHookExecutionContext();
return strategy.evaluate(
providers, key, defaultValue, ctx, p -> p.getIntegerEvaluation(key, defaultValue, ctx));
providers,
key,
defaultValue,
ctx,
provider -> evaluateChild(
hookCtx,
provider,
key,
defaultValue,
ctx,
FlagValueType.INTEGER,
(p, evaluationContext) -> p.getIntegerEvaluation(key, defaultValue, evaluationContext)));
}

@Override
public ProviderEvaluation<Double> getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) {
return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getDoubleEvaluation(key, defaultValue, ctx));
HookExecutionContext hookCtx = currentHookExecutionContext();
return strategy.evaluate(
providers,
key,
defaultValue,
ctx,
provider -> evaluateChild(
hookCtx,
provider,
key,
defaultValue,
ctx,
FlagValueType.DOUBLE,
(p, evaluationContext) -> p.getDoubleEvaluation(key, defaultValue, evaluationContext)));
}

@Override
public ProviderEvaluation<Value> getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) {
return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getObjectEvaluation(key, defaultValue, ctx));
HookExecutionContext hookCtx = currentHookExecutionContext();
return strategy.evaluate(
providers,
key,
defaultValue,
ctx,
provider -> evaluateChild(
hookCtx,
provider,
key,
defaultValue,
ctx,
FlagValueType.OBJECT,
(p, evaluationContext) -> p.getObjectEvaluation(key, defaultValue, evaluationContext)));
}

// runs the child's own hooks; caller-thread snapshot
private <T> ProviderEvaluation<T> evaluateChild(
HookExecutionContext hookExecutionContext,
FeatureProvider provider,
String key,
T defaultValue,
EvaluationContext ctx,
FlagValueType type,
BiFunction<FeatureProvider, EvaluationContext, ProviderEvaluation<T>> providerFunction) {
ClientMetadata clientMetadata =
hookExecutionContext != null ? hookExecutionContext.clientMetadata : hookClientMetadata;
Map<String, Object> hints = hookExecutionContext != null ? hookExecutionContext.hints : Collections.emptyMap();
return hookExecutor.execute(provider, key, defaultValue, type, ctx, clientMetadata, hints, providerFunction);
}

private HookExecutionContext currentHookExecutionContext() {
return localHookExecutionContext.get();
}

@Override
Expand Down
Loading
Loading