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!: Thread safe hooks, provider, and context #79

Merged
merged 13 commits into from
Oct 12, 2022
Merged

Conversation

kinyoklion
Copy link
Member

@kinyoklion kinyoklion commented Oct 7, 2022

  • Change storage type of global hooks to a concurrent stack.
    • Should be able to add and remove from this without locking,
  • Protect references used from multiple threads with a Read/Write lock.
  • Change read only collection types to immutable collection types.
    • Readonly collections are just views into existing mutable collections. So they are still subject to the same modification problems. Immutable collections are supposed to be fixed and unchanging. Considering they are an interface you could still violate this constraint, but it is less likely.

Related to: #56

Signed-off-by: Ryan Lamb <[email protected]>
…he one at the time of client instantiation.

Signed-off-by: Ryan Lamb <[email protected]>
@kinyoklion kinyoklion changed the title Additional thread safety updates. feat: Thread safe hooks, provider, and context Oct 7, 2022
@codecov-commenter
Copy link

codecov-commenter commented Oct 7, 2022

Codecov Report

Merging #79 (094f3d2) into main (d980a94) will increase coverage by 0.44%.
The diff coverage is 97.80%.

@@            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     
Impacted Files Coverage Δ
src/OpenFeatureSDK/Model/FlagEvaluationOptions.cs 91.66% <85.71%> (ø)
src/OpenFeatureSDK/OpenFeatureClient.cs 98.74% <97.56%> (-0.52%) ⬇️
src/OpenFeatureSDK/FeatureProvider.cs 100.00% <100.00%> (ø)
src/OpenFeatureSDK/OpenFeature.cs 100.00% <100.00%> (ø)

📣 We’re building smart automated test selection to slash your CI/CD build times. Learn more

/// <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();
Copy link
Member Author

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.

Copy link
Member Author

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.

/// <returns></returns>
public virtual IReadOnlyList<Hook> GetProviderHooks() => Array.Empty<Hook>();
/// <returns>Immutable list of hooks</returns>
public virtual IImmutableList<Hook> GetProviderHooks() => ImmutableList<Hook>.Empty;
Copy link
Member Author

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.

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 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())
Copy link
Member Author

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.

Copy link
Member Author

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.

public void SetProvider(FeatureProvider featureProvider)
{
this._featureProviderLock.EnterWriteLock();
try
Copy link
Member Author

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.

Copy link
Member

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).

Copy link
Member Author

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.

Copy link
Member Author

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>
Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member

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?

Copy link
Member Author

@kinyoklion kinyoklion Oct 11, 2022

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)

Copy link
Member

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(),
Copy link
Member Author

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.

@kinyoklion kinyoklion marked this pull request as ready for review October 10, 2022 17:31
@kinyoklion kinyoklion changed the title feat: Thread safe hooks, provider, and context feat!: Thread safe hooks, provider, and context Oct 10, 2022
/// <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)
Copy link
Member Author

@kinyoklion kinyoklion Oct 10, 2022

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.

if (provider == null)
{
provider = new NoOpFeatureProvider();
this._logger.LogDebug("No provider configured, using no-op provider");
Copy link
Member Author

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?

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 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.

@toddbaert toddbaert self-requested a review October 11, 2022 15:37
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.

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.

@kinyoklion
Copy link
Member Author

I realized that I missed the client level context locking. So I have added that.

@kinyoklion
Copy link
Member Author

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.

Copy link
Member

@benjiro benjiro left a comment

Choose a reason for hiding this comment

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

LGTM 👍🏻

@kinyoklion
Copy link
Member Author

@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.

@toddbaert
Copy link
Member

@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.

Clients are lightweight, so this seems like a fine recommendation, we could even document it if you think it's worth it.

The alternative would be to make it disposable, but that then becomes like a, "what color are your functions" kind of situation.

Agreed (and I'll have to re-read that post now since it's been a while).

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.

Yep, makes sense to me.

@toddbaert
Copy link
Member

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.

@kinyoklion kinyoklion merged commit 609016f into main Oct 12, 2022
@kinyoklion kinyoklion deleted the feat/thread-safety branch October 12, 2022 18:27
benjiro pushed a commit that referenced this pull request Oct 12, 2022
🤖 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>
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.

4 participants