feat: make provider interface "stateless"; SDK maintains provider state#1096
feat: make provider interface "stateless"; SDK maintains provider state#1096toddbaert merged 31 commits intoopen-feature:mainfrom chrfwow:make-provider-stateless
Conversation
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
| * | ||
| * @param details The details of the event | ||
| */ | ||
| public void emitProviderError(ProviderEventDetails details) { |
There was a problem hiding this comment.
should there also be a method like emitFatalProviderError?
There was a problem hiding this comment.
I would take the approach of just checking if the error is a Fatal error.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| @Test void simpleBooleanFlag() throws Exception { | ||
| OpenFeatureAPI api = OpenFeatureAPI.getInstance(); | ||
| api.setProvider(new NoOpProvider()); | ||
| api.setProvider(TestEventsProvider.initialized()); |
There was a problem hiding this comment.
Some of the tests had to be changed, as now an uninitialized provider will not allow the lookup of a value.
There was a problem hiding this comment.
It might be better to use setProviderAndWait which will initialize the provider before continuing.
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
|
Thanks a lot for this! I will try to review tomorrow! 🙏 |
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
Codecov ReportAttention: Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. |
| if (ProviderState.FATAL.equals(getState())) { | ||
| throw new FatalError("provider in fatal error state"); | ||
| } | ||
| throw new GeneralError("unknown error"); |
There was a problem hiding this comment.
The behaviour here is different than that of the OpenFeatureClient, which would not throw this GeneralError. Which bevaviour is the expected one?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
| @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 | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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 ?
| public Client getClient(String domain, String version) { | ||
| return new OpenFeatureClient(this, | ||
| return new OpenFeatureClient( | ||
| () -> providerRepository.getProvider(domain), |
There was a problem hiding this comment.
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.
| /** | ||
| * 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); | ||
| } | ||
|
|
There was a problem hiding this comment.
As a proof that nothing is breaking, you should be able to get away with making no changes to this provider.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| @Override | ||
| public final void shutdown() { | ||
| providerState = ProviderState.NOT_READY; | ||
| doShutdown(); |
There was a problem hiding this comment.
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().
|
|
||
| public TestEventsProvider(ProviderState initialState) { | ||
| this.state = initialState; | ||
| public static TestEventsProvider initialized() throws Exception { |
There was a problem hiding this comment.
rename to something like newTestEventsProvider() ?
| transient String flagKey = "mykey"; | ||
|
|
||
| @Test void simpleBooleanFlag() { | ||
| @Test void simpleBooleanFlag() throws Exception { |
There was a problem hiding this comment.
^ Apparently, that's someones username 😄
| // 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) { |
| public void initialize(EvaluationContext evaluationContext) throws Exception { | ||
| Awaitility.await().wait(3000); | ||
| protected void doInitialization(EvaluationContext evaluationContext) throws Exception { | ||
| Thread.sleep(10000); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
why would it return error after provider error event ?
any reference to this behavior ?
There was a problem hiding this comment.
any reference to this behavior ?
No, but this is the current behaviour. See #1096 (comment) and the implementation
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
…ke-provider-stateless
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
guidobrei
left a comment
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
How should I handle this? Should I make the constructor package private, as it is deprecated anyway?
There was a problem hiding this comment.
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.
| } | ||
| if (ProviderState.FATAL.equals(state)) { | ||
| throw new FatalError("provider is in an irrecoverable error state"); | ||
| } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com>
…ke-provider-stateless
| return providerRepository.getProviderState(domain); | ||
| } | ||
|
|
||
| public ProviderState getProviderState(FeatureProvider provider) { |
There was a problem hiding this comment.
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 🙈
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Yes I guess the only reason this was done was for this assertion which we can avoid. I agree we should remove these methods.
| FeatureProvider provider = new TestEventsProvider(500); | ||
| OpenFeatureAPI.getInstance().setProviderAndWait(provider); | ||
| assertThat(api.getProvider().getState()).isEqualTo(ProviderState.READY); | ||
| assertThat(api.getProviderState()).isEqualTo(ProviderState.READY); |
There was a problem hiding this comment.
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.
|
@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>
|
I pushed another changeset to resolve @guidobrei 's suggestions: 45e9ba2 |
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
|
|
Thanks @chrfwow for all your help with this! It works great in the contrib repo so far. |
|
It was a pleasure! |



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
getStatein implementing providers (the interface method has been marked as deprecated).