Skip to content

Commit ee313c1

Browse files
committed
feat: Add the override marker to the models and mark evaluations
Adds an override marker to the FeatureFlag and Segment models. The marker is an attribute of the model type, never part of the JSON representation, so serializing an entity or writing it to a persistent store never carries it. A marked shallow copy is produced with with_override_marker(), which leaves the source entity unmarked and shares its definition. The evaluator marks an evaluation as override-affected when any definition it reads carries the marker: the evaluated flag, a prerequisite at any depth, or a segment consulted during rule matching, including a segment that does not match and a segment referenced by another segment. The marking propagates upward only. A prerequisite's own record reflects the definitions its subtree read, so an unaffected prerequisite inside a marked evaluation is not marked and a marked prerequisite does not mark a sibling. An evaluation that fails with an error reason is still marked when it read an override definition. The marking appears on the evaluation reason as the overrideAffected indicator, present only when true, and on EvalResult.override_affected for the client and event processing. The async evaluator mirrors the sync one.
1 parent 7e64242 commit ee313c1

9 files changed

Lines changed: 681 additions & 11 deletions

‎ldclient/evaluation.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ def reason(self) -> dict:
5757
* ``inExperiment``: This is true if the evaluation resulted in an experiment rollout and
5858
served one of the variations in the experiment. Otherwise it is false.
5959
60+
* ``overrideAffected``: This is true if a flag override affected the evaluation, directly
61+
or indirectly. It is present only when true. An evaluation is affected when the flag
62+
itself, a prerequisite flag at any depth, or a segment consulted during rule matching
63+
came from the SDK's override store. Flag overrides are currently experimental and
64+
subject to change.
65+
6066
* ``bigSegmentsStatus``: describes the validity of Big Segment information, if and only if
6167
the flag evaluation required querying at least one Big Segment; otherwise it returns None.
6268
Allowable values are defined in :class:`BigSegmentsStatus`. For more information, read the

