Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "flagengine/engine-test-data"]
path = flagengine/engine-test-data
url = git@github.com:Flagsmith/engine-test-data.git
branch = v3.10.0
branch = v3.11.0
28 changes: 28 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,34 @@ func TestGetIdentityFlagsAppliesSegmentOverridesWithReason(t *testing.T) {
assert.Equal(t, "TARGETING_MATCH; segment=Test Segment", flag.Reason)
}

func TestGetIdentityFlagsAppliesSegmentOverrideDependingOnAnotherFlag(t *testing.T) {
// Given
ctx := context.Background()
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
_, _ = io.WriteString(rw, fixtures.EnvironmentJsonWithFlagDependency)
}))
defer server.Close()

client := flagsmith.NewClient(fixtures.EnvironmentAPIKey,
flagsmith.WithLocalEvaluation(ctx),
flagsmith.WithBaseURL(server.URL+"/api/v1/"))
err := client.UpdateEnvironment(ctx)
assert.NoError(t, err)

// When
flags, err := client.GetIdentityFlags(ctx, "test_identity", nil)

// Then
assert.NoError(t, err)
flag, err := flags.GetFlag("dependent")
assert.NoError(t, err)
assert.True(t, flag.Enabled)
assert.Equal(t, "dependent_override", flag.Value)
assert.Equal(t, "TARGETING_MATCH; segment=Prerequisite Enabled", flag.Reason)
}

