Skip to content

chore: public api works on atomic pointers - #211

Merged
sighphyre merged 12 commits into
fix/storage-returns-pointersfrom
chore/public-api-works-on-atomic-pointers
Nov 21, 2025
Merged

chore: public api works on atomic pointers#211
sighphyre merged 12 commits into
fix/storage-returns-pointersfrom
chore/public-api-works-on-atomic-pointers

Conversation

@sighphyre

@sighphyre sighphyre commented Nov 18, 2025

Copy link
Copy Markdown
Member

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

  • The Datastore layer is now a data store. It no longer caches, that's now handled elsewhere. This has the nice side effect of allowing caching to work correctly when end users implement their own caching
  • Segments are now correctly persisted, meaning first load from disk with the SDK will no longer cause features using segments to fail until API hydration kicks in
  • Segments and features are no longer cached independently in different layers in the SDK
  • Segments and features are held together behind an atomic pointer, meaning synchronization is now done by hardware and not between the Go runtime and the OS
  • Feature evaluation logic is broken into it's own home, expressed as a set of functions that operate on the held feature state

Overall this makes the p99.9 unmeasurably small when operating on a standard strategy feature (which in theory is just a boolean return).

@coveralls

coveralls commented Nov 19, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 19563582314

Details

  • 254 of 351 (72.36%) changed or added relevant lines in 10 files are covered.
  • 56 unchanged lines in 8 files lost coverage.
  • Overall coverage decreased (-4.3%) to 77.151%

Changes Missing Coverage Covered Lines Changed/Added Lines %
storage.go 7 8 87.5%
api/feature.go 4 6 66.67%
internal/strategies/flexible_rollout.go 0 2 0.0%
bootstrap_storage.go 7 14 50.0%
internal/strategies/helpers.go 0 10 0.0%
metrics.go 72 101 71.29%
feature_state.go 67 113 59.29%
Files with Coverage Reduction New Missed Lines %
storage.go 1 78.57%
nooplistener.go 2 62.5%
bootstrap_storage.go 3 50.0%
internal/api/metrics.go 3 0.0%
debuglistener.go 6 0.0%
unleash_mock.go 6 75.0%
client.go 12 86.93%
metrics.go 23 75.37%
Totals Coverage Status
Change from base Build 19537135969: -4.3%
Covered Lines: 1587
Relevant Lines: 2057

💛 - Coveralls

Comment thread bootstrap_storage.go
ds.backingStore.Init(backupPath, appName)
}

func (bs *BootstrapStorage) Load() (*api.FeatureResponse, error) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread bootstrap_storage.go
return bs.backingStore.Persist()
}

func (bs *BootstrapStorage) Get(key string) (*api.Feature, bool) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Get isn't needed anymore since this isn't the cache now. Instead the equivalent responsibility is done on the repository layer

Comment thread bootstrap_storage.go
return bs.backingStore.Get(key)
}

func (bs *BootstrapStorage) List() []*api.Feature {

@sighphyre sighphyre Nov 19, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Likewise this function no longer makes sense. Honestly it probably never made sense on the bootstrapping layer

Comment thread client.go
// 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()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread client.go
// 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) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread client.go
}
}

func resolveToggle(snapshot *FeatureMemoryState, opts featureOption, featureName string) *api.Feature {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread delta_processor.go
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread delta_processor.go
}
}

if err := dp.repository.updateStorageWithDelta(currentFeatures, segments); err != nil {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread feature_state.go
@@ -0,0 +1,163 @@
package unleash

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread feature_state.go
}

// evaluateFeature applies strategies + constraints for a single feature.
// It does NOT handle fallbacks or missing features; that's the caller's job.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread repository.go
return repo
}

func (r *repository) updateState(features *api.FeatureResponse) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread repository.go
if r.streamingClient != nil {
r.streamingClient.stop()
}
if err := r.options.storage.Persist(); err != nil {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread repository.go
}

// 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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread repository.go
// IsStreaming returns whether the repository is currently in streaming mode
func (r *repository) IsStreaming() bool {
r.RLock()
defer r.RUnlock()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The streaming property is set on construction and never mutated. This lock does nothing but introduce contention that doesn't need to be here

Comment thread repository.go
return feature
}

func (r *repository) resolveSegmentConstraints(strategy api.Strategy) ([]api.Constraint, error) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This absolutely does not belong in this layer. It's been moved to feature_state where it can be happy and lock free

Comment thread storage.go

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread streaming_client_test.go
func (m *mockEvent) Event() string { return m.event }
func (m *mockEvent) Data() string { return m.data }

type NoOpStorage struct{}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

@sighphyre
sighphyre marked this pull request as ready for review November 19, 2025 09:10

@FredrikOseberg FredrikOseberg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this looks sane, but it's a big change and requires heavy testing. Maybe we can go over it tomorrow together?

@github-project-automation github-project-automation Bot moved this from In Progress to Approved PRs in Issues and PRs Nov 19, 2025
@sighphyre
sighphyre force-pushed the fix/storage-returns-pointers branch from e3e3fa7 to c401358 Compare November 20, 2025 12:38
@sighphyre
sighphyre force-pushed the chore/public-api-works-on-atomic-pointers branch from 4b5508d to cc57f12 Compare November 20, 2025 12:41
sighphyre and others added 2 commits November 20, 2025 15:19
* 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
@sighphyre
sighphyre merged commit 6ecf2d4 into fix/storage-returns-pointers Nov 21, 2025
5 checks passed
@sighphyre
sighphyre deleted the chore/public-api-works-on-atomic-pointers branch November 21, 2025 07:44
@github-project-automation github-project-automation Bot moved this from Approved PRs to Done in Issues and PRs Nov 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants