Skip to content

Commit aff3a67

Browse files
committed
feat: Add the override marker to the flag and segment models and mark evaluations
FeatureFlag and Segment gain an internal IsOverride marker and an AsOverride method that returns a marked copy sharing the immutable parts of the original. The marker lives on the model only and is never serialized. The evaluator marks an evaluation as override-affected when any definition it read carried the marker: the evaluated flag, a prerequisite at any depth, or a segment consulted during matching, whether or not the segment matched. The marking propagates upward only: a prerequisite record reflects only the definitions its own subtree read. Error results are marked too. The result is reported through the evaluation reason's OverrideAffected indicator, as defined by the OVERRIDE specification.
1 parent e4dfcf4 commit aff3a67

7 files changed

Lines changed: 595 additions & 22 deletions

File tree

‎pkgs/sdk/server/src/Internal/Evaluation/Evaluator.cs‎

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ internal struct EvalState
4848
internal ImmutableList<PrerequisiteEvalRecord>.Builder PrereqEvals;
4949
internal Dictionary<string, IMembership> BigSegmentsMembership;
5050
internal BigSegmentsStatus? BigSegmentsStatus;
51+
// True once the current evaluation scope has read a definition that carries the override
52+
// marker. Evaluate sets it from the evaluated flag. MatchClause sets it for each segment
53+
// that is read. CheckPrerequisites starts a nested scope from the prerequisite's own
54+
// marker and merges the nested result into this scope when it returns, so the marking
55+
// propagates upward only.
56+
internal bool OverrideAffected;
5157

5258
internal EvalState(Context context)
5359
{
@@ -57,6 +63,7 @@ internal EvalState(Context context)
5763
PrereqEvals = null;
5864
BigSegmentsMembership = null;
5965
BigSegmentsStatus = null;
66+
OverrideAffected = false;
6067
}
6168
}
6269

@@ -91,39 +98,46 @@ public EvalResult Evaluate(in FeatureFlag flag, in Context context)
9198
{
9299
throw new Exception(ErrorMessageForTesting);
93100
}
101+
102+
// Reading the flag's own definition is the first read of this evaluation, so the marking
103+
// starts from the flag's override marker.
104+
var state = new EvalState(context);
105+
state.OverrideAffected = flag.IsOverride;
106+
94107
if (!context.Valid)
95108
{
96109
Logger.Warn("Tried to evaluate flag with invalid context: {0} returning null",
97110
flag.Key);
98111

99-
return new EvalResult(
100-
new EvaluationDetail<LdValue>(LdValue.Null, null, EvaluationReason.ErrorReason(EvaluationErrorKind.UserNotSpecified)),
112+
return new EvalResult(ErrorResult(EvaluationErrorKind.UserNotSpecified, state.OverrideAffected),
101113
ImmutableList.Create<PrerequisiteEvalRecord>());
102114
}
103115

104116
try
105117
{
106-
var state = new EvalState(context);
107118
var details = EvaluateInternal(ref state, flag);
119+
var reason = details.Reason;
108120
if (state.BigSegmentsStatus.HasValue)
109121
{
110-
details = new EvaluationDetail<LdValue>(
111-
details.Value,
112-
details.VariationIndex,
113-
details.Reason.WithBigSegmentsStatus(state.BigSegmentsStatus.Value)
114-
);
122+
reason = reason.WithBigSegmentsStatus(state.BigSegmentsStatus.Value);
115123
}
124+
// Error reasons are marked too. A malformed override definition yields the caller's
125+
// default value with an error reason, and an override still affected that result.
126+
reason = reason.WithOverrideAffected(state.OverrideAffected);
127+
details = new EvaluationDetail<LdValue>(details.Value, details.VariationIndex, reason);
116128
return new EvalResult(details, state.PrereqEvals is null ?
117129
ImmutableList.Create<PrerequisiteEvalRecord>() : state.PrereqEvals.ToImmutable());
118130
}
119131
catch (StopEvaluationException e)
120132
{
121133
Logger.Error(@"Could not evaluate flag ""{0}"": {1}", flag.Key, string.Format(e.MessageFormat, e.MessageParams));
122-
return new EvalResult(ErrorResult(e.ErrorKind), ImmutableList.Create<PrerequisiteEvalRecord>());
134+
return new EvalResult(ErrorResult(e.ErrorKind, state.OverrideAffected),
135+
ImmutableList.Create<PrerequisiteEvalRecord>());
123136
}
124137
catch (Exception)
125138
{
126-
return new EvalResult(ErrorResult(EvaluationErrorKind.Exception), ImmutableList.Create<PrerequisiteEvalRecord>());
139+
return new EvalResult(ErrorResult(EvaluationErrorKind.Exception, state.OverrideAffected),
140+
ImmutableList.Create<PrerequisiteEvalRecord>());
127141
}
128142
}
129143

