MultiProvider: Added strategies, unit tests and documentation

Signed-off-by: suvaidkhan <khansuvaid@yahoo.com>
This commit is contained in:
suvaidkhan 2025-07-01 02:10:29 -05:00
parent e67f598357
commit 58c308c25e
6 changed files with 540 additions and 0 deletions

View File

@ -70,6 +70,12 @@
<version>2.0.17</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20250517</version>
</dependency>
<!-- test -->
<dependency>
<groupId>com.tngtech.archunit</groupId>

View File

@ -0,0 +1,53 @@
package dev.openfeature.sdk.multiprovider;
import static dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND;
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. Return the first result returned by a provider. Skip providers that
* indicate they had no value due to FLAG_NOT_FOUND. In all other cases, use the value returned by
* the provider. If any provider returns an error result other than FLAG_NOT_FOUND, the whole
* evaluation should error and bubble up the individual providers error in the result. As soon as
* a value is returned by a provider, the rest of the operation should short-circuit and not call
* the rest of the providers.
*/
@Slf4j
@NoArgsConstructor
public class FirstMatchStrategy implements Strategy {
/**
* Represents a strategy that evaluates providers based on a first-match approach. Provides a
* method to evaluate providers using a specified function and return the evaluation result.
*
* @param providerFunction provider function
* @param <T> ProviderEvaluation type
* @return the provider evaluation
*/
@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 (!FLAG_NOT_FOUND.equals(res.getErrorCode())) {
return res;
}
} catch (FlagNotFoundError e) {
log.debug("flag not found {}", e.getMessage());
}
}
throw new FlagNotFoundError("flag not found");
}
}

View File

@ -0,0 +1,41 @@
package dev.openfeature.sdk.multiprovider;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.FeatureProvider;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.exceptions.GeneralError;
import java.util.Map;
import java.util.function.Function;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* First Successful Strategy. Similar to First Match, except that errors from evaluated providers
* do not halt execution. Instead, it will return the first successful result from a provider. If no
* provider successfully responds, it will throw an 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) {
return res;
}
} catch (Exception e) {
log.debug("evaluation exception {}", e.getMessage());
}
}
throw new GeneralError("evaluation error");
}
}

View File

@ -0,0 +1,148 @@
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 dev.openfeature.sdk.exceptions.GeneralError;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
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;
import org.json.JSONObject;
/** <b>Experimental:</b> Provider implementation for Multi-provider. */
@Slf4j
public class MultiProvider extends EventProvider {
@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 String metadataName;
/**
* Constructs a MultiProvider with the given list of FeatureProviders, using a default strategy.
*
* @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
*/
public MultiProvider(List<FeatureProvider> providers, Strategy strategy) {
this.providers = buildProviders(providers);
if (strategy != null) {
this.strategy = strategy;
} else {
this.strategy = new FirstMatchStrategy();
}
}
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());
}
}
return Collections.unmodifiableMap(providersMap);
}
/**
* Initialize the provider.
*
* @param evaluationContext evaluation context
* @throws Exception on error
*/
@Override
public void initialize(EvaluationContext evaluationContext) throws Exception {
JSONObject json = new JSONObject();
json.put("name", NAME);
JSONObject providersMetadata = new JSONObject();
json.put("originalMetadata", providersMetadata);
ExecutorService initPool = Executors.newFixedThreadPool(INIT_THREADS_COUNT);
Collection<Callable<Boolean>> tasks = new ArrayList<>(providers.size());
for (FeatureProvider provider : providers.values()) {
tasks.add(() -> {
provider.initialize(evaluationContext);
return true;
});
JSONObject providerMetadata = new JSONObject();
providerMetadata.put("name", provider.getMetadata().getName());
providersMetadata.put(provider.getMetadata().getName(), providerMetadata);
}
List<Future<Boolean>> results = initPool.invokeAll(tasks);
for (Future<Boolean> result : results) {
if (!result.get()) {
throw new GeneralError("init failed");
}
}
metadataName = json.toString();
}
@Override
public Metadata getMetadata() {
return () -> metadataName;
}
@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");
}
}

