Skip to content

Commit e4efc8f

Browse files
emyllerclaude
andauthored
feat: Evaluate flags that depend on other flags (#120)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent a95445c commit e4efc8f

12 files changed

Lines changed: 275 additions & 56 deletions

File tree

‎.github/workflows/sdk-spec.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ permissions:
1111
jobs:
1212
sdk-spec:
1313
# Keep the ref in `uses:` and `spec-ref` in sync.
14-
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.3.0
14+
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0
1515
with:
16-
spec-ref: v0.3.0
16+
spec-ref: v0.7.0
17+
tags: core or local-evaluation
1718
language: ruby

‎.gitmodules‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[submodule "spec/engine-test-data"]
22
path = spec/engine-test-data
33
url = git@github.com:Flagsmith/engine-test-data.git
4-
branch = v3.7.0
4+
branch = v3.12.1
55
[submodule "spec/sdk-benchmarks"]
66
path = spec/sdk-benchmarks
77
url = https://github.com/Flagsmith/sdk-benchmarks.git

‎lib/flagsmith.rb‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,13 +222,12 @@ def get_identity_segments(identifier, traits = {})
222222
private
223223

224224
def environment_flags_from_document # rubocop:disable Metrics/MethodLength
225-
context = Flagsmith::Engine::Mappers.get_evaluation_context(environment)
226-
227-
unless context
225+
unless environment
228226
raise Flagsmith::ClientError,
229227
'Unable to get flags. No environment present.'
230228
end
231229

230+
context = Flagsmith::Engine::Mappers.get_environment_flags_evaluation_context(environment)
232231
evaluation_result = Flagsmith::Engine.get_evaluation_result(context)
233232

234233
Flagsmith::Flags::Collection.from_evaluation_result(

‎lib/flagsmith/engine/core.rb‎

Lines changed: 74 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
require 'semantic'
44
require 'securerandom'
5+
require 'set'
56

67
require_relative 'environments/models'
78
require_relative 'features/models'
89
require_relative 'features/constants'
910
require_relative 'identities/models'
11+
require_relative 'lazy_flags'
1012
require_relative 'organisations/models'
1113
require_relative 'projects/models'
1214
require_relative 'segments/evaluator'
@@ -16,7 +18,7 @@
1618

1719
module Flagsmith
1820
# Core evaluation logic for feature flags
19-
module Engine
21+
module Engine # rubocop:disable Metrics/ModuleLength
2022
extend self
2123
include Flagsmith::Engine::Utils::HashFunc
2224
include Flagsmith::Engine::Features::TargetingReasons
@@ -27,37 +29,42 @@ module Engine
2729
# @param evaluation_context [Hash] The evaluation context
2830
# @return [Hash] Evaluation result with flags and segments
2931
def get_evaluation_result(evaluation_context)
30-
evaluation_context = get_enriched_context(evaluation_context)
31-
segments, segment_overrides = evaluate_segments(evaluation_context)
32+
lazy_flags = LazyFlags.new(get_enriched_context(evaluation_context))
33+
evaluation_context = lazy_flags.context
34+
segments, segment_overrides = evaluate_segments(evaluation_context, lazy_flags)
3235
flags = evaluate_features(evaluation_context, segment_overrides)
36+
lazy_flags.apply_to(flags) if lazy_flags.used?
3337
{
3438
flags: flags,
3539
segments: segments
3640
}
3741
end
3842

3943
# Returns { segments: EvaluationResultSegments; segmentOverrides: Record<string, SegmentOverride>; }
40-
def evaluate_segments(evaluation_context)
44+
def evaluate_segments(evaluation_context, lazy_flags)
4145
return [], {} if evaluation_context[:segments].nil?
4246

43-
identity_segments = get_segments_from_context(evaluation_context)
47+
matching_segments = evaluation_context[:segments].each_value.select do |segment|
48+
cycle_hits = lazy_flags.cycle_hits
49+
context_in_segment?(evaluation_context, segment) && lazy_flags.cycle_hits == cycle_hits
50+
end
4451

45-
segments = identity_segments.map do |segment|
52+
segments = matching_segments.map do |segment|
4653
{ name: segment[:name], metadata: segment[:metadata] }.compact
4754
end
4855

49-
segment_overrides = process_segment_overrides(identity_segments)
56+
segment_overrides = process_segment_overrides(matching_segments)
5057

5158
[segments, segment_overrides]
5259
end
5360

5461
# Returns Record<string: override.name, SegmentOverride>
55-
def process_segment_overrides(identity_segments) # rubocop:disable Metrics/MethodLength
62+
def process_segment_overrides(matching_segments) # rubocop:disable Metrics/MethodLength
5663
segment_overrides = {}
5764

58-
identity_segments.each do |segment|
65+
matching_segments.each do |segment|
5966
Array(segment[:overrides]).each do |override|
60-
next unless should_apply_override(override, segment_overrides)
67+
next unless wins_over?(override, segment_overrides[override[:name]]&.dig(:feature))
6168

6269
segment_overrides[override[:name]] = {
6370
feature: override,
@@ -71,65 +78,94 @@ def process_segment_overrides(identity_segments) # rubocop:disable Metrics/Metho
7178

7279
def evaluate_features(evaluation_context, segment_overrides)
7380
identity_key = get_identity_key(evaluation_context)
81+
targeted_feature_names = get_targeted_feature_names(evaluation_context)
7482

7583
(evaluation_context[:features] || {}).each_with_object({}) do |(_, feature), flags|
76-
segment_override = segment_overrides[feature[:name]]
77-
final_feature = segment_override ? segment_override[:feature] : feature
84+
flags[feature[:name].to_sym] =
85+
get_feature_flag_result(feature, segment_overrides[feature[:name]], identity_key, targeted_feature_names)
86+
end
87+
end
88+
89+
# Returns the flag result of a feature, served by its segment override if any, or else by its environment default
90+
def get_feature_flag_result(feature, segment_override, identity_key, targeted_feature_names)
91+
return get_flag_result(segment_override[:feature], identity_key, targeting_match_reason(segment_override)) if segment_override
92+
93+
get_flag_result(feature, identity_key, fallback_reason(feature[:name], targeted_feature_names))
94+
end
7895

79-
flag_result = build_flag_result(final_feature, identity_key, segment_override)
80-
flags[final_feature[:name].to_sym] = flag_result
96+
# Returns the names of the features overridden by at least one segment
97+
def get_targeted_feature_names(evaluation_context)
98+
names = {}
99+
evaluation_context[:segments]&.each_value do |segment|
100+
segment[:overrides]&.each { |override| names[override[:name]] = true }
81101
end
102+
names
103+
end
104+
105+
# Returns the reason for a flag served by a segment override
106+
def targeting_match_reason(segment_override)
107+
"#{TARGETING_REASON_TARGETING_MATCH}; segment=#{segment_override[:segment_name]}"
108+
end
109+
110+
# Returns the reason for a flag served by its environment default:
111+
# DEFAULT if the feature has segment overrides that did not match, STATIC if it has none
112+
def fallback_reason(feature_name, targeted_feature_names)
113+
targeted_feature_names.key?(feature_name) ? TARGETING_REASON_DEFAULT : TARGETING_REASON_STATIC
82114
end
83115

84-
# Returns {value: any; reason?: string}
116+
# Returns {name: string; enabled: boolean; value: any; reason: string; variant?: string; metadata?: any}
117+
def get_flag_result(feature, identity_key, reason)
118+
evaluated = evaluate_feature_value(feature, identity_key)
119+
120+
flag_result = {
121+
name: feature[:name],
122+
enabled: feature[:enabled],
123+
value: evaluated[:value],
124+
reason: evaluated[:reason] || reason,
125+
variant: evaluated[:variant]
126+
}
127+
128+
flag_result[:metadata] = feature[:metadata] if feature[:metadata]
129+
flag_result
130+
end
131+
132+
# Returns {value: any; reason?: string; variant?: string}
85133
def evaluate_feature_value(feature, identity_key = nil)
86134
return get_multivariate_feature_value(feature, identity_key) if feature[:variants]&.any? && identity_key
87135

88-
{ value: feature[:value], reason: nil }
136+
{ value: feature[:value], reason: nil, variant: nil }
89137
end
90138

91-
# Returns {value: any; reason?: string}
139+
# Returns {value: any; reason?: string; variant: string}
92140
def get_multivariate_feature_value(feature, identity_key)
93141
percentage_value = hashed_percentage_for_object_ids([feature[:key], identity_key])
94-
sorted_variants = (feature[:variants] || []).sort_by { |v| v[:priority] || WEAKEST_PRIORITY }
142+
sorted_variants = (feature[:variants] || []).sort_by { |v| v[:priority] || DEFAULT_PRIORITY }
95143

96144
variant = find_matching_variant(sorted_variants, percentage_value)
97-
variant || { value: feature[:value], reason: nil }
145+
variant || { value: feature[:value], reason: nil, variant: 'control' }
98146
end
99147

100148
def find_matching_variant(sorted_variants, percentage_value)
101149
start_percentage = 0
102150
sorted_variants.each do |variant|
103151
limit = start_percentage + variant[:weight]
104-
return { value: variant[:value], reason: "#{TARGETING_REASON_SPLIT}; weight=#{variant[:weight]}" } if start_percentage <= percentage_value && percentage_value < limit
152+
if start_percentage <= percentage_value && percentage_value < limit
153+
return { value: variant[:value], reason: "#{TARGETING_REASON_SPLIT}; weight=#{variant[:weight]}", variant: variant[:key] }
154+
end
105155

106156
start_percentage = limit
107157
end
108158
nil
109159
end
110160

111-
# returns boolean
112-
def should_apply_override(override, existing_overrides)
113-
current_override = existing_overrides[override[:name]]
114-
!current_override || stronger_priority?(override[:priority], current_override[:feature][:priority])
161+
# Whether a segment override takes precedence over the one held so far.
162+
# The lowest priority wins, and the first seen wins a tie.
163+
def wins_over?(candidate, incumbent)
164+
incumbent.nil? || (candidate[:priority] || DEFAULT_PRIORITY) < (incumbent[:priority] || DEFAULT_PRIORITY)
115165
end
116166

117167
private
118168

119-
def build_flag_result(feature, identity_key, segment_override)
120-
evaluated = evaluate_feature_value(feature, identity_key)
121-
122-
flag_result = {
123-
name: feature[:name],
124-
enabled: feature[:enabled],
125-
value: evaluated[:value],
126-
reason: evaluated[:reason] || (segment_override ? "#{TARGETING_REASON_TARGETING_MATCH}; segment=#{segment_override[:segment_name]}" : TARGETING_REASON_DEFAULT)
127-
}
128-
129-
flag_result[:metadata] = feature[:metadata] if feature[:metadata]
130-
flag_result
131-
end
132-
133169
# Extract identity key from evaluation context
134170
#
135171
# @param evaluation_context [Hash] The evaluation context
@@ -139,9 +175,5 @@ def get_identity_key(evaluation_context)
139175

140176
evaluation_context[:identity][:key]
141177
end
142-
143-
def stronger_priority?(priority_a, priority_b)
144-
(priority_a || WEAKEST_PRIORITY) < (priority_b || WEAKEST_PRIORITY)
145-
end
146178
end
147179
end

‎lib/flagsmith/engine/features/constants.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ module Features
66
# Targeting reason constants for evaluation results
77
module TargetingReasons
88
TARGETING_REASON_DEFAULT = 'DEFAULT'
9+
TARGETING_REASON_STATIC = 'STATIC'
910
TARGETING_REASON_TARGETING_MATCH = 'TARGETING_MATCH'
1011
TARGETING_REASON_SPLIT = 'SPLIT'
12+
TARGETING_REASON_CIRCULAR_DEPENDENCY = 'ERROR; code=CIRCULAR_DEPENDENCY'
1113
end
1214
end
1315
end

‎lib/flagsmith/engine/lazy_flags.rb‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# frozen_string_literal: true
2+
3+
require 'set'
4+
5+
module Flagsmith
6+
module Engine
7+
# The `$.flags` of an evaluation context, resolving a flag the first time a segment condition reads it.
8+
#
9+
# Flags are only reachable by name: JSONPath reads them through `dig`.
10+
class LazyFlags
11+
# The evaluation context, with `$.flags` bound to this instance
12+
attr_reader :context
13+
14+
# Incremented whenever a dependency cycle is cut. A segment whose evaluation increments it
15+
# read a flag that could not be resolved, so it must not match.
16+
attr_reader :cycle_hits
17+
18+
def initialize(context)
19+
@context = context.merge(flags: self)
20+
@cycle_hits = 0
21+
end
22+
23+
# @param feature_name [String]
24+
# @return [Hash, nil] The flag result, or nil for a feature absent from the context or in a cycle
25+
def dig(feature_name)
26+
start unless used?
27+
resolve(feature_name) unless @resolved.key?(feature_name)
28+
@resolved[feature_name]
29+
end
30+
31+
# Whether a segment condition read any flag
32+
def used?
33+
!@resolved.nil?
34+
end
35+
36+
# Applies the flags resolved for segment conditions over the single-pass flag results
37+
#
38+
# @param flags [Hash{Symbol => Hash}] The flag results, keyed by feature name
39+
def apply_to(flags)
40+
@resolved.each_value { |flag| flags[flag[:name].to_sym] = flag }
41+
@cyclic.each do |feature_name|
42+
flag = flags[feature_name.to_sym]
43+
flag[:reason] = Features::TargetingReasons::TARGETING_REASON_CIRCULAR_DEPENDENCY if flag
44+
end
45+
end
46+
47+
private
48+
49+
def start
50+
@resolved = {}
51+
@cyclic = Set.new
52+
@resolving = []
53+
@segment_matches = {}
54+
@segment_keys_by_feature_name = {}
55+
(@context[:segments] || {}).each do |segment_key, segment|
56+
Array(segment[:overrides]).each do |override|
57+
(@segment_keys_by_feature_name[override[:name]] ||= []) << segment_key
58+
end
59+
end
60+
end
61+
62+
def resolve(feature_name) # rubocop:disable Metrics/MethodLength
63+
if @resolving.include?(feature_name)
64+
@cyclic.merge(@resolving[@resolving.index(feature_name)..])
65+
@cycle_hits += 1
66+
return
67+
end
68+
69+
feature = find_feature(feature_name)
70+
return unless feature
71+
72+
@resolving.push(feature_name)
73+
begin
74+
segment_override = find_segment_override(feature_name)
75+
ensure
76+
@resolving.pop
77+
end
78+
return if @cyclic.include?(feature_name)
79+
80+
@resolved[feature_name] = Engine.get_feature_flag_result(
81+
feature, segment_override, @context.dig(:identity, :key), @segment_keys_by_feature_name
82+
)
83+
end
84+
85+
def find_feature(feature_name)
86+
features = @context[:features]
87+
return nil unless features
88+
89+
features[feature_name] || features[feature_name.to_sym]
90+
end
91+
92+
def find_segment_override(feature_name)
93+
segment_override = nil
94+
@segment_keys_by_feature_name.fetch(feature_name, []).each do |segment_key|
95+
next unless segment_match?(segment_key)
96+
97+
segment = @context[:segments][segment_key]
98+
segment[:overrides].each do |override|
99+
next unless override[:name] == feature_name && Engine.wins_over?(override, segment_override&.dig(:feature))
100+
101+
segment_override = { feature: override, segment_name: segment[:name] }
102+
end
103+
end
104+
segment_override
105+
end
106+
107+
def segment_match?(segment_key)
108+
return @segment_matches[segment_key] if @segment_matches.key?(segment_key)
109+
110+
cycle_hits = @cycle_hits
111+
matches = Segments::Evaluator.context_in_segment?(@context, @context[:segments][segment_key])
112+
return false if @cycle_hits != cycle_hits # Rests on a flag that could not be resolved
113+
114+
@segment_matches[segment_key] = matches
115+
end
116+
end
117+
end
118+
end

0 commit comments

Comments
 (0)