Skip to content

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

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

suvaidkhan
Copy link

This PR

  • Added Multiprovider class
  • Added Strategies
  • Added a new dependency for JSON

Related Issues

Resolves #1486

Follow-up Tasks

Multiprovider should be removed from the contrib codebase

@suvaidkhan suvaidkhan requested review from a team as code owners July 1, 2025 07:43
@suvaidkhan suvaidkhan force-pushed the suvaidkhan/add-multiprovider-support branch from 8710a52 to f07dd25 Compare July 1, 2025 07:46
Copy link
Contributor

@chrfwow chrfwow left a 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.
Copy link
Contributor

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

Choose a reason for hiding this comment

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

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

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

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

Copy link
Author

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

Copy link

codecov bot commented Jul 1, 2025

Codecov Report

Attention: Patch coverage is 96.10390% with 3 lines in your changes missing coverage. Please review.

Project coverage is 93.41%. Comparing base (cbf7a58) to head (5810779).
Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...v/openfeature/sdk/multiprovider/MultiProvider.java 94.64% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #1500      +/-   ##
============================================
+ Coverage     92.81%   93.41%   +0.60%     
- Complexity      486      518      +32     
============================================
  Files            46       50       +4     
  Lines          1169     1246      +77     
  Branches        103      112       +9     
============================================
+ Hits           1085     1164      +79     
+ Misses           54       51       -3     
- Partials         30       31       +1     
Flag Coverage Δ
unittests 93.41% <96.10%> (+0.60%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@suvaidkhan
Copy link
Author

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.

Should I construct the json using Strings?

@chrfwow
Copy link
Contributor

chrfwow commented Jul 3, 2025

Maybe it would be worth it.
@toddbaert what do you think?

@suvaidkhan
Copy link
Author

@chrfwow @toddbaert any updates on this?

Copy link
Member

@aepfli aepfli left a 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.

public static final int INIT_THREADS_COUNT = 8;
private final Map<String, FeatureProvider> providers;
private final Strategy strategy;
private String metadataName;
Copy link
Member

Choose a reason for hiding this comment

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

[suggestion] as this variable is more than just a name - it is a json object, we might want to rename it to something more representing like

Suggested change
private String metadataName;
private String metadata;

*/
@Override
public void initialize(EvaluationContext evaluationContext) throws Exception {
JSONObject json = new JSONObject();
Copy link
Member

@aepfli aepfli Jul 9, 2025

Choose a reason for hiding this comment

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

[nit] If this is really the only reason why we add a JSON dependency, can we maybe utilize our own object representation of metadata?

something like

@Override
    public void initialize(EvaluationContext evaluationContext) throws Exception {
        var metadataBuilder = MultiProviderMetadata.builder();
        metadataBuilder.name(NAME);
        Map<String, Metadata> providersMetadata = new HashMap();
        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;
            });
            Metadata providerMetadata = provider.getMetadata();
            providersMetadata.put(providerMetadata.getName(), providerMetadata);
        }
        metadataBuilder.originalMetadata(providersMetadata);
        List<Future<Boolean>> results = initPool.invokeAll(tasks);
        for (Future<Boolean> result : results) {
            if (!result.get()) {
                throw new GeneralError("init failed");
            }
        }
        metadata = metadataBuilder.build();
    }

with an own implementation of the metadata interface called MultiProviderMetadata

@Data
@Builder
public class MultiProviderMetadata implements Metadata {

    String name;
    Map<String, Metadata> originalMetadata;
}

wdyt? This would eliminate the dependency and make editing the code easier - I did not test this, and I am not sure if we will miss information with JSON conversion, etc.

Copy link
Author

@suvaidkhan suvaidkhan Jul 14, 2025

Choose a reason for hiding this comment

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

The getMetaData function is supposed to return a String, what should I be returning in this implementation?

Copy link
Member

Choose a reason for hiding this comment

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

I think you can just return the metadata object.

    @Override
    public Metadata getMetadata() {
        return metadata;
    }

There is no need for the lambda anymore :)

Copy link
Author

Choose a reason for hiding this comment

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

Why are we using this map - Map<String, Metadata> originalMetadata; again?
MetaData will only contain one field that is the name right?

Copy link
Member