@@ -166,6 +180,10 @@ private EvaluationDetail<LdValue> EvaluateInternal(ref EvalState state, FeatureF
166180
private static EvaluationDetail<LdValue> ErrorResult(EvaluationErrorKind kind) =>
167181
new EvaluationDetail<LdValue>(LdValue.Null, null, EvaluationReason.ErrorReason(kind));
168182

183+
private static EvaluationDetail<LdValue> ErrorResult(EvaluationErrorKind kind, bool overrideAffected) =>
184+
new EvaluationDetail<LdValue>(LdValue.Null, null,
185+
EvaluationReason.ErrorReason(kind).WithOverrideAffected(overrideAffected));
186+
169187
private EvaluationDetail<LdValue> GetVariation(FeatureFlag flag, int variation, in EvaluationReason reason)
170188
{
171189
if (variation < 0 || variation >= flag.Variations.Count())
@@ -213,7 +231,23 @@ private EvaluationDetail<LdValue> GetOffValue(FeatureFlag flag, in EvaluationRea
213231
}
214232
else
215233
{
216-
var prereqDetails = EvaluateInternal(ref state, prereqFeatureFlag);
234+
// The nested evaluation is a scope of its own. It starts from the prerequisite's
235+
// own marker so that its record reflects only the definitions its subtree read.
236+
// When it returns, or throws, its marking merges into this scope. The marking
237+
// propagates upward only.
238+
var parentOverrideAffected = state.OverrideAffected;
239+
state.OverrideAffected = prereqFeatureFlag.IsOverride;
240+
EvaluationDetail<LdValue> prereqDetails;
241+
bool prereqOverrideAffected;
242+
try
243+
{
244+
prereqDetails = EvaluateInternal(ref state, prereqFeatureFlag);
245+
}
246+
finally
247+
{
248+
prereqOverrideAffected = state.OverrideAffected;
249+
state.OverrideAffected = parentOverrideAffected || prereqOverrideAffected;
250+
}
217251
// Note that if the prerequisite flag is off, we don't consider it a match no matter
218252
// what its off variation was. But we still need to evaluate it in order to generate
219253
// an event.
@@ -225,7 +259,9 @@ private EvaluationDetail<LdValue> GetOffValue(FeatureFlag flag, in EvaluationRea
225259
{
226260
state.PrereqEvals = ImmutableList.CreateBuilder<PrerequisiteEvalRecord>();
227261
}
228-
state.PrereqEvals.Add(new PrerequisiteEvalRecord(prereqFeatureFlag, flag.Key, prereqDetails));
262+
var prereqRecordDetails = new EvaluationDetail<LdValue>(prereqDetails.Value, prereqDetails.VariationIndex,
263+
prereqDetails.Reason.WithOverrideAffected(prereqOverrideAffected));
264+
state.PrereqEvals.Add(new PrerequisiteEvalRecord(prereqFeatureFlag, flag.Key, prereqRecordDetails));
229265
}
230266
if (!prereqOk)
231267
{

‎pkgs/sdk/server/src/Internal/Evaluation/EvaluatorClause.cs‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,19 @@ private bool MatchClause(ref EvalState state, in Clause clause)
1414
foreach (var value in clause.Values)
1515
{
1616
Segment segment = SegmentGetter(value.AsString);
17-
if (segment != null && MatchSegment(ref state, segment))
17+
if (segment != null)
1818
{
19-
return MaybeNegate(clause, true);
19+
// The segment definition is read here, so an override segment marks the
20+
// evaluation. A match is not required. A negated clause turns a non-match into
21+
// a match, so the definition shapes the result either way.
22+
if (segment.IsOverride)
23+
{
24+
state.OverrideAffected = true;
25+
}
26+
if (MatchSegment(ref state, segment))
27+
{
28+
return MaybeNegate(clause, true);
29+
}
2030
}
2131
}
2232
return MaybeNegate(clause, false);

‎pkgs/sdk/server/src/Internal/Model/FeatureFlag.cs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,17 @@ internal sealed class FeatureFlag : IJsonSerializable
3131

3232
public Migration? Migration { get; }
3333

34+
/// <summary>
35+
/// True if this definition came from the SDK's override store rather than from LaunchDarkly
36+
/// data. The evaluator reads this marker to mark the evaluation as override-affected. The
37+
/// marker lives on the model only. It is never serialized.
38+
/// </summary>
39+
internal bool IsOverride { get; }
40+
3441
internal FeatureFlag(string key, int version, bool deleted, bool on, IEnumerable<Prerequisite> prerequisites,
3542
ImmutableList<Target> targets, ImmutableList<Target> contextTargets, IEnumerable<FlagRule> rules, VariationOrRollout fallthrough, int? offVariation,
3643
IEnumerable<LdValue> variations, string salt, bool trackEvents, bool trackEventsFallthrough, UnixMillisecondTime? debugEventsUntilDate,
37-
bool clientSide, long? samplingRatio, bool excludeFromSummaries, Migration? migration)
44+
bool clientSide, long? samplingRatio, bool excludeFromSummaries, Migration? migration, bool isOverride = false)
3845
{
3946
Key = key;
4047
Version = version;
@@ -55,7 +62,18 @@ internal FeatureFlag(string key, int version, bool deleted, bool on, IEnumerable
5562
SamplingRatio = samplingRatio;
5663
ExcludeFromSummaries = excludeFromSummaries;
5764
Migration = migration;
65+
IsOverride = isOverride;
5866
}
67+
68+
/// <summary>
69+
/// Returns a copy of this flag that carries the override marker. The copy shares its immutable
70+
/// parts with this flag, which is left unchanged.
71+
/// </summary>
72+
internal FeatureFlag AsOverride() =>
73+
IsOverride ? this :
74+
new FeatureFlag(Key, Version, Deleted, On, Prerequisites, Targets, ContextTargets, Rules, Fallthrough,
75+
OffVariation, Variations, Salt, TrackEvents, TrackEventsFallthrough, DebugEventsUntilDate, ClientSide,
76+
SamplingRatio, ExcludeFromSummaries, Migration, true);
5977
}
6078

6179
internal struct Rollout

‎pkgs/sdk/server/src/Internal/Model/Segment.cs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ internal sealed class Segment : IJsonSerializable
2323
internal int? Generation { get; }
2424
internal PreprocessedData Preprocessed { get; }
2525

26+
/// <summary>
27+
/// True if this definition came from the SDK's override store rather than from LaunchDarkly
28+
/// data. The evaluator reads this marker to mark the evaluation as override-affected. The
29+
/// marker lives on the model only. It is never serialized.
30+
/// </summary>
31+
internal bool IsOverride { get; }
32+
2633
internal Segment(
2734
string key,
2835
int version,
@@ -35,7 +42,8 @@ internal Segment(
3542
string salt,
3643
bool unbounded,
3744
ContextKind? unboundedContextKind,
38-
int? generation
45+
int? generation,
46+
bool isOverride = false
3947
)
4048
{
4149
Key = key;
@@ -50,9 +58,19 @@ internal Segment(
5058
Unbounded = unbounded;
5159
UnboundedContextKind = unboundedContextKind;
5260
Generation = generation;
61+
IsOverride = isOverride;
5362
Preprocessed = Preprocess(Included, Excluded);
5463
}
5564

65+
/// <summary>
66+
/// Returns a copy of this segment that carries the override marker. The copy shares its
67+
/// immutable parts with this segment, which is left unchanged.
68+
/// </summary>
69+
internal Segment AsOverride() =>
70+
IsOverride ? this :
71+
new Segment(Key, Version, Deleted, Included, Excluded, IncludedContexts, ExcludedContexts, Rules, Salt,
72+
Unbounded, UnboundedContextKind, Generation, true);
73+
5674
private static PreprocessedData Preprocess(IEnumerable<string> included, IEnumerable<string> excluded) =>
5775
new PreprocessedData
5876
{

‎pkgs/sdk/server/test/DataModelTest.cs‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,23 +151,23 @@ public void OptionalSegmentStringPropertiesAreNullable()
151151
Assert.Null(segment1.Salt);
152152
}
153153

154-
private FeatureFlag MustParseFlag(string json)
154+
internal static FeatureFlag MustParseFlag(string json)
155155
{
156156
var item = DataModel.Features.Deserialize(json);
157157
var flag = Assert.IsType<FeatureFlag>(item.Item);
158158
Assert.Equal(flag.Version, item.Version);
159159
return flag;
160160
}
161161

162-
private Segment MustParseSegment(string json)
162+
internal static Segment MustParseSegment(string json)
163163
{
164164
var item = DataModel.Segments.Deserialize(json);
165165
var segment = Assert.IsType<Segment>(item.Item);
166166
Assert.Equal(segment.Version, item.Version);
167167
return segment;
168168
}
169169

170-
private string FlagWithAllPropertiesJson() => @"{
170+
internal static string FlagWithAllPropertiesJson() => @"{
171171
""key"": ""flag-key"",
172172
""version"": 99,
173173
""deleted"": false,
@@ -229,7 +229,7 @@ private string FlagWithAllPropertiesJson() => @"{
229229
""debugEventsUntilDate"": 1000
230230
}";
231231

232-
private void AssertFlagHasAllProperties(FeatureFlag flag)
232+
internal static void AssertFlagHasAllProperties(FeatureFlag flag)
233233
{
234234
Assert.Equal("flag-key", flag.Key);
235235
Assert.Equal(99, flag.Version);
@@ -311,7 +311,7 @@ private void AssertFlagHasAllProperties(FeatureFlag flag)
311311
Assert.Equal(UnixMillisecondTime.OfMillis(1000), flag.DebugEventsUntilDate);
312312
}
313313

314-
private string SegmentWithAllPropertiesJson() => @"{
314+
internal static string SegmentWithAllPropertiesJson() => @"{
315315
""key"": ""segment-key"",
316316
""version"": 99,
317317
""deleted"": false,
@@ -347,7 +347,7 @@ private string SegmentWithAllPropertiesJson() => @"{
347347
""generation"": 51
348348
}";
349349

350-
private void AssertSegmentHasAllProperties(Segment segment)
350+
internal static void AssertSegmentHasAllProperties(Segment segment)
351351
{
352352
Assert.Equal("segment-key", segment.Key);
353353
Assert.Equal(99, segment.Version);

0 commit comments

Comments
 (0)