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 "spec/engine-test-data"]
path = spec/engine-test-data
url = git@github.com:Flagsmith/engine-test-data.git
branch = v3.7.0
branch = v3.12.1
98 changes: 62 additions & 36 deletions lib/flagsmith/engine/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
require_relative 'segments/models'
require_relative 'utils/hash_func'
require_relative 'mappers'
require_relative 'flag_dependencies'

module Flagsmith
# Core evaluation logic for feature flags
module Engine
module Engine # rubocop:disable Metrics/ModuleLength
extend self
include Flagsmith::Engine::Utils::HashFunc
include Flagsmith::Engine::Features::TargetingReasons
Expand All @@ -28,25 +29,27 @@ module Engine
# @return [Hash] Evaluation result with flags and segments
def get_evaluation_result(evaluation_context)
evaluation_context = get_enriched_context(evaluation_context)
segments, segment_overrides = evaluate_segments(evaluation_context)
resolved_flags = LazyFlags.new
evaluation_context = evaluation_context.merge(flags: resolved_flags)
resolved_flags.bind(evaluation_context)

segments, segment_overrides = evaluate_segments(evaluation_context, resolved_flags)
flags = evaluate_features(evaluation_context, segment_overrides)
{
flags: flags,
segments: segments
}
apply_resolved_flags(flags, resolved_flags) if resolved_flags.used?
{ flags: flags, segments: segments }
end

# Returns { segments: EvaluationResultSegments; segmentOverrides: Record<string, SegmentOverride>; }
def evaluate_segments(evaluation_context)
def evaluate_segments(evaluation_context, resolved_flags)
return [], {} if evaluation_context[:segments].nil?

identity_segments = get_segments_from_context(evaluation_context)
matched_segments = evaluation_context[:segments].values.select { |segment| resolved_flags.segment_matches?(segment) }

segments = identity_segments.map do |segment|
segments = matched_segments.map do |segment|
{ name: segment[:name], metadata: segment[:metadata] }.compact
end

segment_overrides = process_segment_overrides(identity_segments)
segment_overrides = process_segment_overrides(matched_segments)

[segments, segment_overrides]
end
Expand All @@ -71,63 +74,90 @@ def process_segment_overrides(identity_segments) # rubocop:disable Metrics/Metho

def evaluate_features(evaluation_context, segment_overrides)
identity_key = get_identity_key(evaluation_context)
targeted_feature_names = get_targeted_feature_names(evaluation_context)

(evaluation_context[:features] || {}).each_with_object({}) do |(_, feature), flags|
segment_override = segment_overrides[feature[:name]]
final_feature = segment_override ? segment_override[:feature] : feature
reason = segment_override ? get_targeting_match_reason(segment_override) : get_fallback_reason(feature[:name], targeted_feature_names)

flag_result = build_flag_result(final_feature, identity_key, segment_override)
flags[final_feature[:name].to_sym] = flag_result
flags[final_feature[:name].to_sym] = get_flag_result(final_feature, identity_key, reason)
end
end

# Returns {value: any; reason?: string}
def evaluate_feature_value(feature, identity_key = nil)
return get_multivariate_feature_value(feature, identity_key) if feature[:variants]&.any? && identity_key
# Returns {name: string; enabled: boolean; value: any; reason: string; variant?: string; metadata?: any}
def get_flag_result(feature, identity_key, reason)
value = feature[:value]
variant = nil

if identity_key && feature[:variants]&.any?
variant = 'control'
matching_variant = get_multivariate_feature_value(feature, identity_key)
value, variant, reason = matching_variant.values_at(:value, :variant, :reason) if matching_variant
end

{ value: feature[:value], reason: nil }
flag_result = { name: feature[:name], enabled: feature[:enabled], value: value, reason: reason, variant: variant }
flag_result[:metadata] = feature[:metadata] if feature[:metadata]
flag_result
end

# Returns {value: any; reason?: string}
# Returns {value: any; variant?: string; reason: string} or nil
def get_multivariate_feature_value(feature, identity_key)
percentage_value = hashed_percentage_for_object_ids([feature[:key], identity_key])
sorted_variants = (feature[:variants] || []).sort_by { |v| v[:priority] || WEAKEST_PRIORITY }

