Skip to content
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

feat: make provider interface "stateless"; SDK maintains provider state #1096

Merged
merged 31 commits into from
Sep 23, 2024

Conversation

chrfwow
Copy link
Contributor

@chrfwow chrfwow commented Sep 9, 2024

This PR

Providers no longer maintain their own state: the state for each provider is maintained in the SDK automatically, and updated according to the success/failures of lifecycle methods (init/shutdown) or events emitted from providers spontaneously.

Related Issues

#844

Please remove getState in implementing providers (the interface method has been marked as deprecated).

Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
@chrfwow chrfwow requested a review from a team as a code owner September 9, 2024 14:01
@chrfwow chrfwow changed the title Make provider interface "stateless", SDK maintains provider state feat: Make provider interface "stateless", SDK maintains provider state Sep 9, 2024
@@ -94,7 +138,7 @@ public void emitProviderStale(ProviderEventDetails details) {
/**
* Emit a {@link ProviderEvent#PROVIDER_ERROR} event.
* Shorthand for {@link #emit(ProviderEvent, ProviderEventDetails)}
*
*
* @param details The details of the event
*/
public void emitProviderError(ProviderEventDetails details) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

should there also be a method like emitFatalProviderError?

Copy link
Member

Choose a reason for hiding this comment

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

I would take the approach of just checking if the error is a Fatal error.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

How can I do that? The ProviderEventDetails parameter does not have a field containing a potential error code. If I can get it from getEventMetadata(), what key is the error code associated with?

Copy link
Member

Choose a reason for hiding this comment

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

Good point. I think that's another thing we need to add. I might have missed it in the issue, but it's in the spec: https://openfeature.dev/specification/types#event-details

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is it queried like this?

String errorCode = details.getEventMetadata().getString("error code");
if("FATAL".equals(errorCode)){
    ...
}

I can't see how this should work from the documentation

Copy link
Member

@toddbaert toddbaert Sep 13, 2024

Choose a reason for hiding this comment

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

I'm saying the event details should have a first class member representing the error code (not in the event data), if it's an error event.

OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProvider(new NoOpProvider());
api.setProvider(TestEventsProvider.initialized());
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Some of the tests had to be changed, as now an uninitialized provider will not allow the lookup of a value.

Copy link
Member

Choose a reason for hiding this comment

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

It might be better to use setProviderAndWait which will initialize the provider before continuing.

Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
@toddbaert
Copy link
Member

Thanks a lot for this! I will try to review tomorrow! 🙏

chrfwow and others added 2 commits September 10, 2024 07:44
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
Copy link

codecov bot commented Sep 10, 2024

Codecov Report

Attention: Patch coverage is 84.61538% with 20 lines in your changes missing coverage. Please review.

Project coverage is 93.91%. Comparing base (dd8ba81) to head (6d199d9).
Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...n/java/dev/openfeature/sdk/ProviderRepository.java 76.05% 14 Missing and 3 partials ⚠️
...feature/sdk/providers/memory/InMemoryProvider.java 0.00% 2 Missing ⚠️
...v/openfeature/sdk/FeatureProviderStateManager.java 97.36% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #1096      +/-   ##
============================================
- Coverage     95.12%   93.91%   -1.22%     
- Complexity      401      430      +29     
============================================
  Files            39       40       +1     
  Lines           924     1019      +95     
  Branches         56       72      +16     
============================================
+ Hits            879      957      +78     
- Misses           24       39      +15     
- Partials         21       23       +2     
Flag Coverage Δ
unittests 93.91% <84.61%> (-1.22%) ⬇️

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.

chrfwow and others added 2 commits September 10, 2024 08:17
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
throw new ProviderNotReadyError("provider not yet initialized");
}
if (ProviderState.FATAL.equals(getState())) {
throw new FatalError("provider in fatal error state");
}
throw new GeneralError("unknown error");
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The behaviour here is different than that of the OpenFeatureClient, which would not throw this GeneralError. Which bevaviour is the expected one?

