chore: public api works on atomic pointers - #211
Conversation
Pull Request Test Coverage Report for Build 19563582314Details
💛 - Coveralls |
| ds.backingStore.Init(backupPath, appName) | ||
| } | ||
|
|
||
| func (bs *BootstrapStorage) Load() (*api.FeatureResponse, error) { |
There was a problem hiding this comment.
The logic here needs to change because we now only access that data store when storing or retrieving data. So now we no longer early exit when failing to read the bootstrap, instead we attempt to fallback to the store and if that fails only then do we return an error. In practice I'm not sure the error handling matters much here since we silently ignore the error anyway
| return bs.backingStore.Persist() | ||
| } | ||
|
|
||
| func (bs *BootstrapStorage) Get(key string) (*api.Feature, bool) { |
There was a problem hiding this comment.
Get isn't needed anymore since this isn't the cache now. Instead the equivalent responsibility is done on the repository layer
| return bs.backingStore.Get(key) | ||
| } | ||
|
|
||
| func (bs *BootstrapStorage) List() []*api.Feature { |
There was a problem hiding this comment.
Likewise this function no longer makes sense. Honestly it probably never made sense on the bootstrapping layer
| // It is safe to call this method from multiple goroutines concurrently. | ||
| func (uc *Client) IsEnabled(feature string, options ...FeatureOption) (enabled bool) { | ||
| result, f := uc.isEnabled(feature, options...) | ||
| snapshot := uc.repository.snapshot() |
There was a problem hiding this comment.
This is effectively the magic. The repository can yield up snapshots of it's current state of features and segments. That snapshot is backed by an atomic pointer, meaning it remains valid for as long as the pointer is held, while the repository is allowed to swap its own pointer for a new value without affecting requests in flight
| // isEnabled abstracts away the details of checking if a toggle is turned on or off | ||
| // without metrics | ||
| func (uc *Client) isEnabled(feature string, options ...FeatureOption) (api.StrategyResult, *api.Feature) { | ||
| func (uc *Client) isEnabled(feature string, snapshot *FeatureMemoryState, options ...FeatureOption) (api.StrategyResult, *api.Feature) { |
There was a problem hiding this comment.
We must operate on the same pointer, otherwise we risk tearing the evaluation. That is actually a substantial risk on the existing implementation - that a single feature evaluation may be operating on a feature or segment that changes during the course of the evaluation
| } | ||
| } | ||
|
|
||
| func resolveToggle(snapshot *FeatureMemoryState, opts featureOption, featureName string) *api.Feature { |
There was a problem hiding this comment.
This needs to be removed. This is a work around because the way we designed our storage interface means that end users implementing their own would lose access to caching. However, this is in the public API and it's hard and painful to remove now and this needs to be a different PR's problem
| type deltaProcessor struct { | ||
| storage Storage | ||
| repository *repository // Repository reference for segment manipulation | ||
| repository *repository // ideally this shouldn't be necessary, but for now we're going going to use it to resolve a snapshot of the feature state |
There was a problem hiding this comment.
In the spirit of keeping this PR self contained, I've made the smallest possible changes to this file. That being said, this needs to be dealt with soonish. The circular dependency on repository is going to cause problems and there's a lot of inappropriate intimacy with other modules. Ideally this becomes a family of pure functions which collapse deltas into a feature response and allow other modules to deal with the other work
| } | ||
| } | ||
|
|
||
| if err := dp.repository.updateStorageWithDelta(currentFeatures, segments); err != nil { |
There was a problem hiding this comment.
I've chosen to discard this error handling branch because I think its wrong. This can only occur if writing to the persistent storage fails in the v5 implementation. The data store is still updated, which means feature flags are still working correctly. I think this is especially problematic with custom implementations of the data store where we don't control the error paths
| @@ -0,0 +1,163 @@ | |||
| package unleash | |||
There was a problem hiding this comment.
Harvested API from Client. This is effectively a state that exposes methods that determine if a given feature/variant is enabled for that state. This isn't quite there yet in terms of architecture but it's closer to how this conceptually should work. Don't want to do more here because this PR is already quite large
| } | ||
|
|
||
| // evaluateFeature applies strategies + constraints for a single feature. | ||
| // It does NOT handle fallbacks or missing features; that's the caller's job. |
There was a problem hiding this comment.
Similar to how other SDKs handle this. Top level APIs deal with fallbacks, missing features + metrics. Engine layers deal with computational evaluation of a flag
| return repo | ||
| } | ||
|
|
||
| func (r *repository) updateState(features *api.FeatureResponse) { |
There was a problem hiding this comment.
One place to control access to mutating the state. This isn't perfect and should be its own thing. But that requires some redesign of the repository layer
| if r.streamingClient != nil { | ||
| r.streamingClient.stop() | ||
| } | ||
| if err := r.options.storage.Persist(); err != nil { |
There was a problem hiding this comment.
I actually don't know what this is intended to achieve. So I've done the dumbest thing possible and removed it. Neither an error case nor a close case should cause a persistence call. Persist is now done explicitly where it's needed - when new data is fetched. Once via delta processing and once via old style feature fetch
| } | ||
|
|
||
| // updateStorageWithDelta updates the storage with delta changes in a thread-safe manner | ||
| func (r *repository) updateStorageWithDelta(features map[string]*api.Feature, segments map[int][]api.Constraint) error { |
There was a problem hiding this comment.
This now goes via the one and only one way to update state. I think this method only really exists because the delta processor and the repository are strangely coupled at the moment
| // IsStreaming returns whether the repository is currently in streaming mode | ||
| func (r *repository) IsStreaming() bool { | ||
| r.RLock() | ||
| defer r.RUnlock() |
There was a problem hiding this comment.
The streaming property is set on construction and never mutated. This lock does nothing but introduce contention that doesn't need to be here
| return feature | ||
| } | ||
|
|
||
| func (r *repository) resolveSegmentConstraints(strategy api.Strategy) ([]api.Constraint, error) { |
There was a problem hiding this comment.
This absolutely does not belong in this layer. It's been moved to feature_state where it can be happy and lock free
|
|
||
| // Storage is an interface that can be implemented in order to have control over how | ||
| // the repository of feature toggles is persisted. | ||
| // Storage controls persistence of the SDK state (features + segments). |
There was a problem hiding this comment.
This module is the heart of a lot of the problems we have. I've taken a knife to it. It now does what it says on the tin. It stores stuff. There's more to be done here but in another PR
This unfortunately is a breaking change
| func (m *mockEvent) Event() string { return m.event } | ||
| func (m *mockEvent) Data() string { return m.data } | ||
|
|
||
| type NoOpStorage struct{} |
There was a problem hiding this comment.
So that our tests are not loading from disk and therefore hydrating their initial state from the runs of other tests. It's not technically necessary for this PR but I've had a ton of problems in SDKs that don't correctly enforce a pattern for no op storage and it's substantially worse in complex integration tests
FredrikOseberg
left a comment
There was a problem hiding this comment.
I think this looks sane, but it's a big change and requires heavy testing. Maybe we can go over it tomorrow together?
e3e3fa7 to
c401358
Compare
…s of the whole state they need
4b5508d to
cc57f12
Compare
* fix: use a sync pool for strategy randomness, which offers slightly better perf under load * chore: rework metrics to use less locking on the hotpath * fix: variant strategies now correctly inherit stickiness from their strategy * chore: make metric count channels best effort send
First step to making this lock free is to stop using patterns that require locking. This PR makes some overdue changes to the way data is held in the SDK
Overall this makes the p99.9 unmeasurably small when operating on a standard strategy feature (which in theory is just a boolean return).