-
Notifications
You must be signed in to change notification settings - Fork 51
feat: move multi-provider into SDK and deprecate contrib implementation (open-feature#1486) #1765
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| package dev.openfeature.sdk.multiprovider; | ||
|
|
||
| import static dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND; | ||
|
|
||
| import dev.openfeature.sdk.ErrorCode; | ||
| import dev.openfeature.sdk.EvaluationContext; | ||
| import dev.openfeature.sdk.FeatureProvider; | ||
| import dev.openfeature.sdk.ProviderEvaluation; | ||
| import dev.openfeature.sdk.exceptions.FlagNotFoundError; | ||
| import java.util.Map; | ||
| import java.util.function.Function; | ||
| import lombok.NoArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| /** | ||
| * First match strategy. | ||
| * | ||
| * <p>Return the first result returned by a provider. | ||
| * <ul> | ||
| * <li>Skip providers that indicate they had no value due to {@code FLAG_NOT_FOUND}.</li> | ||
| * <li>On any other error code, return that error result.</li> | ||
| * <li>If a provider throws {@link FlagNotFoundError}, it is treated like {@code FLAG_NOT_FOUND}.</li> | ||
| * <li>If all providers report {@code FLAG_NOT_FOUND}, return a {@code FLAG_NOT_FOUND} error.</li> | ||
| * </ul> | ||
| * As soon as a non-{@code FLAG_NOT_FOUND} result is returned by a provider (success or other error), | ||
| * the rest of the operation short-circuits and does not call the remaining providers. | ||
| */ | ||
| @Slf4j | ||
| @NoArgsConstructor | ||
| public class FirstMatchStrategy implements Strategy { | ||
|
|
||
| @Override | ||
| public <T> ProviderEvaluation<T> evaluate( | ||
| Map<String, FeatureProvider> providers, | ||
| String key, | ||
| T defaultValue, | ||
| EvaluationContext ctx, | ||
| Function<FeatureProvider, ProviderEvaluation<T>> providerFunction) { | ||
| for (FeatureProvider provider : providers.values()) { | ||
| try { | ||
| ProviderEvaluation<T> res = providerFunction.apply(provider); | ||
| ErrorCode errorCode = res.getErrorCode(); | ||
| if (errorCode == null) { | ||
| // Successful evaluation | ||
| return res; | ||
| } | ||
| if (!FLAG_NOT_FOUND.equals(errorCode)) { | ||
| // Any non-FLAG_NOT_FOUND error bubbles up | ||
| return res; | ||
| } | ||
| // else FLAG_NOT_FOUND: skip to next provider | ||
| } catch (FlagNotFoundError e) { | ||
| log.debug( | ||
| "flag not found {} in provider {}", | ||
| key, | ||
| provider.getMetadata().getName(), | ||
| e); | ||
| } | ||
| } | ||
|
|
||
| // All providers either threw or returned FLAG_NOT_FOUND | ||
| return ProviderEvaluation.<T>builder() | ||
| .errorMessage("Flag not found in any provider") | ||
| .errorCode(FLAG_NOT_FOUND) | ||
| .build(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| 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.Map; | ||
| import java.util.function.Function; | ||
| import lombok.NoArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| /** | ||
| * First Successful Strategy. | ||
| * | ||
| * <p>Similar to “First Match”, except that errors from evaluated providers do not halt execution. | ||
| * Instead, it returns the first successful result from a provider. If no provider successfully | ||
| * responds, it returns a {@code GENERAL} error result. | ||
| */ | ||
| @Slf4j | ||
| @NoArgsConstructor | ||
| public class FirstSuccessfulStrategy implements Strategy { | ||
|
|
||
| @Override | ||
| public <T> ProviderEvaluation<T> evaluate( | ||
| Map<String, FeatureProvider> providers, | ||
| String key, | ||
| T defaultValue, | ||
| EvaluationContext ctx, | ||
| Function<FeatureProvider, ProviderEvaluation<T>> providerFunction) { | ||
| for (FeatureProvider provider : providers.values()) { | ||
| try { | ||
| ProviderEvaluation<T> res = providerFunction.apply(provider); | ||
| if (res.getErrorCode() == null) { | ||
| // First successful result (no error code) | ||
| return res; | ||
| } | ||
| } catch (Exception e) { | ||
| log.debug( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We usually do not log in hot code paths but can see the value of having a debug message here. |
||
| "evaluation exception for key {} in provider {}", | ||
| key, | ||
| provider.getMetadata().getName(), | ||
| e); | ||
| } | ||
| } | ||
|
|
||
| return ProviderEvaluation.<T>builder() | ||
| .errorMessage("No provider successfully responded") | ||
| .errorCode(ErrorCode.GENERAL) | ||
| .build(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| package dev.openfeature.sdk.multiprovider; | ||
|
|
||
| import dev.openfeature.sdk.EvaluationContext; | ||
| import dev.openfeature.sdk.EventProvider; | ||
| import dev.openfeature.sdk.FeatureProvider; | ||
| import dev.openfeature.sdk.Metadata; | ||
| import dev.openfeature.sdk.ProviderEvaluation; | ||
| import dev.openfeature.sdk.Value; | ||
| import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; | ||
| import java.util.ArrayList; | ||
| import java.util.Collection; | ||
| import java.util.Collections; | ||
| import java.util.HashMap; | ||
| import java.util.LinkedHashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.concurrent.Callable; | ||
| import java.util.concurrent.ExecutorService; | ||
| import java.util.concurrent.Executors; | ||
| import java.util.concurrent.Future; | ||
| import lombok.Getter; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| /** | ||
| * <b>Experimental:</b> Provider implementation for multi-provider. | ||
| * | ||
| * <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. | ||
| */ | ||
| @Slf4j | ||
| public class MultiProvider extends EventProvider { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Although we should take care of this in a follow up asap, because we effectively loose the Event features when using |
||
|
|
||
| @Getter | ||
| private static final String NAME = "multiprovider"; | ||
|
|
||
| public static final int INIT_THREADS_COUNT = 8; | ||
|
|
||
| private final Map<String, FeatureProvider> providers; | ||
| private final Strategy strategy; | ||
| private MultiProviderMetadata metadata; | ||
|
|
||
| /** | ||
| * Constructs a MultiProvider with the given list of FeatureProviders, by default uses | ||
| * {@link FirstMatchStrategy}. | ||
| * | ||
| * @param providers the list of FeatureProviders to initialize the MultiProvider with | ||
| */ | ||
| public MultiProvider(List<FeatureProvider> providers) { | ||
| this(providers, null); | ||
| } | ||
|
|
||
| /** | ||
| * Constructs a MultiProvider with the given list of FeatureProviders and a strategy. | ||
| * | ||
| * @param providers the list of FeatureProviders to initialize the MultiProvider with | ||
| * @param strategy the strategy (if {@code null}, {@link FirstMatchStrategy} is used) | ||
| */ | ||
| public MultiProvider(List<FeatureProvider> providers, Strategy strategy) { | ||
| this.providers = buildProviders(providers); | ||
| if (strategy != null) { | ||
| this.strategy = strategy; | ||
| } else { | ||
| this.strategy = new FirstMatchStrategy(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we assume that I'd rather prefer a |
||
| } | ||
| } | ||
|
|
||
| protected static Map<String, FeatureProvider> buildProviders(List<FeatureProvider> providers) { | ||
| Map<String, FeatureProvider> providersMap = new LinkedHashMap<>(providers.size()); | ||
| for (FeatureProvider provider : providers) { | ||
| FeatureProvider prevProvider = | ||
| providersMap.put(provider.getMetadata().getName(), provider); | ||
| if (prevProvider != null) { | ||
| log.warn("duplicated provider name: {}", provider.getMetadata().getName()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we want to prevent this? @aepfli, @chrfwow do we treat the provider name as unique identifier in the SDK? And if, we should throw because it would be a clear misconfiguration. Scenario where I could see the same provider twice is e.g., using flagd providers that should read from multiple flag configuration files. According to the spec, the provider name identifies the implementation, not the instance. |
||
| } | ||
| } | ||
| return Collections.unmodifiableMap(providersMap); | ||
| } | ||
|
|
||
| /** | ||
| * Initialize the provider. | ||
| * | ||
| * @param evaluationContext evaluation context | ||
| * @throws Exception on error (e.g. wrapped {@link java.util.concurrent.ExecutionException} | ||
| * from a failing provider) | ||
| */ | ||
| @Override | ||
| public void initialize(EvaluationContext evaluationContext) throws Exception { | ||
| var metadataBuilder = MultiProviderMetadata.builder().name(NAME); | ||
| HashMap<String, Metadata> providersMetadata = new HashMap<>(); | ||
|
|
||
| if (providers.isEmpty()) { | ||
| metadataBuilder.originalMetadata(Collections.unmodifiableMap(providersMetadata)); | ||
| metadata = metadataBuilder.build(); | ||
| return; | ||
| } | ||
|
|
||
| ExecutorService executorService = Executors.newFixedThreadPool(Math.min(INIT_THREADS_COUNT, providers.size())); | ||
| try { | ||
| Collection<Callable<Void>> tasks = new ArrayList<>(providers.size()); | ||
| for (FeatureProvider provider : providers.values()) { | ||
| tasks.add(() -> { | ||
| provider.initialize(evaluationContext); | ||
| return null; | ||
| }); | ||
| Metadata providerMetadata = provider.getMetadata(); | ||
| providersMetadata.put(providerMetadata.getName(), providerMetadata); | ||
| } | ||
|
|
||
| metadataBuilder.originalMetadata(Collections.unmodifiableMap(providersMetadata)); | ||
|
|
||
| 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. | ||
| result.get(); | ||
| } | ||
| } catch (Exception e) { | ||
| // If initialization fails for any provider, attempt to shut down all providers | ||
| // to avoid a partial/limbo state. | ||
| for (FeatureProvider provider : providers.values()) { | ||
| try { | ||
| provider.shutdown(); | ||
| } catch (Exception shutdownEx) { | ||
| log.error( | ||
| "error shutting down provider {} after failed initialize", | ||
| provider.getMetadata().getName(), | ||
| shutdownEx); | ||
| } | ||
| } | ||
| throw e; | ||
| } finally { | ||
| executorService.shutdown(); | ||
| } | ||
|
|
||
| metadata = metadataBuilder.build(); | ||
| } | ||
|
|
||
| @SuppressFBWarnings(value = "EI_EXPOSE_REP") | ||
| @Override | ||
| public Metadata getMetadata() { | ||
| return metadata; | ||
| } | ||
|
|
||
| @Override | ||
| public ProviderEvaluation<Boolean> getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { | ||
| return strategy.evaluate( | ||
| providers, key, defaultValue, ctx, p -> p.getBooleanEvaluation(key, defaultValue, ctx)); | ||
| } | ||
|
|
||
| @Override | ||
| public ProviderEvaluation<String> getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { | ||
| return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getStringEvaluation(key, defaultValue, ctx)); | ||
| } | ||
|
|
||
| @Override | ||
| public ProviderEvaluation<Integer> getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { | ||
| return strategy.evaluate( | ||
| providers, key, defaultValue, ctx, p -> p.getIntegerEvaluation(key, defaultValue, ctx)); | ||
| } | ||
|
|
||
| @Override | ||
| public ProviderEvaluation<Double> getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { | ||
| return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getDoubleEvaluation(key, defaultValue, ctx)); | ||
| } | ||
|
|
||
| @Override | ||
| public ProviderEvaluation<Value> getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { | ||
| return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getObjectEvaluation(key, defaultValue, ctx)); | ||
| } | ||
|
|
||
| @Override | ||
| public void shutdown() { | ||
| log.debug("shutdown begin"); | ||
| for (FeatureProvider provider : providers.values()) { | ||
| try { | ||
| provider.shutdown(); | ||
| } catch (Exception e) { | ||
| log.error("error shutdown provider {}", provider.getMetadata().getName(), e); | ||
| } | ||
| } | ||
| log.debug("shutdown end"); | ||
| // Important: ensure EventProvider's executor is also shut down | ||
| super.shutdown(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package dev.openfeature.sdk.multiprovider; | ||
|
|
||
| import dev.openfeature.sdk.Metadata; | ||
| import java.util.Map; | ||
| import lombok.Builder; | ||
| import lombok.Value; | ||
|
|
||
| /** | ||
| * Metadata for {@link MultiProvider}. | ||
| * | ||
| * <p>Contains the multiprovider's own name and a map of the original metadata from each underlying | ||
| * provider. | ||
| */ | ||
| @Value | ||
| @Builder | ||
| public class MultiProviderMetadata implements Metadata { | ||
|
|
||
| String name; | ||
| Map<String, Metadata> originalMetadata; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We usually do not log in hot code paths like flag evaluations.