Skip to content
Draft
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
41 changes: 37 additions & 4 deletions lib/ldclient-rb/evaluation_detail.rb
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ class EvaluationReason
# @return [Symbol]
attr_reader :big_segments_status

# True if an override affected this evaluation, directly or transitively. It is true when the
# evaluated flag came from the SDK's override store. It is also true when a prerequisite flag at
# any depth, or a segment read during the evaluation, came from the override store. In the JSON
# representation, the `overrideAffected` property appears only when this is true.
#
# Flag overrides are currently experimental and subject to change.
# @return [Boolean]
attr_reader :override_affected

# Returns an instance whose {#kind} is {#OFF}.
# @return [EvaluationReason]
def self.off
Expand Down Expand Up @@ -216,12 +225,14 @@ def ==(other)
if other.is_a? EvaluationReason
@kind == other.kind && @rule_index == other.rule_index && @rule_id == other.rule_id &&
@prerequisite_key == other.prerequisite_key && @error_kind == other.error_kind &&
@big_segments_status == other.big_segments_status
@big_segments_status == other.big_segments_status &&
@override_affected == other.override_affected
elsif other.is_a? Hash
@kind.to_s == other[:kind] && @rule_index == other[:ruleIndex] && @rule_id == other[:ruleId] &&
@prerequisite_key == other[:prerequisiteKey] &&
(other[:errorKind] == @error_kind.nil? ? nil : @error_kind.to_s) &&
(other[:bigSegmentsStatus] == @big_segments_status.nil? ? nil : @big_segments_status.to_s)
(other[:bigSegmentsStatus] == @big_segments_status.nil? ? nil : @big_segments_status.to_s) &&
!!other[:overrideAffected] == @override_affected
end
end

Expand Down Expand Up @@ -286,6 +297,8 @@ def as_json(*) # parameter is unused, but may be passed if we're using the json
unless @big_segments_status.nil?
ret[:bigSegmentsStatus] = @big_segments_status
end
# The property is written only when true, so the wire format of an ordinary evaluation is unchanged.
ret[:overrideAffected] = true if @override_affected
ret
end

Expand All @@ -312,22 +325,41 @@ def [](key)
@error_kind.nil? ? nil : @error_kind.to_s
when :bigSegmentsStatus
@big_segments_status.nil? ? nil : @big_segments_status.to_s
when :overrideAffected
@override_affected
else
nil
end
end

def with_big_segments_status(big_segments_status)
return self if @big_segments_status == big_segments_status
EvaluationReason.new(@kind, @rule_index, @rule_id, @prerequisite_key, @error_kind, @in_experiment, big_segments_status)
EvaluationReason.new(@kind, @rule_index, @rule_id, @prerequisite_key, @error_kind, @in_experiment,
big_segments_status, @override_affected)
end

#
# Returns a reason that is the same as this one apart from the {#override_affected} indicator.
# Returns this instance when the indicator already has the given value.
#
# Flag overrides are currently experimental and subject to change.
#
# @param override_affected [Boolean]
# @return [EvaluationReason]
#
def with_override_affected(override_affected)
override_affected = !!override_affected
return self if @override_affected == override_affected
EvaluationReason.new(@kind, @rule_index, @rule_id, @prerequisite_key, @error_kind, @in_experiment,
@big_segments_status, override_affected)
end