‎ldclient/impl/async_evaluator.py‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
_match_clause_by_kind,
1616
_match_single_context_value,
1717
_maybe_negate,
18-
check_targets
18+
check_targets,
19+
mark_override_affected
1920
)
2021
from ldclient.impl.events.types import EventFactory
2122
from ldclient.impl.model import *
@@ -56,15 +57,23 @@ def __init__(
5657
async def evaluate(self, flag: FeatureFlag, context: Context, event_factory: EventFactory) -> EvalResult:
5758
state = EvalResult()
5859
state.original_flag_key = flag.key
60+
# Reading the flag's own definition is the first read of the evaluation, so the marking
61+
# starts from the flag's override marker.
62+
state.override_affected = flag.is_override
5963
try:
6064
state.detail = await self._evaluate(flag, context, state, event_factory)
6165
except EvaluationException as e:
6266
if self.__logger is not None:
6367
self.__logger.error('Could not evaluate flag "%s": %s' % (flag.key, e.message))
6468
state.detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': e.error_kind})
69+
# An evaluation that fails is still marked when it read an override definition.
70+
if state.override_affected:
71+
mark_override_affected(state.detail)
6572
return state
6673
if state.big_segments_status is not None:
6774
state.detail.reason['bigSegmentsStatus'] = state.big_segments_status
75+
if state.override_affected:
76+
mark_override_affected(state.detail)
6877
return state
6978

7079
async def _evaluate(self, flag: FeatureFlag, context: Context, state: EvalResult, event_factory: EventFactory) -> EvaluationDetail:
@@ -120,9 +129,21 @@ async def _check_prerequisites(self, flag: FeatureFlag, context: Context, state:
120129
log.warning("Missing prereq flag: " + prereq_key)
121130
failed_prereq = prereq
122131
else:
123-
state.depth += 1
124-
prereq_res = await self._evaluate(prereq_flag, context, state, event_factory)
125-
state.depth -= 1
132+
# The prerequisite is an evaluation in its own right. Its marking starts from
133+
# its own definition and covers only the definitions its subtree reads. The
134+
# parent's marking is restored and merged afterward, so the marking propagates
135+
# upward only. A failure inside the subtree still merges what was read.
136+
parent_override_affected = state.override_affected
137+
state.override_affected = prereq_flag.is_override
138+
try:
139+
state.depth += 1
140+
prereq_res = await self._evaluate(prereq_flag, context, state, event_factory)
141+
state.depth -= 1
142+
finally:
143+
prereq_override_affected = state.override_affected
144+
state.override_affected = parent_override_affected or prereq_override_affected
145+
if prereq_override_affected:
146+
mark_override_affected(prereq_res)
126147
# Note that if the prerequisite flag is off, we don't consider it a match no matter what its
127148
# off variation was. But we still need to evaluate it in order to generate an event.
128149
if (not prereq_flag.on) or prereq_res.variation_index != prereq.variation:
@@ -146,7 +167,14 @@ async def _clause_matches_context(self, clause: Clause, context: Context, state:
146167
if clause.op == 'segmentMatch':
147168
for seg_key in clause.values:
148169
segment = await self.__get_segment(seg_key)
149-
if segment is not None and await self._segment_matches_context(segment, context, state):
170+
if segment is None:
171+
continue
172+
# The segment definition was read at this point, so an override segment marks the
173+
# evaluation whether or not it matches. A segment that does not match still shapes
174+
# the outcome, for example through a negated clause.
175+
if segment.is_override:
176+
state.override_affected = True
177+
if await self._segment_matches_context(segment, context, state):
150178
return _maybe_negate(clause, True)
151179
return _maybe_negate(clause, False)
152180

‎ldclient/impl/evaluator.py‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
_maybe_negate,
1818
_variation_index_for_context,
1919
check_targets,
20-
error_reason
20+
error_reason,
21+
mark_override_affected
2122
)
2223
from ldclient.impl.events.types import EventFactory
2324
from ldclient.impl.model import *
@@ -57,15 +58,23 @@ def __init__(
5758
def evaluate(self, flag: FeatureFlag, context: Context, event_factory: EventFactory) -> EvalResult:
5859
state = EvalResult()
5960
state.original_flag_key = flag.key
61+
# Reading the flag's own definition is the first read of the evaluation, so the marking
62+
# starts from the flag's override marker.
63+
state.override_affected = flag.is_override
6064
try:
6165
state.detail = self._evaluate(flag, context, state, event_factory)
6266
except EvaluationException as e:
6367
if self.__logger is not None:
6468
self.__logger.error('Could not evaluate flag "%s": %s' % (flag.key, e.message))
6569
state.detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': e.error_kind})
70+
# An evaluation that fails is still marked when it read an override definition.
71+
if state.override_affected:
72+
mark_override_affected(state.detail)
6673
return state
6774
if state.big_segments_status is not None:
6875
state.detail.reason['bigSegmentsStatus'] = state.big_segments_status
76+
if state.override_affected:
77+
mark_override_affected(state.detail)
6978
return state
7079

7180
def _evaluate(self, flag: FeatureFlag, context: Context, state: EvalResult, event_factory: EventFactory) -> EvaluationDetail:
@@ -118,9 +127,21 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR
118127
log.warning("Missing prereq flag: " + prereq_key)
119128
failed_prereq = prereq
120129
else:
121-
state.depth += 1
122-
prereq_res = self._evaluate(prereq_flag, context, state, event_factory)
123-
state.depth -= 1
130+
# The prerequisite is an evaluation in its own right. Its marking starts from
131+
# its own definition and covers only the definitions its subtree reads. The
132+
# parent's marking is restored and merged afterward, so the marking propagates
133+
# upward only. A failure inside the subtree still merges what was read.
134+
parent_override_affected = state.override_affected
135+
state.override_affected = prereq_flag.is_override
136+
try:
137+
state.depth += 1
138+
prereq_res = self._evaluate(prereq_flag, context, state, event_factory)
139+
state.depth -= 1
140+
finally:
141+
prereq_override_affected = state.override_affected
142+
state.override_affected = parent_override_affected or prereq_override_affected
143+
if prereq_override_affected:
144+
mark_override_affected(prereq_res)
124145
# Note that if the prerequisite flag is off, we don't consider it a match no matter what its
125146
# off variation was. But we still need to evaluate it in order to generate an event.
126147
if (not prereq_flag.on) or prereq_res.variation_index != prereq.variation:
@@ -144,7 +165,14 @@ def _clause_matches_context(self, clause: Clause, context: Context, state: EvalR
144165
if clause.op == 'segmentMatch':
145166
for seg_key in clause.values:
146167
segment = self.__get_segment(seg_key)
147-
if segment is not None and self._segment_matches_context(segment, context, state):
168+
if segment is None:
169+
continue
170+
# The segment definition was read at this point, so an override segment marks the
171+
# evaluation whether or not it matches. A segment that does not match still shapes
172+
# the outcome, for example through a negated clause.
173+
if segment.is_override:
174+
state.override_affected = True
175+
if self._segment_matches_context(segment, context, state):
148176
return _maybe_negate(clause, True)
149177
return _maybe_negate(clause, False)
150178

‎ldclient/impl/evaluator_common.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
# prerequisite evaluations, and the cached state of any Big Segments query that we may have
2626
# ended up having to do for the context.
2727
class EvalResult:
28-
__slots__ = ['detail', 'events', 'big_segments_status', 'big_segments_membership', 'original_flag_key', 'prereq_stack', 'segment_stack', 'depth', 'prerequisites']
28+
__slots__ = ['detail', 'events', 'big_segments_status', 'big_segments_membership', 'original_flag_key', 'prereq_stack', 'segment_stack', 'depth', 'prerequisites', 'override_affected']
2929

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

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

256261
def error_reason(error_kind: str) -> dict:
257262
return {'kind': 'ERROR', 'errorKind': error_kind}
263+
264+
265+
def mark_override_affected(detail: EvaluationDetail) -> None:
266+
"""
267+
Sets the override-affected indicator on the evaluation reason. The indicator is present
268+
only when true, so a reason that was not affected by an override is left untouched and
269+
serializes exactly as before.
270+
"""
271+
detail.reason['overrideAffected'] = True

‎ldclient/impl/model/feature_flag.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
from typing import Any, Dict, List, Optional, Set, Union
23

34
from ldclient.impl.model.clause import Clause
@@ -96,10 +97,12 @@ class FeatureFlag(ModelEntity):
9697
'_salt',
9798
'_track_events',
9899
'_debug_events_until_date',
100+
'_is_override',
99101
]
100102

101103
def __init__(self, data: dict):
102104
super().__init__(data)
105+
self._is_override = False
103106
# In the following logic, we're being somewhat lenient in terms of allowing most properties to
104107
# be absent even if they are really required in the schema. That's for backward compatibility
105108
# with test logic that constructed incomplete JSON, and also with the file data source which
@@ -202,3 +205,24 @@ def exclude_from_summaries(self) -> bool:
202205
@property
203206
def sampling_ratio(self) -> Optional[int]:
204207
return self._sampling_ratio
208+
209+
@property
210+
def is_override(self) -> bool:
211+
"""
212+
True if this flag definition was supplied by an SDK override source rather than by
213+
LaunchDarkly data. The marker is never part of the JSON representation. Only the SDK
214+
components that manage override entries set it. Components that read a definition
215+
through the store may treat a marked definition the same as any other.
216+
217+
Flag overrides are currently experimental and subject to change.
218+
"""
219+
return self._is_override
220+
221+
def with_override_marker(self) -> 'FeatureFlag':
222+
"""
223+
Returns a shallow copy of this flag with the override marker set. The copy shares the
224+
underlying data with this instance, which is left unmarked.
225+
"""
226+
marked = copy.copy(self)
227+
marked._is_override = True
228+
return marked

‎ldclient/impl/model/segment.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
from typing import Any, List, Optional, Set
23

34
from ldclient.impl.model.attribute_ref import (
@@ -65,10 +66,12 @@ class Segment(ModelEntity):
6566
'_unbounded',
6667
'_unbounded_context_kind',
6768
'_generation',
69+
'_is_override',
6870
]
6971

7072
def __init__(self, data: dict):
7173
super().__init__(data)
74+
self._is_override = False
7275
# In the following logic, we're being somewhat lenient in terms of allowing most properties to
7376
# be absent even if they are really required in the schema. That's for backward compatibility
7477
# with test logic that constructed incomplete JSON, and also with the file data source which
@@ -137,3 +140,24 @@ def unbounded_context_kind(self) -> Optional[str]:
137140
@property
138141
def generation(self) -> Optional[int]:
139142
return self._generation
143+
144+
@property
145+
def is_override(self) -> bool:
146+
"""
147+
True if this segment definition was supplied by an SDK override source rather than by
148+
LaunchDarkly data. The marker is never part of the JSON representation. Only the SDK
149+
components that manage override entries set it. Components that read a definition
150+
through the store may treat a marked definition the same as any other.
151+
152+
Flag overrides are currently experimental and subject to change.
153+
"""
154+
return self._is_override
155+
156+
def with_override_marker(self) -> 'Segment':
157+
"""
158+
Returns a shallow copy of this segment with the override marker set. The copy shares the
159+
underlying data with this instance, which is left unmarked.
160+
"""
161+
marked = copy.copy(self)
162+
marked._is_override = True
163+
return marked

0 commit comments

Comments
 (0)