Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .github/workflows/sdk-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ permissions:
jobs:
sdk-spec:
# Keep the ref in `uses:` and `spec-ref` in sync.
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.3.0
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0
with:
spec-ref: v0.3.0
spec-ref: v0.7.0
tags: core or local-evaluation
language: ruby
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[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
[submodule "spec/sdk-benchmarks"]
path = spec/sdk-benchmarks
url = https://github.com/Flagsmith/sdk-benchmarks.git
Expand Down
5 changes: 2 additions & 3 deletions lib/flagsmith.rb
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,12 @@ def get_identity_segments(identifier, traits = {})
private

def environment_flags_from_document # rubocop:disable Metrics/MethodLength
context = Flagsmith::Engine::Mappers.get_evaluation_context(environment)

unless context
unless environment
raise Flagsmith::ClientError,
'Unable to get flags. No environment present.'
end

context = Flagsmith::Engine::Mappers.get_environment_flags_evaluation_context(environment)
evaluation_result = Flagsmith::Engine.get_evaluation_result(context)

Flagsmith::Flags::Collection.from_evaluation_result(
Expand Down
116 changes: 74 additions & 42 deletions lib/flagsmith/engine/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

require 'semantic'
require 'securerandom'
require 'set'

require_relative 'environments/models'
require_relative 'features/models'
require_relative 'features/constants'
require_relative 'identities/models'
require_relative 'lazy_flags'
require_relative 'organisations/models'
require_relative 'projects/models'
require_relative 'segments/evaluator'
Expand All @@ -16,7 +18,7 @@

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 @@ -27,37 +29,42 @@ module Engine
# @param evaluation_context [Hash] The evaluation context
# @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)
lazy_flags = LazyFlags.new(get_enriched_context(evaluation_context))
evaluation_context = lazy_flags.context
segments, segment_overrides = evaluate_segments(evaluation_context, lazy_flags)
flags = evaluate_features(evaluation_context, segment_overrides)
lazy_flags.apply_to(flags) if lazy_flags.used?
{
flags: flags,
segments: segments
}
end

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

identity_segments = get_segments_from_context(evaluation_context)
matching_segments = evaluation_context[:segments].each_value.select do |segment|
cycle_hits = lazy_flags.cycle_hits
context_in_segment?(evaluation_context, segment) && lazy_flags.cycle_hits == cycle_hits
end

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

segment_overrides = process_segment_overrides(identity_segments)
segment_overrides = process_segment_overrides(matching_segments)

[segments, segment_overrides]
end

# Returns Record<string: override.name, SegmentOverride>
def process_segment_overrides(identity_segments) # rubocop:disable Metrics/MethodLength
def process_segment_overrides(matching_segments) # rubocop:disable Metrics/MethodLength
segment_overrides = {}

identity_segments.each do |segment|
matching_segments.each do |segment|
Array(segment[:overrides]).each do |override|
next unless should_apply_override(override, segment_overrides)
next unless wins_over?(override, segment_overrides[override[:name]]&.dig(:feature))

segment_overrides[override[:name]] = {
feature: override,
Expand All @@ -71,65 +78,94 @@ 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
flags[feature[:name].to_sym] =
get_feature_flag_result(feature, segment_overrides[feature[:name]], identity_key, targeted_feature_names)
end
end

# Returns the flag result of a feature, served by its segment override if any, or else by its environment default
def get_feature_flag_result(feature, segment_override, identity_key, targeted_feature_names)
return get_flag_result(segment_override[:feature], identity_key, targeting_match_reason(segment_override)) if segment_override

get_flag_result(feature, identity_key, fallback_reason(feature[:name], targeted_feature_names))
end

flag_result = build_flag_result(final_feature, identity_key, segment_override)
flags[final_feature[:name].to_sym] = flag_result
# 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 the reason for a flag served by a segment override
def targeting_match_reason(segment_override)
"#{TARGETING_REASON_TARGETING_MATCH}; segment=#{segment_override[:segment_name]}"
end

# Returns the reason for a flag served by its environment default:
# DEFAULT if the feature has segment overrides that did not match, STATIC if it has none
def fallback_reason(feature_name, targeted_feature_names)
targeted_feature_names.key?(feature_name) ? TARGETING_REASON_DEFAULT : TARGETING_REASON_STATIC
end

# Returns {value: any; reason?: string}
# Returns {name: string; enabled: boolean; value: any; reason: string; variant?: string; metadata?: any}
def get_flag_result(feature, identity_key, reason)
evaluated = evaluate_feature_value(feature, identity_key)

flag_result = {
name: feature[:name],
enabled: feature[:enabled],
value: evaluated[:value],
reason: evaluated[:reason] || reason,
variant: evaluated[:variant]
}

flag_result[:metadata] = feature[:metadata] if feature[:metadata]
flag_result
end

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

{ value: feature[:value], reason: nil }
{ value: feature[:value], reason: nil, variant: nil }
end

# Returns {value: any; reason?: string}
# Returns {value: any; reason?: string; variant: string}
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 }
sorted_variants = (feature[:variants] || []).sort_by { |v| v[:priority] || DEFAULT_PRIORITY }

variant = find_matching_variant(sorted_variants, percentage_value)
variant || { value: feature[:value], reason: nil }
variant || { value: feature[:value], reason: nil, variant: 'control' }
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], reason: "#{TARGETING_REASON_SPLIT}; weight=#{variant[:weight]}", variant: variant[:key] }
end

