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
6 changes: 6 additions & 0 deletions ldclient/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ def reason(self) -> dict:
* ``inExperiment``: This is true if the evaluation resulted in an experiment rollout and
served one of the variations in the experiment. Otherwise it is false.

* ``overrideAffected``: This is true if a flag override affected the evaluation, directly
or indirectly. It is present only when true. An evaluation is affected when the flag
itself, a prerequisite flag at any depth, or a segment consulted during rule matching
came from the SDK's override store. Flag overrides are currently experimental and
subject to change.

* ``bigSegmentsStatus``: describes the validity of Big Segment information, if and only if
the flag evaluation required querying at least one Big Segment; otherwise it returns None.
Allowable values are defined in :class:`BigSegmentsStatus`. For more information, read the
Expand Down
38 changes: 33 additions & 5 deletions ldclient/impl/async_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
_match_clause_by_kind,
_match_single_context_value,
_maybe_negate,
check_targets
check_targets,
mark_override_affected
)
from ldclient.impl.events.types import EventFactory
from ldclient.impl.model import *
Expand Down Expand Up @@ -56,15 +57,23 @@ def __init__(
async def evaluate(self, flag: FeatureFlag, context: Context, event_factory: EventFactory) -> EvalResult:
state = EvalResult()
state.original_flag_key = flag.key
# Reading the flag's own definition is the first read of the evaluation, so the marking
# starts from the flag's override marker.
state.override_affected = flag.is_override
try:
state.detail = await self._evaluate(flag, context, state, event_factory)
except EvaluationException as e:
if self.__logger is not None:
self.__logger.error('Could not evaluate flag "%s": %s' % (flag.key, e.message))
state.detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': e.error_kind})
# An evaluation that fails is still marked when it read an override definition.
if state.override_affected:
mark_override_affected(state.detail)
return state
if state.big_segments_status is not None:
state.detail.reason['bigSegmentsStatus'] = state.big_segments_status
if state.override_affected:
mark_override_affected(state.detail)
return state

async def _evaluate(self, flag: FeatureFlag, context: Context, state: EvalResult, event_factory: EventFactory) -> EvaluationDetail:
Expand Down Expand Up @@ -120,9 +129,21 @@ async def _check_prerequisites(self, flag: FeatureFlag, context: Context, state:
log.warning("Missing prereq flag: " + prereq_key)
failed_prereq = prereq
else:
state.depth += 1
prereq_res = await self._evaluate(prereq_flag, context, state, event_factory)
state.depth -= 1
# The prerequisite is an evaluation in its own right. Its marking starts from
# its own definition and covers only the definitions its subtree reads. The
# parent's marking is restored and merged afterward, so the marking propagates
# upward only. A failure inside the subtree still merges what was read.
parent_override_affected = state.override_affected
state.override_affected = prereq_flag.is_override
try:
state.depth += 1
prereq_res = await self._evaluate(prereq_flag, context, state, event_factory)
state.depth -= 1
finally:
prereq_override_affected = state.override_affected
state.override_affected = parent_override_affected or prereq_override_affected
if prereq_override_affected:
mark_override_affected(prereq_res)
# 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 (not prereq_flag.on) or prereq_res.variation_index != prereq.variation:
Expand All @@ -146,7 +167,14 @@ async def _clause_matches_context(self, clause: Clause, context: Context, state:
if clause.op == 'segmentMatch':
for seg_key in clause.values:
segment = await self.__get_segment(seg_key)
if segment is not None and await self._segment_matches_context(segment, context, state):
if segment is None:
continue
# The segment definition was read at this point, so an override segment marks the
# evaluation whether or not it matches. A segment that does not match still shapes
# the outcome, for example through a negated clause.
if segment.is_override:
state.override_affected = True
if await self._segment_matches_context(segment, context, state):
return _maybe_negate(clause, True)
return _maybe_negate(clause, False)

Expand Down
38 changes: 33 additions & 5 deletions ldclient/impl/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
_maybe_negate,
_variation_index_for_context,
check_targets,
error_reason
error_reason,
mark_override_affected
)
from ldclient.impl.events.types import EventFactory
from ldclient.impl.model import *
Expand Down Expand Up @@ -57,15 +58,23 @@ def __init__(
def evaluate(self, flag: FeatureFlag, context: Context, event_factory: EventFactory) -> EvalResult:
state = EvalResult()
state.original_flag_key = flag.key
# Reading the flag's own definition is the first read of the evaluation, so the marking
# starts from the flag's override marker.
state.override_affected = flag.is_override
try:
state.detail = self._evaluate(flag, context, state, event_factory)
except EvaluationException as e:
if self.__logger is not None:
self.__logger.error('Could not evaluate flag "%s": %s' % (flag.key, e.message))
state.detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': e.error_kind})
# An evaluation that fails is still marked when it read an override definition.
if state.override_affected:
mark_override_affected(state.detail)
return state
if state.big_segments_status is not None:
state.detail.reason['bigSegmentsStatus'] = state.big_segments_status
if state.override_affected:
mark_override_affected(state.detail)
return state

def _evaluate(self, flag: FeatureFlag, context: Context, state: EvalResult, event_factory: EventFactory) -> EvaluationDetail:
Expand Down Expand Up @@ -118,9 +127,21 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR
log.warning("Missing prereq flag: " + prereq_key)
failed_prereq = prereq
else:
state.depth += 1
prereq_res = self._evaluate(prereq_flag, context, state, event_factory)
state.depth -= 1
# The prerequisite is an evaluation in its own right. Its marking starts from
# its own definition and covers only the definitions its subtree reads. The
# parent's marking is restored and merged afterward, so the marking propagates
# upward only. A failure inside the subtree still merges what was read.
parent_override_affected = state.override_affected
state.override_affected = prereq_flag.is_override
try:
state.depth += 1
prereq_res = self._evaluate(prereq_flag, context, state, event_factory)
state.depth -= 1
finally:
prereq_override_affected = state.override_affected
state.override_affected = parent_override_affected or prereq_override_affected
if prereq_override_affected:
mark_override_affected(prereq_res)
# 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 (not prereq_flag.on) or prereq_res.variation_index != prereq.variation:
Expand All @@ -144,7 +165,14 @@ def _clause_matches_context(self, clause: Clause, context: Context, state: EvalR
if clause.op == 'segmentMatch':
for seg_key in clause.values:
segment = self.__get_segment(seg_key)
if segment is not None and self._segment_matches_context(segment, context, state):
if segment is None:
continue
# The segment definition was read at this point, so an override segment marks the
# evaluation whether or not it matches. A segment that does not match still shapes
# the outcome, for example through a negated clause.
if segment.is_override:
state.override_affected = True
if self._segment_matches_context(segment, context, state):
return _maybe_negate(clause, True)
return _maybe_negate(clause, False)

Expand Down
16 changes: 15 additions & 1 deletion ldclient/impl/evaluator_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
# prerequisite evaluations, and the cached state of any Big Segments query that we may have
# ended up having to do for the context.
class EvalResult:
__slots__ = ['detail', 'events', 'big_segments_status', 'big_segments_membership', 'original_flag_key', 'prereq_stack', 'segment_stack', 'depth', 'prerequisites']
__slots__ = ['detail', 'events', 'big_segments_status', 'big_segments_membership', 'original_flag_key', 'prereq_stack', 'segment_stack', 'depth', 'prerequisites', 'override_affected']

def __init__(self):
self.detail = None
Expand All @@ -37,6 +37,11 @@ def __init__(self):
self.segment_stack = None # type: Optional[List[str]]
self.depth = 0
self.prerequisites = [] # type: List[str]
# True when the evaluation in progress has read a flag or segment definition that carries
# the override marker. While a prerequisite is evaluated, this holds the marking of the
# prerequisite's own subtree. The evaluator merges it into the parent's marking afterward,
# so the marking propagates upward only.
self.override_affected = False

def record_prerequisite(self, key: str):
if self.depth == 0:
Expand Down Expand Up @@ -255,3 +260,12 @@ def check_targets(flag: FeatureFlag, context: Context) -> Optional[EvaluationDet

def error_reason(error_kind: str) -> dict:
return {'kind': 'ERROR', 'errorKind': error_kind}


def mark_override_affected(detail: EvaluationDetail) -> None:
"""
Sets the override-affected indicator on the evaluation reason. The indicator is present
only when true, so a reason that was not affected by an override is left untouched and
serializes exactly as before.
"""
detail.reason['overrideAffected'] = True
24 changes: 24 additions & 0 deletions ldclient/impl/model/feature_flag.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
from typing import Any, Dict, List, Optional, Set, Union

from ldclient.impl.model.clause import Clause
Expand Down Expand Up @@ -96,10 +97,12 @@ class FeatureFlag(ModelEntity):
'_salt',
'_track_events',
'_debug_events_until_date',
'_is_override',
]

def __init__(self, data: dict):
super().__init__(data)
self._is_override = False
# In the following logic, we're being somewhat lenient in terms of allowing most properties to
# be absent even if they are really required in the schema. That's for backward compatibility
# with test logic that constructed incomplete JSON, and also with the file data source which
Expand Down Expand Up @@ -202,3 +205,24 @@ def exclude_from_summaries(self) -> bool:
@property
def sampling_ratio(self) -> Optional[int]:
return self._sampling_ratio

@property
def is_override(self) -> bool:
"""
True if this flag definition was supplied by an SDK override source rather than by
LaunchDarkly data. The marker is never part of the JSON representation. Only the SDK
components that manage override entries set it. Components that read a definition
through the store may treat a marked definition the same as any other.

Flag overrides are currently experimental and subject to change.
"""
return self._is_override

def with_override_marker(self) -> 'FeatureFlag':
"""
Returns a shallow copy of this flag with the override marker set. The copy shares the
underlying data with this instance, which is left unmarked.
"""
marked = copy.copy(self)
marked._is_override = True
return marked
24 changes: 24 additions & 0 deletions ldclient/impl/model/segment.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
from typing import Any, List, Optional, Set

from ldclient.impl.model.attribute_ref import (
Expand Down Expand Up @@ -65,10 +66,12 @@ class Segment(ModelEntity):
'_unbounded',
'_unbounded_context_kind',
'_generation',
'_is_override',
]

def __init__(self, data: dict):
super().__init__(data)
self._is_override = False
# In the following logic, we're being somewhat lenient in terms of allowing most properties to
# be absent even if they are really required in the schema. That's for backward compatibility
# with test logic that constructed incomplete JSON, and also with the file data source which
Expand Down Expand Up @@ -137,3 +140,24 @@ def unbounded_context_kind(self) -> Optional[str]:
@property
def generation(self) -> Optional[int]:
return self._generation

@property
def is_override(self) -> bool:
"""
True if this segment definition was supplied by an SDK override source rather than by
LaunchDarkly data. The marker is never part of the JSON representation. Only the SDK
components that manage override entries set it. Components that read a definition
through the store may treat a marked definition the same as any other.

Flag overrides are currently experimental and subject to change.
"""
return self._is_override

def with_override_marker(self) -> 'Segment':
"""
Returns a shallow copy of this segment with the override marker set. The copy shares the
underlying data with this instance, which is left unmarked.
"""
marked = copy.copy(self)
marked._is_override = True
return marked
Loading
Loading