-
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,63 @@ | ||
| 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 ignored) { | ||
| // do not log in hot path, just skip | ||
| } | ||
| } | ||
|
|
||
| // 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,48 @@ | ||
| 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 ignored) { | ||
| // swallow and continue; errors from individual providers | ||
| // are not fatal for this strategy | ||
| } | ||
| } | ||
|
|
||
| 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,180 @@ | ||
| 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.Objects; | ||
| 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 { | ||
|
|
||
| @Getter | ||
| private static final String NAME = "multiprovider"; | ||
|
|
||
| // Use CPU count as upper bound for init threads. | ||
| public static final int INIT_THREADS_COUNT = Runtime.getRuntime().availableProcessors(); | ||
|
|
||
| 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, new FirstMatchStrategy()); | ||
| } | ||
|
|
||
| /** | ||
| * 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); | ||
| this.strategy = Objects.requireNonNull(strategy, "strategy must not be null"); | ||
| } | ||
|
|
||
| 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.info("duplicated provider name: {}", provider.getMetadata().getName()); | ||
| } | ||
| } | ||
| 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<>(); | ||
|
Contributor
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. Considering https://github.com/open-feature/java-sdk/pull/1765/changes#r2618228793, this should maybe be a KeyValuePair List
Contributor
Author
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. For now I kept originalMetadata as Map<String, Metadata> to match how buildProviders currently treats provider names as keys. |
||
|
|
||
| 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 via the | ||
| // standard shutdown path to avoid a partial/limbo state. | ||
| try { | ||
| shutdown(); | ||
| } catch (Exception shutdownEx) { | ||
| log.error("error during shutdown after failed initialize", 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.
The
MultiProviderdoes not emit any lifecycle events. If we don't want to support this in the first iteration it is sufficient to implementFeatureProviderinstead of extendingEventProvider.Although we should take care of this in a follow up asap, because we effectively loose the Event features when using
MultiProvider.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.
Agreed that the current MultiProvider doesn’t emit events yet.
For now I’ve kept it extending EventProvider to avoid changing the surface again during the move, and to keep the door open for proper event propagation in a follow-up.
I’ll open a follow-up issue to track “Emit provider lifecycle/configuration events from MultiProvider” so we can design the right event semantics without blocking this migration PR.