-
Notifications
You must be signed in to change notification settings - Fork 48
feat: Add multi-provider support #1500
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?
feat: Add multi-provider support #1500
Conversation
8710a52
to
f07dd25
Compare
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.
Looks good so far!
I'm not sure if we want to add a json dependecy (which might also produce the License Compliance error) "just" so that we can build up the metadata name string.
private String metadataName; | ||
|
||
/** | ||
* Constructs a MultiProvider with the given list of FeatureProviders, using a default strategy. |
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.
I think the comment should say what the default strategy is
json.put("name", NAME); | ||
JSONObject providersMetadata = new JSONObject(); | ||
json.put("originalMetadata", providersMetadata); | ||
ExecutorService initPool = Executors.newFixedThreadPool(INIT_THREADS_COUNT); |
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.
ExecutorService initPool = Executors.newFixedThreadPool(INIT_THREADS_COUNT); | |
ExecutorService initPool = Executors.newFixedThreadPool(Math.min(INIT_THREADS_COUNT, providers.size())); |
throw new GeneralError("init failed"); | ||
} | ||
} | ||
metadataName = json.toString(); |
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.
If the executer service is unused from this point onwards, we should shut it down
assertThrows(FlagNotFoundError.class, () -> finalMultiProvider1.getStringEvaluation("non-existing", "", null)); | ||
|
||
multiProvider.shutdown(); | ||
multiProvider = new MultiProvider(providers, new FirstSuccessfulStrategy()); |
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.
There should probably be a test file with tests for each strategy. If you want to reduce code duplication by the flag setup, make those test classes extend some common test setup class
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.
have created a BaseStrategyTest
for the setup which is extended by the separate test classes for each strategy
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1500 +/- ##
============================================
+ Coverage 92.97% 93.45% +0.47%
- Complexity 488 519 +31
============================================
Files 46 50 +4
Lines 1182 1253 +71
Branches 103 112 +9
============================================
+ Hits 1099 1171 +72
+ Misses 53 51 -2
- Partials 30 31 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Should I construct the json using Strings? |
Maybe it would be worth it. |
@chrfwow @toddbaert any updates on this? |
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.
Thank you, great effort. I am looking forward to having the multiprovider migrated. For me, the added dependency is currently a show stopper, because I think we can handle that with proper and simple data classes, even with better metadata support overall (not losing information from the sub providers). I added simple but untested code snippets to my review. Please let me know what you think about this approach.
src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
Outdated
Show resolved
Hide resolved
src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
Outdated
Show resolved
Hide resolved
Looks good to me. |
97f6dc9
to
1103949
Compare
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.
Looks good
src/test/java/dev/openfeature/sdk/multiProvider/MultiProviderTest.java
Outdated
Show resolved
Hide resolved
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.
Thank you, the basic implementation looks good to me. But i took a closer look at the two strategies. The strategies seem to be a little inconsistent in exception handling, logging and returning. I think we should normalize this a little for consistency (no need for more abstraction, but it should log the same way, it should handle all the possible exceptions the same way, etc).
src/test/java/dev/openfeature/sdk/multiProvider/MultiProviderTest.java
Outdated
Show resolved
Hide resolved
src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
Outdated
Show resolved
Hide resolved
src/main/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategy.java
Outdated
Show resolved
Hide resolved
src/main/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategy.java
Outdated
Show resolved
Hide resolved
src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java
Outdated
Show resolved
Hide resolved
ff27ce8
to
fc8c18f
Compare
Signed-off-by: suvaidkhan <[email protected]>
… removed json dep Signed-off-by: suvaidkhan <[email protected]>
Signed-off-by: suvaidkhan <[email protected]>
Signed-off-by: suvaidkhan <[email protected]>
fc8c18f
to
84bc45a
Compare
|
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.
Sorry for the delay @suvaidkhan - i was on vacation - thank you for incoporating all the changes, i will give other maintainers the chance to also take a look for 2 more days, and else i will merge it.
Thank you this contribution is highly appreciated!
/gemini review |
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.
Code Review
This pull request introduces multi-provider support, a significant and valuable feature. The implementation includes different provider evaluation strategies and a MultiProvider
class to manage them. The code is well-structured and includes a good set of tests. I've identified a few issues, including a critical one in the initialize
method related to resource handling and edge cases, as well as some medium-severity items around error reporting and immutability. I've provided detailed suggestions to address these points. Overall, this is a great addition, and with these fixes, it will be even more robust.
public void initialize(EvaluationContext evaluationContext) throws Exception { | ||
var metadataBuilder = MultiProviderMetadata.builder(); | ||
metadataBuilder.name(NAME); | ||
HashMap<String, Metadata> providersMetadata = new HashMap<>(); | ||
ExecutorService executorService = Executors.newFixedThreadPool(Math.min(INIT_THREADS_COUNT, providers.size())); | ||
Collection<Callable<Boolean>> tasks = new ArrayList<>(providers.size()); | ||
for (FeatureProvider provider : providers.values()) { | ||
tasks.add(() -> { | ||
provider.initialize(evaluationContext); | ||
return true; | ||
}); | ||
Metadata providerMetadata = provider.getMetadata(); | ||
providersMetadata.put(providerMetadata.getName(), providerMetadata); | ||
} | ||
metadataBuilder.originalMetadata(providersMetadata); | ||
List<Future<Boolean>> results = executorService.invokeAll(tasks); | ||
for (Future<Boolean> result : results) { | ||
if (!result.get()) { | ||
executorService.shutdown(); | ||
throw new GeneralError("init failed"); | ||
} | ||
} | ||
executorService.shutdown(); | ||
metadata = metadataBuilder.build(); | ||
} |
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 initialize
method has a few critical issues:
- Crash on empty provider list: If
MultiProvider
is created with an empty list of providers,providers.size()
will be 0, andExecutors.newFixedThreadPool(0)
will throw anIllegalArgumentException
. - Resource leak: If any provider's
initialize
method throws an exception, theExecutorService
is not shut down, leading to a resource leak. - Unreachable code: The
if (!result.get())
block is unreachable becauseresult.get()
will either returntrue
or throw an exception. - Mutable state: The
providersMetadata
map is passed to the builder without being made unmodifiable, which could lead to modification of internal state.
I suggest refactoring the method to handle these issues.
public void initialize(EvaluationContext evaluationContext) throws Exception {
var metadataBuilder = MultiProviderMetadata.builder().name(NAME);
HashMap<String, Metadata> providersMetadata = new HashMap<>();
if (providers.isEmpty()) {
metadataBuilder.originalMetadata(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);
}
// Make the metadata map unmodifiable to protect internal state.
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();
}
} finally {
executorService.shutdown();
}
metadata = metadataBuilder.build();
}
return ProviderEvaluation.<T>builder() | ||
.errorMessage("No provider successfully responded") | ||
.errorCode(ErrorCode.GENERAL) | ||
.build(); | ||
} |
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.
When all providers return FLAG_NOT_FOUND
, the strategy currently returns a GENERAL
error. It would be more accurate to return a FLAG_NOT_FOUND
error code to reflect that the flag was not found in any of the providers. The error message could also be more specific.
Note that this change will require updating FirstMatchStrategyTest.shouldThrowFlagNotFoundWhenAllProvidersReturnFlagNotFound
and MultiProviderTest.shouldUseDefaultFirstMatchStrategy
to assert for FLAG_NOT_FOUND
instead of GENERAL
.
return ProviderEvaluation.<T>builder() | |
.errorMessage("No provider successfully responded") | |
.errorCode(ErrorCode.GENERAL) | |
.build(); | |
} | |
return ProviderEvaluation.<T>builder() | |
.errorMessage("Flag not found in any provider") | |
.errorCode(ErrorCode.FLAG_NOT_FOUND) | |
.build(); |
@Data | ||
@Builder | ||
public class MultiProviderMetadata implements Metadata { | ||
String name; | ||
Map<String, Metadata> originalMetadata; | ||
|
||
@Override | ||
public String getName() { | ||
return name; | ||
} | ||
} |
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 MultiProviderMetadata
class is currently mutable due to Lombok's @Data
annotation, which generates setters. This could allow external code to modify the metadata of the MultiProvider
, which is generally undesirable. To ensure immutability, I recommend using @Value
instead of @Data
. @Value
creates an immutable class with private final
fields and getters. The explicit getName()
override also becomes unnecessary as @Value
will generate it.
@lombok.Value
@Builder
public class MultiProviderMetadata implements Metadata {
String name;
Map<String, Metadata> originalMetadata;
}
import lombok.SneakyThrows; | ||
import org.junit.jupiter.api.Test; | ||
|
||
class MultiProviderTest extends BaseStrategyTest { |
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 test suite for MultiProvider
is missing a test case for initialization with an empty list of providers. This is an important edge case that currently causes a crash in the submitted code. Please add a test to cover this scenario and ensure it's handled gracefully (i.e., it should not throw an exception).
Here is an example of what such a test could look like:
@Test
void shouldInitializeWithEmptyProvidersList() {
MultiProvider multiProvider = new MultiProvider(new ArrayList<>());
assertDoesNotThrow(() -> multiProvider.initialize(null));
assertNotNull(multiProvider.getMetadata());
assertEquals(MultiProvider.getNAME(), multiProvider.getMetadata().getName());
}
okay @suvaidkhan - as we now have gemini support, i executed the gemini review, and i think the initialization issue seems to be critical. We do have two options here - first we add the feedback for gemini now, and wait for your implementation before merging, or second option, we are merging this, and create an issue to address the gemini feedback regarding initialization. wdyt? |
I'm fine with both |
This PR
Related Issues
Resolves #1486
Follow-up Tasks
Multiprovider should be removed from the contrib codebase