Copy link
Member

Choose a reason for hiding this comment

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

See: https://github.com/open-feature/java-sdk/pull/1096/files/98c620f14a81a0fda47207eb9d912826a33ec446#r1752386247

We should be able to delete this code entirely since initialization is tracked in the SDK, but things should also work fine if we don't touch this class at all.

Copy link
Member

Choose a reason for hiding this comment

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

so do you suggest to generate a Second deprecated InMemoryProvider to always ensure backwards compatibility and run the tests for both? or do you think that is overkill?

Comment on lines 31 to 51
@Override
public final void initialize(EvaluationContext evaluationContext) throws Exception {
try {
doInitialization(evaluationContext);
providerState = ProviderState.READY;
} catch (OpenFeatureError openFeatureError) {
if (ErrorCode.PROVIDER_FATAL.equals(openFeatureError.getErrorCode())) {
providerState = ProviderState.FATAL;
} else {
providerState = ProviderState.ERROR;
}
throw openFeatureError;
} catch (Exception e) {
providerState = ProviderState.ERROR;
throw new GeneralError(e);
}
}

protected void doInitialization(EvaluationContext evaluationContext) throws Exception {
// Intentionally left blank, to be implemented by inheritors
}
Copy link
Member

Choose a reason for hiding this comment

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

This implementation is logically correct, but this won't work retroactively with existing implementations (many of which can be found here, though there's others maintained by vendors, etc).

We need to do this in a non-breaking way, without breaking any existing contracts or behvaiors. In C#, I accomplished this by creating a new package-private ProviderState field on the provider which the SDK updates after it has successfully (or unsuccessfully) run the initialize method, and basically ignoring the previous provider state field.

This allows existing providers to work without breaking changes.

Copy link
Member

@liran2000 liran2000 Sep 10, 2024

Choose a reason for hiding this comment

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

@toddbaert I wonder if there can be some additional automated step to see if the sdk-contrib repository providers are not breaking, possibly as warning / non-mandatory. There is the InMemoryProvider on this repository which is a good sanity, maybe checking the other providers as well can help. Or is too much? What do you think ?