start_percentage = limit
end
nil
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])
# Whether a segment override takes precedence over the one held so far.
# The lowest priority wins, and the first seen wins a tie.
def wins_over?(candidate, incumbent)
incumbent.nil? || (candidate[:priority] || DEFAULT_PRIORITY) < (incumbent[:priority] || DEFAULT_PRIORITY)
end

private

def build_flag_result(feature, identity_key, segment_override)
evaluated = evaluate_feature_value(feature, identity_key)

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)
}

flag_result[:metadata] = feature[:metadata] if feature[:metadata]
flag_result
end

# Extract identity key from evaluation context
#
# @param evaluation_context [Hash] The evaluation context
Expand All @@ -139,9 +175,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,8 +6,10 @@ module Features
# Targeting reason constants for evaluation results
module TargetingReasons
TARGETING_REASON_DEFAULT = 'DEFAULT'
TARGETING_REASON_STATIC = 'STATIC'
TARGETING_REASON_TARGETING_MATCH = 'TARGETING_MATCH'
TARGETING_REASON_SPLIT = 'SPLIT'
TARGETING_REASON_CIRCULAR_DEPENDENCY = 'ERROR; code=CIRCULAR_DEPENDENCY'
end
end
end
Expand Down
118 changes: 118 additions & 0 deletions lib/flagsmith/engine/lazy_flags.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# frozen_string_literal: true

require 'set'

module Flagsmith
module Engine
# The `$.flags` of an evaluation context, resolving a flag the first time a segment condition reads it.
#
# Flags are only reachable by name: JSONPath reads them through `dig`.
class LazyFlags
# The evaluation context, with `$.flags` bound to this instance
attr_reader :context

# Incremented whenever a dependency cycle is cut. A segment whose evaluation increments it
# read a flag that could not be resolved, so it must not match.
attr_reader :cycle_hits

def initialize(context)
@context = context.merge(flags: self)
@cycle_hits = 0
end

# @param feature_name [String]
# @return [Hash, nil] The flag result, or nil for a feature absent from the context or in a cycle
def dig(feature_name)
start unless used?
resolve(feature_name) unless @resolved.key?(feature_name)
@resolved[feature_name]
end

# Whether a segment condition read any flag
def used?
!@resolved.nil?
end

# Applies the flags resolved for segment conditions over the single-pass flag results
#
# @param flags [Hash{Symbol => Hash}] The flag results, keyed by feature name
def apply_to(flags)
@resolved.each_value { |flag| flags[flag[:name].to_sym] = flag }
@cyclic.each do |feature_name|
flag = flags[feature_name.to_sym]
flag[:reason] = Features::TargetingReasons::TARGETING_REASON_CIRCULAR_DEPENDENCY if flag
end
end

private

def start
@resolved = {}
@cyclic = Set.new
@resolving = []
@segment_matches = {}
@segment_keys_by_feature_name = {}
(@context[:segments] || {}).each do |segment_key, segment|
Array(segment[:overrides]).each do |override|
(@segment_keys_by_feature_name[override[:name]] ||= []) << segment_key
end
end
end

def resolve(feature_name) # rubocop:disable Metrics/MethodLength
if @resolving.include?(feature_name)
@cyclic.merge(@resolving[@resolving.index(feature_name)..])
@cycle_hits += 1
return
end

feature = find_feature(feature_name)
return unless feature

@resolving.push(feature_name)
begin
segment_override = find_segment_override(feature_name)
ensure
@resolving.pop
end
return if @cyclic.include?(feature_name)

@resolved[feature_name] = Engine.get_feature_flag_result(
feature, segment_override, @context.dig(:identity, :key), @segment_keys_by_feature_name
)
end

def find_feature(feature_name)
features = @context[:features]
return nil unless features

features[feature_name] || features[feature_name.to_sym]
end

def find_segment_override(feature_name)
segment_override = nil
@segment_keys_by_feature_name.fetch(feature_name, []).each do |segment_key|
next unless segment_match?(segment_key)

segment = @context[:segments][segment_key]
segment[:overrides].each do |override|
next unless override[:name] == feature_name && Engine.wins_over?(override, segment_override&.dig(:feature))

segment_override = { feature: override, segment_name: segment[:name] }
end
end
segment_override
end

def segment_match?(segment_key)
return @segment_matches[segment_key] if @segment_matches.key?(segment_key)

cycle_hits = @cycle_hits
matches = Segments::Evaluator.context_in_segment?(@context, @context[:segments][segment_key])
return false if @cycle_hits != cycle_hits # Rests on a flag that could not be resolved

@segment_matches[segment_key] = matches
end
end
end
end
Loading
Loading