variant = find_matching_variant(sorted_variants, percentage_value)
variant || { value: feature[:value], reason: nil }
find_matching_variant(sorted_variants, percentage_value)
end

def find_matching_variant(sorted_variants, percentage_value)
start_percentage = 0
sorted_variants.each do |variant|
limit = start_percentage + variant[:weight]
return { value: variant[:value], reason: "#{TARGETING_REASON_SPLIT}; weight=#{variant[:weight]}" } if start_percentage <= percentage_value && percentage_value < limit
if start_percentage <= percentage_value && percentage_value < limit
return { value: variant[:value], variant: variant[:key], reason: "#{TARGETING_REASON_SPLIT}; weight=#{variant[:weight]}" }
end

start_percentage = limit
end
nil
end

# Returns the names of the features overridden by at least one segment
def get_targeted_feature_names(evaluation_context)
names = {}
evaluation_context[:segments]&.each_value do |segment|
segment[:overrides]&.each { |override| names[override[:name]] = true }
end
names
end

# Returns DEFAULT for a feature whose segment overrides did not match, STATIC for a feature without any
def get_fallback_reason(feature_name, targeted_feature_names)
targeted_feature_names.key?(feature_name) ? TARGETING_REASON_DEFAULT : TARGETING_REASON_STATIC
end

def get_targeting_match_reason(segment_override)
"#{TARGETING_REASON_TARGETING_MATCH}; segment=#{segment_override[:segment_name]}"
end

# returns boolean
def should_apply_override(override, existing_overrides)
current_override = existing_overrides[override[:name]]
!current_override || stronger_priority?(override[:priority], current_override[:feature][:priority])
end

private

def build_flag_result(feature, identity_key, segment_override)
evaluated = evaluate_feature_value(feature, identity_key)
def stronger_priority?(priority_a, priority_b)
(priority_a || WEAKEST_PRIORITY) < (priority_b || WEAKEST_PRIORITY)
end

flag_result = {
name: feature[:name],
enabled: feature[:enabled],
value: evaluated[:value],
reason: evaluated[:reason] || (segment_override ? "#{TARGETING_REASON_TARGETING_MATCH}; segment=#{segment_override[:segment_name]}" : TARGETING_REASON_DEFAULT)
}
private

flag_result[:metadata] = feature[:metadata] if feature[:metadata]
flag_result
# Flags resolved while evaluating segment conditions take precedence, as they were resolved with the cycle guard
def apply_resolved_flags(flags, resolved_flags)
resolved_flags.each { |feature_name, flag_result| flags[feature_name] = flag_result }
resolved_flags.cyclic_feature_names.each do |feature_name|
flags[feature_name] = flags[feature_name].merge(reason: TARGETING_REASON_CIRCULAR_DEPENDENCY) if flags.key?(feature_name)
end
end

# Extract identity key from evaluation context
Expand All @@ -139,9 +169,5 @@ def get_identity_key(evaluation_context)

evaluation_context[:identity][:key]
end

def stronger_priority?(priority_a, priority_b)
(priority_a || WEAKEST_PRIORITY) < (priority_b || WEAKEST_PRIORITY)
end
end
end
2 changes: 2 additions & 0 deletions lib/flagsmith/engine/features/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module Features
# Targeting reason constants for evaluation results
module TargetingReasons
TARGETING_REASON_DEFAULT = 'DEFAULT'
TARGETING_REASON_STATIC = 'STATIC'
TARGETING_REASON_CIRCULAR_DEPENDENCY = 'ERROR; code=CIRCULAR_DEPENDENCY'
TARGETING_REASON_TARGETING_MATCH = 'TARGETING_MATCH'
TARGETING_REASON_SPLIT = 'SPLIT'
end
Expand Down
140 changes: 140 additions & 0 deletions lib/flagsmith/engine/flag_dependencies.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# frozen_string_literal: true

require 'set'