* @param version a version identifier
* @return a new client instance
*/
public Client getClient(String domain, String version) {
return new OpenFeatureClient(this,
return new OpenFeatureClient(
() -> providerRepository.getProvider(domain),
Copy link
Member

Choose a reason for hiding this comment

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

Inside the ProviderRespository, you will have to modify this private method to call the initialize function on the provider if it's not yet initialized or in the process of being initialized, then update a new package-private status member indicating readiness.

Comment on lines 50 to 61
/**
* Initializes the provider.
* @param evaluationContext evaluation context
* @throws Exception on error
*/
@Override
public void initialize(EvaluationContext evaluationContext) throws Exception {
super.initialize(evaluationContext);
state = ProviderState.READY;
log.debug("finished initializing provider, state: {}", state);
}

Copy link
Member

Choose a reason for hiding this comment

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

As a proof that nothing is breaking, you should be able to get away with making no changes to this provider.

Comment on lines 95 to 101
if (ProviderEvent.PROVIDER_ERROR.equals(event)) {
providerState = ProviderState.ERROR;
} else if (ProviderEvent.PROVIDER_STALE.equals(event)) {
providerState = ProviderState.STALE;
} else if (ProviderEvent.PROVIDER_READY.equals(event)) {
providerState = ProviderState.READY;
}
Copy link
Member

Choose a reason for hiding this comment

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

Can you add a specific test for this? It relates to specification requirement 5.3.5, so you can use our @Specification annotation for tracking compliance.

@toddbaert toddbaert self-requested a review September 10, 2024 17:46
Copy link
Member

@toddbaert toddbaert left a comment

Choose a reason for hiding this comment

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

Really good first pass! I've left some comments. The most important is here.

If you need more help or clarification, please reach out on slack.

@Override
public final void shutdown() {
providerState = ProviderState.NOT_READY;
doShutdown();
Copy link
Member

Choose a reason for hiding this comment

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

why this split is needed ?
without it, provider can implement shutdown() same as today and calling super.shutdown() in it.
providers which were implemented this way, can continue to work as is.
similar with initialize().

@@ -35,8 +26,10 @@ public TestEventsProvider(int initTimeoutMs, boolean initError, String initError
this.initErrorMessage = initErrorMessage;
}

public TestEventsProvider(ProviderState initialState) {
this.state = initialState;
public static TestEventsProvider initialized() throws Exception {
Copy link
Member

Choose a reason for hiding this comment

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

rename to something like newTestEventsProvider() ?

class DeveloperExperienceTest implements HookFixtures {
transient String flagKey = "mykey";

@Test void simpleBooleanFlag() {
@Test void simpleBooleanFlag() throws Exception {
Copy link
Member

Choose a reason for hiding this comment

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

can use @SneakyThrows.
also at other places.

Copy link
Member

Choose a reason for hiding this comment

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

^ Apparently, that's someones username 😄

@@ -94,8 +90,12 @@ class MutatingHook implements Hook {

@Override
// change the provider during a before hook - this should not impact the evaluation in progress
public Optional before(HookContext ctx, Map hints) {
FeatureProviderTestUtils.setFeatureProvider(new NoOpProvider());
public Optional before(HookContext ctx, Map hints) {
Copy link
Member

Choose a reason for hiding this comment

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

can use @SneakyThrows and remove try/catch ?

public void initialize(EvaluationContext evaluationContext) throws Exception {
Awaitility.await().wait(3000);
protected void doInitialization(EvaluationContext evaluationContext) throws Exception {
Thread.sleep(10000);
Copy link
Member

Choose a reason for hiding this comment

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

why change the implementation and wait that much longer ?

inMemoryProvider.initialize(null);
inMemoryProvider.emitProviderError(ProviderEventDetails.builder().build());

// ErrorCode.GENERAL should be returned when evaluated via the client
Copy link
Member

Choose a reason for hiding this comment

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

why would it return error after provider error event ?
any reference to this behavior ?

Copy link
Contributor Author

@chrfwow chrfwow Sep 11, 2024

Choose a reason for hiding this comment

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

any reference to this behavior ?

No, but this is the current behaviour. See #1096 (comment) and the implementation

chrfwow and others added 2 commits September 11, 2024 09:06
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
chrfwow and others added 3 commits September 19, 2024 09:13
Copy link
Member

@guidobrei guidobrei left a comment

Choose a reason for hiding this comment

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

I have one request for change: I think the @Delegate annotation is a leftover from the Decorator approach and can be removed.

*/
@Deprecated() // TODO: eventually we will make this non-public. See issue #872
public OpenFeatureClient(OpenFeatureAPI openFeatureAPI, String domain, String version) {
public OpenFeatureClient(
ProviderAccessor providerAccessor,
Copy link
Member

Choose a reason for hiding this comment

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

ProviderAccessor is exposed outside its defined visibility scope. OpenFeatureClient and the constructor are public, but the ProviderAccessor is package privet.

Today I learned... I would have expected the compiler to not allow such constructs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

How should I handle this? Should I make the constructor package private, as it is deprecated anyway?

Copy link
Member

@guidobrei guidobrei Sep 20, 2024

Choose a reason for hiding this comment

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

I would add a package internal accessor to getProviderStateManager(String domain) in OpenFeatureAPI . Then you can access the state manager in the client and you can remove the ´ProviderAccessor` interface completely.

Copy link
Member

Choose a reason for hiding this comment

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

Done: 45e9ba2

}
if (ProviderState.FATAL.equals(state)) {
throw new FatalError("provider is in an irrecoverable error state");
}
Copy link
Member

Choose a reason for hiding this comment

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

We had some effort in parallel to handle evaluation errors without exceptions in #1095.

@toddbaert does this also apply to unexpected Provider states?

In general, how do we handle provider state here? If it's now managed by the SDK, does it only forward the evaluation calls to the provider, if it is in READY state, or in any state except NOT_READY and FATAL?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Currently we only forward calls if the provider is not in error (or fatal), which differs from the implementation of the InMemoryProvider, where they are only forwarded when the provider is READY. Is this what we want?

Copy link
Member

@toddbaert toddbaert Sep 20, 2024

Choose a reason for hiding this comment

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

Currently we only forward calls if the provider is not in error (or fatal), which differs from the implementation of the InMemoryProvider, where they are only forwarded when the provider is READY. Is this what we want?

Yes, this is what we want. Some providers may be in an ERROR state, and still work in some sense (they may send error telemetry, or read from an out-of-date cache, or even try to reconnect before returning). The only states that should short-circuit are FATAL and NOT_READY. In these cases we can be sure the provider hasn't connected at all or is in such a bad state (FATAL) that it's pointless to even use and will never recover (perhaps a bad credential or something like that). This is defined in the spec (and implemented in other SDKs):

https://openfeature.dev/specification/sections/flag-evaluation#requirement-177
https://openfeature.dev/specification/sections/flag-evaluation#requirement-178

As far as throwing errors, yes - it would be better if we could instead return error resolutions but still run the error hooks, as we do in #1095 , to avoid the cost of creating the stack trace and exception.

chrfwow and others added 2 commits September 20, 2024 12:53
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
return providerRepository.getProviderState(domain);
}

public ProviderState getProviderState(FeatureProvider provider) {
Copy link
Member

Choose a reason for hiding this comment

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

I know I've requested this, but maybe it's better to not expose this on the public API surface unless someone requests it. We could also get rid of the additional code in the ProviderRepository that handles this request again.

Sorry for being too greedy 🙈

Copy link
Member

Choose a reason for hiding this comment

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

This overload would return a null state if the provider is not registered, which is what I would expect (or an exception?), but it's still different to the behavior of other getProviderState() overloads.

Copy link
Member

Choose a reason for hiding this comment

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

Yes I guess the only reason this was done was for this assertion which we can avoid. I agree we should remove these methods.

Copy link
Member

Choose a reason for hiding this comment

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

Done: 45e9ba2

@@ -82,12 +78,12 @@ void getApiInstance() {
@Test void providerAndWait() {
FeatureProvider provider = new TestEventsProvider(500);
OpenFeatureAPI.getInstance().setProviderAndWait(provider);
assertThat(api.getProvider().getState()).isEqualTo(ProviderState.READY);
assertThat(api.getProviderState()).isEqualTo(ProviderState.READY);
Copy link
Member

@toddbaert toddbaert Sep 20, 2024

Choose a reason for hiding this comment

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

We can instead get a client (even before we set the provider) and then check the Provider state there, so we can remove this public getProviderState as @guidobrei suggests.

Copy link
Member

Choose a reason for hiding this comment

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

Done: 45e9ba2

@toddbaert
Copy link
Member

@chrfwow I think I agree with @guidobrei 's points. Thanks for being so patient with all these reviews! I think we're almost there but I think the last few changes seem small.

Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
@toddbaert
Copy link
Member

I pushed another changeset to resolve @guidobrei 's suggestions: 45e9ba2

Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
Copy link

sonarcloud bot commented Sep 23, 2024

@toddbaert toddbaert changed the title feat: Make provider interface "stateless", SDK maintains provider state feat: make provider interface "stateless"; SDK maintains provider state Sep 23, 2024
@toddbaert toddbaert merged commit 1b1e527 into open-feature:main Sep 23, 2024
10 of 12 checks passed
@toddbaert
Copy link
Member

Thanks @chrfwow for all your help with this! It works great in the contrib repo so far.

@chrfwow
Copy link
Contributor Author

chrfwow commented Sep 24, 2024

It was a pleasure!

@chrfwow chrfwow deleted the make-provider-stateless branch September 26, 2024 05:52
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.

6 participants