-
Notifications
You must be signed in to change notification settings - Fork 51
feat: add multi-provider to SDK #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
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
63 changes: 63 additions & 0 deletions
63
src/main/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategy.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } |
48 changes: 48 additions & 0 deletions
48
src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } |
179 changes: 179 additions & 0 deletions
179
src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| 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<>(); | ||
toddbaert marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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(); | ||
| } | ||
| } | ||
20 changes: 20 additions & 0 deletions
20
src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderMetadata.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.