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
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ See [here](https://javadoc.io/doc/dev.openfeature/sdk/latest/) for the Javadocs.
| ------ |---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
| ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
| ✅ | [Multi-provider (experimental)](#multi-provider-experimental) | Combine multiple providers and delegate evaluations according to a strategy. |
| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
| ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
| ✅ | [Logging](#logging) | Integrate with popular logging packages. |
Expand All @@ -147,7 +148,40 @@ Look [here](https://openfeature.dev/ecosystem?instant_search%5BrefinementList%5D
If the provider you're looking for hasn't been created yet, see the [develop a provider](#develop-a-provider) section to learn how to build it yourself.

Once you've added a provider as a dependency, it can be registered with OpenFeature like this:


In some situations, it may be beneficial to register multiple providers in the same application.
This is possible using [domains](#domains), which is covered in more detail below.

#### Multi-provider (experimental)

In addition to domains, you may want to delegate flag evaluation across multiple providers using a configurable strategy.
The multi-provider allows you to compose several `FeatureProvider` implementations and determine which provider's result to use.

> **Experimental:** This API is experimental and may change in future releases.

```java
import dev.openfeature.sdk.OpenFeatureAPI;
import dev.openfeature.sdk.Client;
import dev.openfeature.sdk.FeatureProvider;
import dev.openfeature.sdk.multiprovider.MultiProvider;

import java.util.List;

public void multiProviderExample() throws Exception {
FeatureProvider primaryProvider = new MyPrimaryProvider();
FeatureProvider fallbackProvider = new MyFallbackProvider();

MultiProvider multiProvider = new MultiProvider(List.of(primaryProvider, fallbackProvider));

OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProviderAndWait(multiProvider);

Client client = api.getClient();
boolean value = client.getBooleanValue("some-flag", false);
}
```


#### Synchronous

To register a provider in a blocking manner to ensure it is ready before further actions are taken, you can use the `setProviderAndWait` method as shown below:
Expand Down
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();
}
}
180 changes: 180 additions & 0 deletions src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
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 {
Copy link
Member

Choose a reason for hiding this comment

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

The MultiProvider does not emit any lifecycle events. If we don't want to support this in the first iteration it is sufficient to implement FeatureProvider instead of extending EventProvider.

Although we should take care of this in a follow up asap, because we effectively loose the Event features when using MultiProvider.

Copy link
Contributor Author

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.


@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<>();
Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.
I agree a List<KeyValuePair<String, Metadata>> could better represent duplicate names; happy to open a follow-up issue to discuss that representation and adjust the metadata type there if we decide to support duplicates fully.


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;
}
Loading
Loading