func TestGetIdentityFlagsSetsVariantForMultivariateFeature(t *testing.T) {
// Given
ctx := context.Background()
Expand Down
76 changes: 76 additions & 0 deletions fixtures/fixture.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,82 @@ const EnvironmentJsonWithMultivariateFeature = `
}
`

// EnvironmentJsonWithFlagDependency contains a feature overridden by a segment
// that matches when another feature, the prerequisite, is enabled.
const EnvironmentJsonWithFlagDependency = `
{
"api_key": "B62qaMZNwfiqT76p38ggrQ",
"name": "Test Environment",
"updated_at": "2023-12-06T10:21:54.079725Z",
"project": {
"name": "Test project",
"organisation": {
"feature_analytics": false,
"name": "Test Org",
"id": 1,
"persist_trait_data": true,
"stop_serving_flags": false
},
"id": 1,
"hide_disabled_flags": false,
"segments": [{
"id": 1,
"name": "Prerequisite Enabled",
"feature_states": [{
"multivariate_feature_state_values": [],
"feature_state_value": "dependent_override",
"id": 3,
"featurestate_uuid": "dependent-override-uuid",
"feature": {
"name": "dependent",
"type": "STANDARD",
"id": 2
},
"segment_id": 1,
"enabled": true
}],
"rules": [{
"type": "ALL",
"conditions": [{
"operator": "EQUAL",
"property_": "$.flags.prerequisite.enabled",
"value": "true"
}],
"rules": []
}]
}]
},
"segment_overrides": [],
"id": 1,
"feature_states": [{
"multivariate_feature_state_values": [],
"feature_state_value": "prerequisite_value",
"id": 1,
"featurestate_uuid": "prerequisite-uuid",
"feature": {
"name": "prerequisite",
"type": "STANDARD",
"id": 1
},
"segment_id": null,
"enabled": true
}, {
"multivariate_feature_state_values": [],
"feature_state_value": "dependent_default",
"id": 2,
"featurestate_uuid": "dependent-uuid",
"feature": {
"name": "dependent",
"type": "STANDARD",
"id": 2
},
"segment_id": null,
"enabled": false
}],
"identity_overrides": []
}
`

const FlagsJson = `
[{
"id": 1,
Expand Down
133 changes: 133 additions & 0 deletions flagengine/dependencies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package flagengine

import (
"fmt"
"slices"

"github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval"
)

const circularDependencyReason = "ERROR; code=CIRCULAR_DEPENDENCY"

// flagDependencyResolver resolves the flags read by segment conditions on `$.flags`,
// following dependencies transitively and memoising the results for one evaluation.
// Its state is only allocated once a condition reads a flag.
type flagDependencyResolver struct {
ec *engine_eval.EngineEvaluationContext
sortedSegmentKeys []string

identityKey *string
segmentKeysByFeatureName map[string][]string
segmentMatches map[string]bool
resolving []string

// Flag results resolved so far, mapped by feature name.
resolved map[string]*engine_eval.FlagResult
// Names of the features found to be in a dependency cycle.
cyclic map[string]struct{}
// Incremented whenever a cycle is cut. A segment whose evaluation increments it
// read a flag that could not be resolved, so it must not match.
cycleHits int
}

func newFlagDependencyResolver(ec *engine_eval.EngineEvaluationContext, sortedSegmentKeys []string) *flagDependencyResolver {
return &flagDependencyResolver{ec: ec, sortedSegmentKeys: sortedSegmentKeys}
}

func (r *flagDependencyResolver) init() {
r.identityKey = getIdentityKey(r.ec)
r.segmentKeysByFeatureName = make(map[string][]string)
for _, segmentKey := range r.sortedSegmentKeys {
for _, override := range r.ec.Segments[segmentKey].Overrides {
r.segmentKeysByFeatureName[override.Name] = append(r.segmentKeysByFeatureName[override.Name], segmentKey)
}
}
r.segmentMatches = make(map[string]bool)
r.resolved = make(map[string]*engine_eval.FlagResult)
r.cyclic = make(map[string]struct{})
}

// ResolveFlag implements engine_eval.FlagResolver.
func (r *flagDependencyResolver) ResolveFlag(featureName string) *engine_eval.FlagResult {
if r.resolved == nil {
r.init()
}
if _, ok := r.resolved[featureName]; !ok {
r.resolveFeature(featureName)
}
return r.resolved[featureName]
}

func (r *flagDependencyResolver) resolveFeature(featureName string) {
if cycleStart := slices.Index(r.resolving, featureName); cycleStart >= 0 {
for _, name := range r.resolving[cycleStart:] {
r.cyclic[name] = struct{}{}
}
r.cycleHits++
return
}
featureContext, ok := r.ec.Features[featureName]
if !ok {
return
}

r.resolving = append(r.resolving, featureName)
override, hasOverride := r.getSegmentOverride(featureName)
r.resolving = r.resolving[:len(r.resolving)-1]

if _, ok := r.cyclic[featureName]; ok {
return
}

reason := "DEFAULT"
if hasOverride {
featureContext = *override.featureContext
reason = fmt.Sprintf("TARGETING_MATCH; segment=%s", override.segmentName)
}
flagResult := getFlagResultFromFeatureContext(featureName, &featureContext, r.identityKey, reason)
r.resolved[featureName] = &flagResult
}

// matchesSegment reports whether the context matches a segment, resolving the flags it depends on.
func (r *flagDependencyResolver) matchesSegment(segmentKey string) bool {
if matches, ok := r.segmentMatches[segmentKey]; ok {
return matches
}

cycleHits := r.cycleHits
segmentContext := r.ec.Segments[segmentKey]
matches := engine_eval.IsContextInSegmentWithFlags(r.ec, &segmentContext, r)
if r.cycleHits != cycleHits {
return false
}

if r.segmentMatches != nil {
r.segmentMatches[segmentKey] = matches
}
return matches
}

func (r *flagDependencyResolver) getSegmentOverride(featureName string) (featureContextWithSegmentName, bool) {
var segmentOverride featureContextWithSegmentName
found := false

for _, segmentKey := range r.segmentKeysByFeatureName[featureName] {
if !r.matchesSegment(segmentKey) {
continue
}
segmentContext := r.ec.Segments[segmentKey]
for i := range segmentContext.Overrides {
override := &segmentContext.Overrides[i]
if override.Name != featureName {
continue
}
if found && !overrideTakesPrecedence(override, segmentOverride.featureContext) {
continue
}
segmentOverride = featureContextWithSegmentName{featureContext: override, segmentName: segmentContext.Name}
found = true
}
}

return segmentOverride, found
}
2 changes: 1 addition & 1 deletion flagengine/engine-test-data
Submodule engine-test-data updated 19 files
+61 −0 test_cases/test_flag_dependency__absent_prerequisite__should_not_override.jsonc
+79 −0 test_cases/test_flag_dependency__bracket_rooted_property__should_not_override.jsonc
+81 −0 test_cases/test_flag_dependency__bracketed_field__should_override.jsonc
+108 −0 test_cases/test_flag_dependency__competing_overrides__lowest_priority_wins.jsonc
+87 −0 test_cases/test_flag_dependency__context_supplied_flags__should_be_ignored.jsonc
+106 −0 test_cases/test_flag_dependency__cyclic__should_not_override.jsonc
+106 −0 test_cases/test_flag_dependency__cyclic_enabled_prerequisite__segment_should_not_match.jsonc
+66 −0 test_cases/test_flag_dependency__cyclic_is_not_set__segment_should_not_match.jsonc
+98 −0 test_cases/test_flag_dependency__nested_rule__should_override.jsonc
+59 −0 test_cases/test_flag_dependency__no_overrides__segment_still_reported.jsonc
+73 −0 test_cases/test_flag_dependency__prerequisite_disabled__should_not_override.jsonc
+81 −0 test_cases/test_flag_dependency__prerequisite_enabled__should_override.jsonc
+79 −0 test_cases/test_flag_dependency__quoted_feature_name__should_override.jsonc
+181 −0 test_cases/test_flag_dependency__shared_segment__resolved_once_per_feature.jsonc
+120 −0 test_cases/test_flag_dependency__transitive__should_cascade.jsonc
+112 −0 test_cases/test_flag_dependency__transitive_unmet__should_not_cascade.jsonc
+76 −0 test_cases/test_flag_dependency__value__should_override.jsonc
+93 −0 test_cases/test_flag_dependency__variant__should_override.jsonc
+79 −0 test_cases/test_flag_dependency__wildcard_property__should_not_override.jsonc
82 changes: 46 additions & 36 deletions flagengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,16 @@ func getPriorityOrDefault(priority *float64) float64 {
return math.Inf(1) // Weakest possible priority
}

func getMatchingSegmentsAndOverrides(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) {
func getMatchingSegmentsAndOverrides(ec *engine_eval.EngineEvaluationContext, sortedSegmentKeys []string, resolver *flagDependencyResolver) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) {
segmentResults := []engine_eval.SegmentResult{}
featureOverrides := make(map[string]featureContextWithSegmentName)

// Process segments in deterministic order (sorted by key)
for _, segmentContext := range getSortedSegments(ec.Segments) {
if !engine_eval.IsContextInSegment(ec, &segmentContext) {
for _, segmentKey := range sortedSegmentKeys {
if !resolver.matchesSegment(segmentKey) {
continue
}
segmentContext := ec.Segments[segmentKey]

// Record matched segment
segmentResults = append(segmentResults, engine_eval.SegmentResult{
Expand All @@ -46,61 +47,61 @@ func getMatchingSegmentsAndOverrides(ec *engine_eval.EngineEvaluationContext) ([
return segmentResults, featureOverrides
}

// getSortedSegments returns segments sorted by their keys for deterministic ordering.
func getSortedSegments(segments map[string]engine_eval.SegmentContext) []engine_eval.SegmentContext {
// getSortedSegmentKeys returns segment keys sorted for deterministic ordering.
func getSortedSegmentKeys(segments map[string]engine_eval.SegmentContext) []string {
keys := make([]string, 0, len(segments))
for key := range segments {
keys = append(keys, key)
}
sort.Strings(keys)

sorted := make([]engine_eval.SegmentContext, 0, len(keys))
for _, key := range keys {
sorted = append(sorted, segments[key])
}
return sorted
return keys
}

// applySegmentOverrides updates the feature overrides map with this segment's overrides,
// only replacing existing overrides if the new one has equal or higher priority.
func applySegmentOverrides(segment *engine_eval.SegmentContext, featureOverrides map[string]featureContextWithSegmentName) {
for i := range segment.Overrides {
override := &segment.Overrides[i]
newPriority := getPriorityOrDefault(override.Priority)

// Check if we should use this override
if existing, exists := featureOverrides[override.Name]; exists {
existingPriority := getPriorityOrDefault(existing.featureContext.Priority)
if newPriority > existingPriority {
continue // Existing override has higher priority
}
if existing, exists := featureOverrides[override.Name]; exists && !overrideTakesPrecedence(override, existing.featureContext) {
continue
}

// Use this override (either it's new or has equal/higher priority)
featureOverrides[override.Name] = featureContextWithSegmentName{
featureContext: override,
segmentName: segment.Name,
}
}
}

func getFlagResults(ec *engine_eval.EngineEvaluationContext, featureOverrides map[string]featureContextWithSegmentName) map[string]*engine_eval.FlagResult {
flags := make(map[string]*engine_eval.FlagResult)

// Get identity key if identity exists
var identityKey *string
if ec.Identity != nil {
// If identity key is not provided, construct it from environment key and identifier
if ec.Identity.Key == "" {
constructedKey := ec.Environment.Key + "_" + ec.Identity.Identifier
identityKey = &constructedKey
} else {
identityKey = &ec.Identity.Key
}
// overrideTakesPrecedence reports whether a segment override replaces the one held so far,
// i.e. whether it has equal or higher priority.
func overrideTakesPrecedence(candidate, incumbent *engine_eval.FeatureContext) bool {
return getPriorityOrDefault(candidate.Priority) <= getPriorityOrDefault(incumbent.Priority)
}

// getIdentityKey returns the identity key used for multivariate bucketing, or nil without an identity.
func getIdentityKey(ec *engine_eval.EngineEvaluationContext) *string {
if ec.Identity == nil {
return nil
}
// If identity key is not provided, construct it from environment key and identifier
if ec.Identity.Key == "" {
constructedKey := ec.Environment.Key + "_" + ec.Identity.Identifier
return &constructedKey
}
return &ec.Identity.Key
}

// getFlagResults evaluates every feature, reusing the flags already resolved as dependencies.
func getFlagResults(ec *engine_eval.EngineEvaluationContext, featureOverrides map[string]featureContextWithSegmentName, resolved map[string]*engine_eval.FlagResult) map[string]*engine_eval.FlagResult {
flags := make(map[string]*engine_eval.FlagResult, len(ec.Features))
identityKey := getIdentityKey(ec)

if ec.Features != nil {
for featureName, featureContext := range ec.Features {
if flagResult, ok := resolved[featureName]; ok {
flags[featureName] = flagResult
continue
}
reason := "DEFAULT"
// Check if there's an override for this feature
if override, ok := featureOverrides[featureName]; ok {
Expand All @@ -117,11 +118,20 @@ func getFlagResults(ec *engine_eval.EngineEvaluationContext, featureOverrides ma

// GetEvaluationResult computes flags and matched segments.
func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult {
sortedSegmentKeys := getSortedSegmentKeys(ec.Segments)
resolver := newFlagDependencyResolver(ec, sortedSegmentKeys)

// Process segments and get overrides
segmentResults, featureOverrides := getMatchingSegmentsAndOverrides(ec)
segmentResults, featureOverrides := getMatchingSegmentsAndOverrides(ec, sortedSegmentKeys, resolver)

// Get flag results
flags := getFlagResults(ec, featureOverrides)
flags := getFlagResults(ec, featureOverrides, resolver.resolved)

for featureName := range resolver.cyclic {
if flagResult, ok := flags[featureName]; ok {
flagResult.Reason = circularDependencyReason
}
}

return engine_eval.EvaluationResult{
Flags: flags,
Expand Down
Loading
Loading