#
# Constructor that sets all properties. Applications should not normally use this constructor,
# but should use class methods like {#off} to avoid creating unnecessary instances.
#
def initialize(kind, rule_index, rule_id, prerequisite_key, error_kind, in_experiment=nil,
big_segments_status = nil)
big_segments_status = nil, override_affected = false)
@kind = kind.to_sym
@rule_index = rule_index
@rule_id = rule_id
Expand All @@ -337,6 +369,7 @@ def initialize(kind, rule_index, rule_id, prerequisite_key, error_kind, in_exper
@error_kind = error_kind
@in_experiment = in_experiment
@big_segments_status = big_segments_status
@override_affected = !!override_affected
end

private_class_method def self.make_error(error_kind)
Expand Down
80 changes: 62 additions & 18 deletions lib/ldclient-rb/impl/evaluator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ module Impl
PrerequisiteEvalRecord = Struct.new(
:prereq_flag, # the prerequisite flag that we evaluated
:prereq_of_flag, # the flag that it was a prerequisite of
:detail # the EvaluationDetail representing the evaluation result
:detail, # the EvaluationDetail representing the evaluation result
:override_affected # true if a definition read by the prerequisite's own evaluation came from the override store
)

class EvaluationException < StandardError
Expand All @@ -35,6 +36,9 @@ def initialize(original_flag)
@segment_stack = EvaluatorStack.new(nil)
@prerequisites = []
@depth = 0
# Reading the flag's own definition is the first read of the evaluation, so the marking
# starts from the flag's override marker.
@override_affected = original_flag.override?
end

def record_evaluated_prereq_key(key)
Expand All @@ -45,6 +49,10 @@ def record_evaluated_prereq_key(key)
attr_reader :prerequisites
attr_reader :prereq_stack
attr_reader :segment_stack
# True if the evaluation in progress has read a definition that carries the override marker.
# While a prerequisite is evaluated, this holds the marking of the prerequisite's own subtree.
# The marking propagates upward only.
attr_accessor :override_affected
end

#
Expand Down Expand Up @@ -130,7 +138,8 @@ def initialize(get_flag, get_segment, get_big_segments_membership, logger)
:detail, # the EvaluationDetail representing the evaluation result
:prereq_evals, # an array of PrerequisiteEvalRecord instances, or nil
:big_segments_status,
:big_segments_membership
:big_segments_membership,
:override_affected # true if any definition read during the evaluation came from the override store
)

# Helper function used internally to construct an EvaluationDetail for an error result.
Expand All @@ -152,23 +161,25 @@ def evaluate(flag, context)
result = EvalResult.new
begin
detail = eval_internal(flag, context, result, state)

unless result.big_segments_status.nil?
# If big_segments_status is non-nil at the end of the evaluation, it means a query was done at
# some point and we will want to include the status in the evaluation reason.
detail = EvaluationDetail.new(detail.value, detail.variation_index,
detail.reason.with_big_segments_status(result.big_segments_status))
end
rescue EvaluationException => exn
Impl::Util.log_exception(@logger, "Unexpected error when evaluating flag #{flag.key}", exn)
result.detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(exn.error_kind))
return result, state
detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(exn.error_kind))
rescue => exn
Impl::Util.log_exception(@logger, "Unexpected error when evaluating flag #{flag.key}", exn)
result.detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_EXCEPTION))
return result, state
detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_EXCEPTION))
end

unless result.big_segments_status.nil?
# If big_segments_status is non-nil at the end of the evaluation, it means a query was done at
# some point and we will want to include the status in the evaluation reason.
detail = EvaluationDetail.new(detail.value, detail.variation_index,
detail.reason.with_big_segments_status(result.big_segments_status))
end
result.detail = detail
# Error results are marked too. A malformed override definition yields an error reason, and an
# override still affected that result.
result.override_affected = state.override_affected
result.detail = mark_override_affected(detail, state.override_affected)
[result, state]
end

Expand Down Expand Up @@ -240,15 +251,27 @@ def self.make_big_segment_ref(segment) # method is visible for testing
@logger.error { "[LDClient] Could not retrieve prerequisite flag \"#{prereq_key}\" when evaluating \"#{flag.key}\"" }
prereq_ok = false
else
state.depth += 1
prereq_res = eval_internal(prereq_flag, context, eval_result, state)
state.depth -= 1
# The prerequisite's own record reflects only the definitions that its own subtree read.
# Its marking starts from its own definition. When it is done, the marking propagates
# upward into this flag's marking, also when the evaluation ends with an error.
parent_affected = state.override_affected
state.override_affected = prereq_flag.override?
prereq_affected = state.override_affected
begin
state.depth += 1
prereq_res = eval_internal(prereq_flag, context, eval_result, state)
ensure
state.depth -= 1
prereq_affected = state.override_affected
state.override_affected = parent_affected || prereq_affected
end
# Note that if the prerequisite flag is off, we don't consider it a match no matter what its
# off variation was. But we still need to evaluate it in order to generate an event.
if !prereq_flag.on || prereq_res.variation_index != prerequisite.variation
prereq_ok = false
end
prereq_eval = PrerequisiteEvalRecord.new(prereq_flag, flag, prereq_res)
prereq_res = mark_override_affected(prereq_res, prereq_affected)
prereq_eval = PrerequisiteEvalRecord.new(prereq_flag, flag, prereq_res, prereq_affected)
eval_result.prereq_evals = [] if eval_result.prereq_evals.nil?
eval_result.prereq_evals.push(prereq_eval)
end
Expand Down Expand Up @@ -294,7 +317,15 @@ def self.make_big_segment_ref(segment) # method is visible for testing
end