View File

@ -0,0 +1,17 @@
package dev.openfeature.sdk.multiprovider;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.FeatureProvider;
import dev.openfeature.sdk.ProviderEvaluation;
import java.util.Map;
import java.util.function.Function;
/** strategy. */
public interface Strategy {
<T> ProviderEvaluation<T> evaluate(
Map<String, FeatureProvider> providers,
String key,
T defaultValue,
EvaluationContext ctx,
Function<FeatureProvider, ProviderEvaluation<T>> providerFunction);
}

View File

@ -0,0 +1,275 @@
package dev.openfeature.sdk.multiProvider;
import static dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.FeatureProvider;
import dev.openfeature.sdk.Metadata;
import dev.openfeature.sdk.MutableContext;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.Value;
import dev.openfeature.sdk.exceptions.FlagNotFoundError;
import dev.openfeature.sdk.exceptions.GeneralError;
import dev.openfeature.sdk.multiprovider.FirstMatchStrategy;
import dev.openfeature.sdk.multiprovider.FirstSuccessfulStrategy;
import dev.openfeature.sdk.multiprovider.MultiProvider;
import dev.openfeature.sdk.multiprovider.Strategy;
import dev.openfeature.sdk.providers.memory.Flag;
import dev.openfeature.sdk.providers.memory.InMemoryProvider;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.function.Function;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Test;
class MultiProviderTest {
@SneakyThrows
@Test
public void testInit() {
FeatureProvider provider1 = mock(FeatureProvider.class);
FeatureProvider provider2 = mock(FeatureProvider.class);
when(provider1.getMetadata()).thenReturn(() -> "provider1");
when(provider2.getMetadata()).thenReturn(() -> "provider2");
List<FeatureProvider> providers = new ArrayList<>(2);
providers.add(provider1);
providers.add(provider2);
Strategy strategy = mock(Strategy.class);
MultiProvider multiProvider = new MultiProvider(providers, strategy);
multiProvider.initialize(null);
assertNotNull(multiProvider);
assertEquals(
"{\"originalMetadata\":{\"provider1\":{\"name\":\"provider1\"},"
+ "\"provider2\":{\"name\":\"provider2\"}},\"name\":\"multiprovider\"}",
multiProvider.getMetadata().getName());
}
@SneakyThrows
@Test
public void testInitOneFails() {
FeatureProvider provider1 = mock(FeatureProvider.class);
FeatureProvider provider2 = mock(FeatureProvider.class);
when(provider1.getMetadata()).thenReturn(() -> "provider1");
when(provider2.getMetadata()).thenReturn(() -> "provider2");
doThrow(new GeneralError()).when(provider1).initialize(any());
doThrow(new GeneralError()).when(provider1).shutdown();
List<FeatureProvider> providers = new ArrayList<>(2);
providers.add(provider1);
providers.add(provider2);
Strategy strategy = mock(Strategy.class);
MultiProvider multiProvider = new MultiProvider(providers, strategy);
assertThrows(ExecutionException.class, () -> multiProvider.initialize(null));
assertDoesNotThrow(() -> multiProvider.shutdown());
}
@Test
public void testDuplicateProviderNames() {
FeatureProvider provider1 = mock(FeatureProvider.class);
FeatureProvider provider2 = mock(FeatureProvider.class);
when(provider1.getMetadata()).thenReturn(() -> "provider");
when(provider2.getMetadata()).thenReturn(() -> "provider");
List<FeatureProvider> providers = new ArrayList<>(2);
providers.add(provider1);
providers.add(provider2);
assertDoesNotThrow(() -> new MultiProvider(providers, null).initialize(null));
}
@SneakyThrows
@Test
public void testRetrieveMetadataName() {
List<FeatureProvider> providers = new ArrayList<>();
FeatureProvider mockProvider = mock(FeatureProvider.class);
when(mockProvider.getMetadata()).thenReturn(() -> "MockProvider");
providers.add(mockProvider);
Strategy mockStrategy = mock(Strategy.class);
MultiProvider multiProvider = new MultiProvider(providers, mockStrategy);
multiProvider.initialize(null);
assertEquals(
"{\"originalMetadata\":{\"MockProvider\":{\"name\":\"MockProvider\"}}," + "\"name\":\"multiprovider\"}",
multiProvider.getMetadata().getName());
}
@SneakyThrows
@Test
public void testEvaluations() {
Map<String, Flag<?>> flags1 = new HashMap<>();
flags1.put(
"b1",
Flag.builder()
.variant("true", true)
.variant("false", false)
.defaultVariant("true")
.build());
flags1.put("i1", Flag.builder().variant("v", 1).defaultVariant("v").build());
flags1.put("d1", Flag.builder().variant("v", 1.0).defaultVariant("v").build());
flags1.put("s1", Flag.builder().variant("v", "str1").defaultVariant("v").build());
flags1.put(
"o1",
Flag.builder().variant("v", new Value("v1")).defaultVariant("v").build());
InMemoryProvider provider1 = new InMemoryProvider(flags1) {
public Metadata getMetadata() {
return () -> "old-provider";
}
};
Map<String, Flag<?>> flags2 = new HashMap<>();
flags2.put(
"b1",
Flag.builder()
.variant("true", true)
.variant("false", false)
.defaultVariant("false")
.build());
flags2.put("i1", Flag.builder().variant("v", 2).defaultVariant("v").build());
flags2.put("d1", Flag.builder().variant("v", 2.0).defaultVariant("v").build());
flags2.put("s1", Flag.builder().variant("v", "str2").defaultVariant("v").build());
flags2.put(
"o1",
Flag.builder().variant("v", new Value("v2")).defaultVariant("v").build());
flags2.put(
"s2", Flag.builder().variant("v", "s2str2").defaultVariant("v").build());
InMemoryProvider provider2 = new InMemoryProvider(flags2) {
public Metadata getMetadata() {
return () -> "new-provider";
}
};
List<FeatureProvider> providers = new ArrayList<>(2);
providers.add(provider1);
providers.add(provider2);
MultiProvider multiProvider = new MultiProvider(providers);
multiProvider.initialize(null);
assertEquals(true, multiProvider.getBooleanEvaluation("b1", false, null).getValue());
assertEquals(1, multiProvider.getIntegerEvaluation("i1", 0, null).getValue());
assertEquals(1.0, multiProvider.getDoubleEvaluation("d1", 0.0, null).getValue());
assertEquals("str1", multiProvider.getStringEvaluation("s1", "", null).getValue());
assertEquals(
"v1",
multiProvider.getObjectEvaluation("o1", null, null).getValue().asString());
assertEquals("s2str2", multiProvider.getStringEvaluation("s2", "", null).getValue());
MultiProvider finalMultiProvider1 = multiProvider;
assertThrows(FlagNotFoundError.class, () -> finalMultiProvider1.getStringEvaluation("non-existing", "", null));
multiProvider.shutdown();
multiProvider = new MultiProvider(providers, new FirstSuccessfulStrategy());
multiProvider.initialize(null);
assertEquals(true, multiProvider.getBooleanEvaluation("b1", false, null).getValue());
assertEquals(1, multiProvider.getIntegerEvaluation("i1", 0, null).getValue());
assertEquals(1.0, multiProvider.getDoubleEvaluation("d1", 0.0, null).getValue());
assertEquals("str1", multiProvider.getStringEvaluation("s1", "", null).getValue());
assertEquals(
"v1",
multiProvider.getObjectEvaluation("o1", null, null).getValue().asString());
assertEquals("s2str2", multiProvider.getStringEvaluation("s2", "", null).getValue());
MultiProvider finalMultiProvider2 = multiProvider;
assertThrows(GeneralError.class, () -> finalMultiProvider2.getStringEvaluation("non-existing", "", null));
multiProvider.shutdown();
Strategy customStrategy = new Strategy() {
final FirstMatchStrategy fallbackStrategy = new FirstMatchStrategy();
@Override
public <T> ProviderEvaluation<T> evaluate(
Map<String, FeatureProvider> providers,
String key,
T defaultValue,
EvaluationContext ctx,
Function<FeatureProvider, ProviderEvaluation<T>> providerFunction) {
Value contextProvider = null;
if (ctx != null) {
contextProvider = ctx.getValue("provider");
}
if (contextProvider != null && "new-provider".equals(contextProvider.asString())) {
return providerFunction.apply(providers.get("new-provider"));
}
return fallbackStrategy.evaluate(providers, key, defaultValue, ctx, providerFunction);
}
};
multiProvider = new MultiProvider(providers, customStrategy);
multiProvider.initialize(null);
EvaluationContext context = new MutableContext().add("provider", "new-provider");
assertEquals(
false, multiProvider.getBooleanEvaluation("b1", true, context).getValue());
assertEquals(true, multiProvider.getBooleanEvaluation("b1", true, null).getValue());
}
@Test
public void testFirstMatchStrategyErrorCode() {
FeatureProvider provider1 = mock(FeatureProvider.class);
FeatureProvider provider2 = mock(FeatureProvider.class);
FeatureProvider provider3 = mock(FeatureProvider.class);
when(provider1.getMetadata()).thenReturn(() -> "provider1");
when(provider2.getMetadata()).thenReturn(() -> "provider2");
when(provider3.getMetadata()).thenReturn(() -> "provider3");
ProviderEvaluation<String> flagNotFoundResult = mock(ProviderEvaluation.class);
when(flagNotFoundResult.getErrorCode()).thenReturn(FLAG_NOT_FOUND);
ProviderEvaluation<String> successResult = mock(ProviderEvaluation.class);
when(successResult.getErrorCode()).thenReturn(null);
when(successResult.getValue()).thenReturn("success");
when(provider1.getStringEvaluation("test", "default", null)).thenReturn(flagNotFoundResult);
when(provider2.getStringEvaluation("test", "default", null)).thenReturn(successResult);
Map<String, FeatureProvider> providers = new LinkedHashMap<>();
providers.put("provider1", provider1);
providers.put("provider2", provider2);
providers.put("provider3", provider3);
FirstMatchStrategy strategy = new FirstMatchStrategy();
ProviderEvaluation<String> result = strategy.evaluate(
providers, "test", "default", null, p -> p.getStringEvaluation("test", "default", null));
assertEquals("success", result.getValue());
}
@Test
public void testFirstSuccessfulStrategyErrorCode() {
FeatureProvider provider1 = mock(FeatureProvider.class);
FeatureProvider provider2 = mock(FeatureProvider.class);
when(provider1.getMetadata()).thenReturn(() -> "provider1");
when(provider2.getMetadata()).thenReturn(() -> "provider2");
ProviderEvaluation<String> flagNotFoundResult = mock(ProviderEvaluation.class);
when(flagNotFoundResult.getErrorCode()).thenReturn(FLAG_NOT_FOUND);
ProviderEvaluation<String> successResult = mock(ProviderEvaluation.class);
when(successResult.getErrorCode()).thenReturn(null);
when(successResult.getValue()).thenReturn("success");
when(provider1.getStringEvaluation("test", "default", null)).thenReturn(flagNotFoundResult);
when(provider2.getStringEvaluation("test", "default", null)).thenReturn(successResult);
Map<String, FeatureProvider> providers = new LinkedHashMap<>();
providers.put("provider1", provider1);
providers.put("provider2", provider2);
FirstSuccessfulStrategy strategy = new FirstSuccessfulStrategy();
ProviderEvaluation<String> result = strategy.evaluate(
providers, "test", "default", null, p -> p.getStringEvaluation("test", "default", null));
assertEquals("success", result.getValue());
}
}