-
Notifications
You must be signed in to change notification settings - Fork 21
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!: Thread safe hooks, provider, and context #79
Conversation
Signed-off-by: Ryan Lamb <[email protected]>
…he one at the time of client instantiation. Signed-off-by: Ryan Lamb <[email protected]>
Codecov Report
@@ Coverage Diff @@
## main #79 +/- ##
==========================================
+ Coverage 94.08% 94.52% +0.44%
==========================================
Files 20 20
Lines 473 530 +57
Branches 36 37 +1
==========================================
+ Hits 445 501 +56
Misses 16 16
- Partials 12 13 +1
📣 We’re building smart automated test selection to slash your CI/CD build times. Learn more |
Signed-off-by: Ryan Lamb <[email protected]>
/// <returns>A immutable list of <see cref="Hook"/></returns> | ||
public IReadOnlyList<Hook> GetHooks() => this._hooks.AsReadOnly(); | ||
/// <returns>Enumeration of <see cref="Hook"/></returns> | ||
public IEnumerable<Hook> GetHooks() => this._hooks.Reverse(); |
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.
In the open feature API and Client, the SDK controls the implementation type and can promise the use of a concurrent collection.
In the provider this is not the case, so that is constrained to an immutable collection.
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.
Also, I am reversing these for now. I wasn't completely comfortable with all the test changes required for them to be in the opposite order.
Signed-off-by: Ryan Lamb <[email protected]>
d6d186b
to
dda0f28
Compare
/// <returns></returns> | ||
public virtual IReadOnlyList<Hook> GetProviderHooks() => Array.Empty<Hook>(); | ||
/// <returns>Immutable list of hooks</returns> | ||
public virtual IImmutableList<Hook> GetProviderHooks() => ImmutableList<Hook>.Empty; |
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.
Alternatively this could be enumerable like other places, with documentation that the provider must use a thread-safe implementation. Technically a readonlylist, with the same constraint, would also works. I think immutable directs people in a way that the easiest solution is also the correct solution.
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 immutable directs people in a way that the easiest solution is also the correct solution.
Agreed.
.Concat(options?.Hooks ?? Enumerable.Empty<Hook>()) | ||
.Concat(this._featureProvider.GetProviderHooks()) | ||
.Concat(OpenFeature.Instance.GetProvider().GetProviderHooks()) |
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.
Ok. This isn't correct. You would't want the hooks from a different provider than the evaluation method. Those two things need to be resolved at the same time.
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 that we need to get the reference, then use that reference to get the method and the hooks. We would not just want to get them in the call, because they could still be separated in time.
Signed-off-by: Ryan Lamb <[email protected]>
Signed-off-by: Ryan Lamb <[email protected]>
Signed-off-by: Ryan Lamb <[email protected]>
public void SetProvider(FeatureProvider featureProvider) | ||
{ | ||
this._featureProviderLock.EnterWriteLock(); | ||
try |
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.
None of these code blocks can throw, but when using this type of lock I think using try/finally is a customary way to release the lock.
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.
Yep, agreed. In fact, I made a little lock-wrapper class that allows us to use a try-with-resources block in Java (I think c# has an equivalent with the using
keyword and IDisposable
implementations).
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.
Yeah, it could be better to make a couple little RAII objects for locks.
I am not sure for a couple reasons. The first is that it would generate an extra bit of garbage for each access to these fields. It would likely be negligible.
The second is that all the locking is currently only exposed in this one file.
If we were to need to expose the locking outside this file, then I could see the idea of returning a read lock protected object which the consumer could use in a using block.
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.
Especially for double locks, where they could nest the using statement.
Signed-off-by: Ryan Lamb <[email protected]>
/// operations. For instance, during an evaluation the flag resolution method, and the provider hooks | ||
/// should be accessed from the same reference, not two independent calls to | ||
/// <see cref="OpenFeature.GetProvider"/>. | ||
/// </para> |
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.
One thing I am not calling out here is that setting hooks, provider, and context are not guaranteed to be ordered among themselves. If you have one thread setting all 3, and another thread doing evaluations, then a single evaluation may use some combination of the values being set.
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.
One thing I am not calling out here is that setting hooks, provider, and context are not guaranteed to be ordered among themselves. If you have one thread setting all 3, and another thread doing evaluations, then a single evaluation may use some combination of the values being set.
In Java and go, we read lock as soon as the evaluation starts while we get the provider, the context, and the hooks. I think this might be worth doing.
As for the block comment, I had added something similar in the read-lock block in Java, so it makes sense to me and I think it's worth pointing out.
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.
So, here each of the 3 things are individually thread safe.
The provider will use a stable reference, which covers the provider hooks and other data.
The context is immutable, and we get a reference at a specific point in time. The reference globally may change, but we are decoupled from that because we got a reference. Locking for the duration of getting the context and the provider wouldn't prevent interleaved setting of them. You could set one, and then the evaluation gets the lock, you have to wait, and then set the other.
The last bit are the other two sets of hooks. These are interesting. There isn't anything which inherently couples them to anything else. They are copied, so the list will be stable in the places they are iterate, but up until they are iterated, they could change.
If you have cross dependent hooks then they should be added with AddHooks, which will add them atomically. I should document that.
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 added a couple comments to the hook methods.
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 you have cross dependent hooks
Worth saying I generally think this should be avoided, but I think people might do it.
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.
Locking for the duration of getting the context and the provider wouldn't prevent interleaved setting of them.
Wouldn't read-locking in the evaluation on a shared lock which is write-locked by both the provider and context mutators address exactly this? Or am I misunderstanding?
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.
Not unless you also locked for the duration of setting both of them.
If they can be independently set, as they can in the implementation at the moment, then it doesn't.
Thread 1:
SetProvider(provider)
writeLock(_provider)
// Evaluation could start here
SetContext(context)
writeLock(_context)
Thread 2:
Evaluate()
readLock(_provider)
readLock(_context)
So, given this code someone could intend to set the provider and context, but it would be possible for evaluation to grab the lock before thread 1 set the context, but after it set the provider.
In order to not interleave it would need to be more like
Thread 1:
writeLock(_provider)
writeLock(_context)
setProvider
setContext
Thread 2:
Evaluate()
readLock(_provider)
readLock(_context)
Or use a single lock (but still set both)
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.
Ahh, yes, I agree. I misunderstood your initial statement.
@@ -241,7 +273,7 @@ public FeatureClient(FeatureProvider featureProvider, string name, string versio | |||
flagKey, | |||
defaultValue, | |||
flagValueType, this._metadata, | |||
OpenFeature.Instance.GetProviderMetadata(), | |||
provider.GetMetadata(), |
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.
We don't want to call GetProviderMetadata
here on the OpenFeature
singleton, because it could have change between when we got the resolution method and here. We want to be sure to only use the cached reference.
/// <param name="name">Name of client <see cref="ClientMetadata"/></param> | ||
/// <param name="version">Version of client <see cref="ClientMetadata"/></param> | ||
/// <param name="logger">Logger used by client</param> | ||
/// <param name="context">Context given to this client</param> | ||
/// <exception cref="ArgumentNullException">Throws if any of the required parameters are null</exception> | ||
public FeatureClient(FeatureProvider featureProvider, string name, string version, ILogger logger = null, EvaluationContext context = null) |
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.
Just realized I need to re-introduce the equivalent of this check elsewhere.
I lifted the behavior the Java SDK has. Which is to log that it was not set, and then use the NoOp provider.
Signed-off-by: Ryan Lamb <[email protected]>
if (provider == null) | ||
{ | ||
provider = new NoOpFeatureProvider(); | ||
this._logger.LogDebug("No provider configured, using no-op provider"); |
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.
This is debug
in the Java SDK, but I wonder if maybe it should be more stern?
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 No-op is a valid configuration, and may be used intentionally, especially if OpenFeature is implemented in 3rd party libraries.
For this reason, I think this should remain a debug
level log.
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.
Thanks @kinyoklion ! This looks great. Please consider my comments, though I'm not convinced any modifications are necessary. I think you've addressed the major concerns by using a consistent reference to provider
during the course of evaluation.
Signed-off-by: Ryan Lamb <[email protected]>
Signed-off-by: Ryan Lamb <[email protected]>
I realized that I missed the client level context locking. So I have added that. |
I've noticed another thing that needs to be resolved. The ReaderWriterLock type is disposable, so the containing type should implement disposable. Not really a problem for the global OpenFeature instance, because it isn't going anywhere. It is inconvenient for the client instances to be disposable though. So I may want to consider a normal lock in that case. |
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.
LGTM 👍🏻
Signed-off-by: Ryan Lamb <[email protected]>
Signed-off-by: Ryan Lamb <[email protected]>
@toddbaert @benjiro I switched the OpenFeatureClient locks to normal locks. This does mean if you are using one client from many threads, that it will have more contention. Maybe... make more clients. The alternative would be to make it disposable, but that then becomes like a, "what color are your functions" kind of situation. In OpenFeature.cs I am not disposing. I added a comment. The singleton isn't meant to go away, so disposing it doesn't make sense. |
Clients are lightweight, so this seems like a fine recommendation, we could even document it if you think it's worth it.
Agreed (and I'll have to re-read that post now since it's been a while).
Yep, makes sense to me. |
I know there's been a few changes on this, but I think it's this is good work and certainly more threadsafe than what we had before. I'm in favor of merging it unless there's objections. |
🤖 I have created a release *beep* *boop* --- ## [0.4.0](v0.3.0...v0.4.0) (2022-10-12) ### ⚠ BREAKING CHANGES * Thread safe hooks, provider, and context (#79) * Implement builders and immutable contexts. (#77) ### Features * Implement builders and immutable contexts. ([#77](#77)) ([d980a94](d980a94)) * Thread safe hooks, provider, and context ([#79](#79)) ([609016f](609016f)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Related to: #56