segment = @get_segment.call(v)
!segment.nil? && segment_match_context(segment, context, eval_result, state)
if segment.nil?
false
else
# The segment definition was read, so an override segment marks the evaluation here. A
# match is not required: a negated clause turns a non-match into a match, so the
# definition shapes the result either way.
state.override_affected = true if segment.override?
segment_match_context(segment, context, eval_result, state)
end
}
clause.negate ? !result : result
else
Expand Down Expand Up @@ -482,6 +513,19 @@ def self.make_big_segment_ref(segment) # method is visible for testing
bucket.nil? || bucket < weight
end

# Returns the detail with its reason marked as override-affected when the evaluation read a
# definition from the override store. Returns the same detail otherwise, so that the precomputed
# detail instances stay shared.
#
# @param detail [LaunchDarkly::EvaluationDetail]
# @param override_affected [Boolean]
# @return [LaunchDarkly::EvaluationDetail]
private def mark_override_affected(detail, override_affected)
return detail unless override_affected

EvaluationDetail.new(detail.value, detail.variation_index, detail.reason.with_override_affected(true))
end

private def get_value_for_variation_or_rollout(flag, vr, context, precomputed_results)
index, in_experiment = EvaluatorBucketing.variation_index_for_context(flag, vr, context)

Expand Down
34 changes: 34 additions & 0 deletions lib/ldclient-rb/impl/model/feature_flag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def initialize(data, logger = nil)
@key = data[:key]
@version = data[:version]
@deleted = !!data[:deleted]
@override = false
return if @deleted
migration_settings = data[:migration] || {}
@migration_settings = MigrationSettings.new(migration_settings[:checkRatio])
Expand Down Expand Up @@ -96,6 +97,35 @@ def initialize(data, logger = nil)
# @return [String]
attr_reader :salt

#
# True if this definition came from the SDK's override store rather than from LaunchDarkly
# data. The marker is not part of the flag data and is never serialized. Only the SDK
# components that manage override entries set it, through {#as_override}. Evaluation reads
# it to mark the evaluations it affects. Every other reader treats a marked definition the
# same as any other.
#
# Flag overrides are currently experimental and subject to change.
#
# @return [Boolean]
#
def override?
@override
end

#
# Returns a shallow copy of this flag that carries the override marker. The copy shares its
# data with this flag. Nothing writes to that data. This flag is not changed.
#
# Flag overrides are currently experimental and subject to change.
#
# @return [FeatureFlag]
#
def as_override
copy = dup
copy.override = true
copy
end

# This method allows us to read properties of the object as if it's just a hash. Currently this is
# necessary because some data store logic is still written to expect hashes; we can remove it once
# we migrate entirely to using attributes of the class.
Expand All @@ -115,6 +145,10 @@ def as_json(*) # parameter is unused, but may be passed if we're using the json
def to_json(*a)
as_json.to_json(*a)
end

protected

attr_writer :override
end

class Prerequisite
Expand Down
34 changes: 34 additions & 0 deletions lib/ldclient-rb/impl/model/segment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def initialize(data, logger = nil)
@key = data[:key]
@version = data[:version]
@deleted = !!data[:deleted]
@override = false
return if @deleted
@included = data[:included] || []
@excluded = data[:excluded] || []
Expand Down Expand Up @@ -67,6 +68,35 @@ def initialize(data, logger = nil)
# @return [String]
attr_reader :salt

#
# True if this definition came from the SDK's override store rather than from LaunchDarkly
# data. The marker is not part of the segment data and is never serialized. Only the SDK
# components that manage override entries set it, through {#as_override}. Evaluation reads
# it to mark the evaluations it affects. Every other reader treats a marked definition the
# same as any other.
#
# Flag overrides are currently experimental and subject to change.
#
# @return [Boolean]
#
def override?
@override
end

#
# Returns a shallow copy of this segment that carries the override marker. The copy shares
# its data with this segment. Nothing writes to that data. This segment is not changed.
#
# Flag overrides are currently experimental and subject to change.
#
# @return [Segment]
#
def as_override
copy = dup
copy.override = true
copy
end

# This method allows us to read properties of the object as if it's just a hash. Currently this is
# necessary because some data store logic is still written to expect hashes; we can remove it once
# we migrate entirely to using attributes of the class.
Expand All @@ -86,6 +116,10 @@ def as_json(*) # parameter is unused, but may be passed if we're using the json
def to_json(*a)
as_json.to_json(*a)
end

protected

attr_writer :override
end

class SegmentTarget
Expand Down
Loading
Loading