module Flagsmith
module Engine
# The `$.flags` mapping of an evaluation context, resolving a flag when a segment condition first reads it,
# and the memo of the context's segment verdicts.
class LazyFlags < Hash
# Incremented whenever a cycle is cut, so a segment evaluated meanwhile is known to rest on an unresolved flag.
attr_accessor :cycle_hits

def initialize
super() { |_, feature_name| resolve(feature_name) }
@cycle_hits = 0
@used = false
end

def bind(context)
@context = context
end

# Names of the flags found to be in a dependency cycle.
def cyclic_feature_names
@cyclic_feature_names ||= Set.new
end

# Whether a segment condition has read a flag.
def used?
@used
end

# Whether the context belongs to a segment, memoised once flags are read unless a dependency cycle was cut meanwhile.
def segment_matches?(segment)
return @segment_matches[segment] if @segment_matches&.key?(segment)

cycle_hits = @cycle_hits
matches = Flagsmith::Engine::Segments::Evaluator.context_in_segment?(@context, segment)
return false if @cycle_hits != cycle_hits

(@segment_matches ||= {}.compare_by_identity)[segment] = matches if @used
matches
end

def key?(feature_name)
!self[feature_name].nil?
end

private

def resolve(feature_name)
@used = true
resolver.resolve_feature(feature_name)
fetch(feature_name, nil)
end

def resolver
@resolver ||= DependencyResolver.new(@context, self)
end
end

# Resolves the flags of a context whose segments depend on other flags.
class DependencyResolver
include Flagsmith::Engine::Features::TargetingReasons

def initialize(context, flags)
@context = context
@flags = flags
@segments = context[:segments] || {}
@features_by_name = (context[:features] || {}).values.to_h { |feature| [feature[:name].to_sym, feature] }
@segment_keys_by_feature_name = segment_keys_by_feature_name
@resolving = []
end

def resolve_feature(feature_name)
return cut_cycle(feature_name) if @resolving.include?(feature_name)

feature = @features_by_name[feature_name]
return unless feature

segment_override = resolve_segment_override(feature_name)
return if @flags.cyclic_feature_names.include?(feature_name)

@flags.store(feature_name, flag_result(feature_name, feature, segment_override))
end

private

def cut_cycle(feature_name)
@flags.cyclic_feature_names.merge(@resolving.drop(@resolving.index(feature_name)))
@flags.cycle_hits += 1
end

def resolve_segment_override(feature_name)
@resolving.push(feature_name)
find_segment_override(feature_name)
ensure
@resolving.pop # A raise swallowed by JSONPath evaluation must not leave the name looking like a cycle
end

def find_segment_override(feature_name)
matching_segment_overrides(feature_name).reduce(nil) do |winner, candidate|
next candidate if winner.nil? || Flagsmith::Engine.stronger_priority?(candidate[:feature][:priority], winner[:feature][:priority])

winner
end
end

def matching_segment_overrides(feature_name)
@segment_keys_by_feature_name.fetch(feature_name, []).flat_map do |segment_key|
segment = @segments[segment_key]
next [] unless @flags.segment_matches?(segment)

Array(segment[:overrides])
.select { |override| override[:name].to_sym == feature_name }
.map { |override| { feature: override, segment_name: segment[:name] } }
end
end

def flag_result(feature_name, feature, segment_override)
identity_key = @context.dig(:identity, :key)
return Flagsmith::Engine.get_flag_result(feature, identity_key, fallback_reason(feature_name)) unless segment_override

Flagsmith::Engine.get_flag_result(segment_override[:feature], identity_key, Flagsmith::Engine.get_targeting_match_reason(segment_override))
end

def fallback_reason(feature_name)
@segment_keys_by_feature_name.key?(feature_name) ? TARGETING_REASON_DEFAULT : TARGETING_REASON_STATIC
end

def segment_keys_by_feature_name
@segments.each_with_object({}) do |(segment_key, segment), keys_by_name|
Array(segment[:overrides]).each do |override|
(keys_by_name[override[:name].to_sym] ||= []) << segment_key
end
end
end
end
end
end
Loading
Loading