Choose a reason for hiding this comment

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

For the Multiprovider it is different. Ideally the metadata contains all the metadata of each provider too. So this map will contain for each sub provider the metadata

Copy link
Author

Choose a reason for hiding this comment

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

I'm getting - may expose internal representation on Spotbugs for getMetaData.
Was thinking of creating a defensive copy in getMetaData, something like this -

 public Metadata getMetadata() {
        if (metadata == null) {
            return null;
        }

        Map<String, Metadata> defensiveCopy = metadata.getOriginalMetadata() == null
            ? null
            : Map.copyOf(metadata.getOriginalMetadata());

        return MultiProviderMetadata.builder()
            .name(metadata.getName())
            .originalMetadata(defensiveCopy)
            .build();

    }

Is this acceptable ?

Copy link
Member

Choose a reason for hiding this comment

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

to be honest, i think we should ignore this spotbugs warning

@suvaidkhan
Copy link
Author

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.

Looks good to me.
I'll add these changes and some UTs to make sure it's working correctly.

@suvaidkhan suvaidkhan force-pushed the suvaidkhan/add-multiprovider-support branch from 97f6dc9 to 1103949 Compare July 17, 2025 00:07
Copy link
Contributor

@chrfwow chrfwow left a comment

Choose a reason for hiding this comment

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

Looks good

List<Future<Boolean>> results = initPool.invokeAll(tasks);
for (Future<Boolean> result : results) {
if (!result.get()) {
throw new GeneralError("init failed");
Copy link
Contributor

Choose a reason for hiding this comment

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

We also need to shut down the executer service in this case

Strategy mockStrategy = mock(Strategy.class);
MultiProvider multiProvider = new MultiProvider(providers, mockStrategy);
multiProvider.initialize(null);
assertNotNull(multiProvider);
Copy link
Contributor

Choose a reason for hiding this comment

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

This assert is a NO-OP

Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
assertNotNull(multiProvider);

Copy link

@suvaidkhan suvaidkhan requested review from aepfli and chrfwow July 18, 2025 23:24
@suvaidkhan
Copy link
Author

Hey @aepfli @chrfwow this is ready for review

Copy link
Member

@aepfli aepfli left a 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).

Strategy mockStrategy = mock(Strategy.class);
MultiProvider multiProvider = new MultiProvider(providers, mockStrategy);
multiProvider.initialize(null);
assertNotNull(multiProvider);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
assertNotNull(multiProvider);

var metadataBuilder = MultiProviderMetadata.builder();
metadataBuilder.name(NAME);
HashMap<String, Metadata> providersMetadata = new HashMap<>();
ExecutorService initPool = Executors.newFixedThreadPool(Math.min(INIT_THREADS_COUNT, providers.size()));
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
ExecutorService initPool = Executors.newFixedThreadPool(Math.min(INIT_THREADS_COUNT, providers.size()));
ExecutorService executorService = Executors.newFixedThreadPool(Math.min(INIT_THREADS_COUNT, providers.size()));

initPool seems odd, we should properly name it, and i suggest to name it executorService

return res;
}
} catch (FlagNotFoundError e) {
log.debug("flag not found {}", e.getMessage());
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
log.debug("flag not found {}", e.getMessage());
log.debug("flag not found {}", key, e);

The exception might have a different message, hence i think adding the key is worth it, as a separate infomration. Additionally most logging frameworks allow you to add the exception as an additional parameter, which will print the stacktrace if available. It is just a good practice

log.debug("flag not found {}", e.getMessage());
}
}
throw new FlagNotFoundError("flag not found");
Copy link
Member

Choose a reason for hiding this comment

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

If we throw an exception here i suggest to add the key

Suggested change
throw new FlagNotFoundError("flag not found");
throw new FlagNotFoundError("flag '" + key + "' not found");

but it would be better to construct a ProviderEvaluation object with an ErrorCode. we do not use the exception for flow control, our SDK will also handle this properly.

}
}

throw new GeneralError("evaluation error");
Copy link
Member

Choose a reason for hiding this comment

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

We should be consistent in our Exceptions/return-values - we should construct a ProviderEvaluation with an error code, and the error code should be consistent for all Strategies

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Move multi-provider into SDK
3 participants